ap actor profile

This commit is contained in:
2026-04-30 11:27:38 +03:00
parent 1e01d7e344
commit 944dadb7ed
+185
View File
@@ -0,0 +1,185 @@
use std::sync::Arc;
use axum::Json;
use axum::extract::{Path, Query, State};
use axum::http::header;
use axum::response::{IntoResponse, Response};
use crate::db;
use crate::state::AppState;
use super::types::*;
#[derive(serde::Deserialize)]
pub struct WebFingerQuery {
resource: String,
}
pub async fn webfinger(
State(state): State<Arc<AppState>>,
Query(q): Query<WebFingerQuery>,
) -> Result<Json<WebFingerResponse>, Response> {
let ap = state.ap.as_ref().ok_or_else(|| (axum::http::StatusCode::NOT_FOUND, "not found").into_response())?;
let expected = format!("acct:{}@{}", ap.actor_name, ap.domain);
// also match group actors like acct:cruciverb-animals@domain
let prefix = format!("acct:{}-", ap.actor_name);
if q.resource.starts_with(&prefix) && q.resource.contains(&format!("@{}", ap.domain)) {
let name = q.resource.strip_prefix("acct:").unwrap_or("").split('@').next().unwrap_or("");
let uri = format!("https://{}/@{}", ap.domain, name);
return Ok(Json(WebFingerResponse {
subject: q.resource.clone(),
links: vec![WebFingerLink {
rel: "self".into(),
link_type: Some("application/activity+json".into()),
href: uri,
}],
}));
}
if q.resource != expected {
return Err((axum::http::StatusCode::NOT_FOUND, "not found").into_response());
}
Ok(Json(WebFingerResponse {
subject: expected,
links: vec![WebFingerLink {
rel: "self".into(),
link_type: Some("application/activity+json".into()),
href: ap.actor_uri.clone(),
}],
}))
}
pub async fn actor_document(
State(state): State<Arc<AppState>>,
Path(name): Path<String>,
) -> Result<Response, Response> {
let ap = state.ap.as_ref().ok_or_else(|| (axum::http::StatusCode::NOT_FOUND, "not found").into_response())?;
// check if this is a group actor request (e.g. @cruciverb-animals)
let prefix = format!("{}-", ap.actor_name);
if name.starts_with(&prefix) && name.len() > prefix.len() {
let theme = &name[prefix.len()..];
return group_actor_document(ap, theme).await;
}
// only respond for the configured actor name
if name != ap.actor_name {
return Err((axum::http::StatusCode::NOT_FOUND, "not found").into_response());
}
let default_bio = "Daily crossword puzzles in five sizes, four game modes, cryptic clues, multiplayer rooms, party games, a built-in puzzle editor for constructors, and a clue trainer for the stubborn. Self-hosted on whatever you call a server. Federated via ActivityPub because crosswords are social infrastructure. CC0 because charging for grid squares is morally dubious.";
let (display_name, bio, avatar_path, banner_path) = {
let dbi = state.db.lock().await;
let dn = db::get_server_config(&dbi.db, "ap_actor_name")
.unwrap_or_else(|| std::env::var("AP_ACTOR_NAME").unwrap_or_else(|_| "crossword".into()));
let b = db::get_server_config(&dbi.db, "ap_bio")
.unwrap_or_else(|| default_bio.into());
let av = db::get_server_config(&dbi.db, "ap_avatar_path");
let bn = db::get_server_config(&dbi.db, "ap_banner_path");
(dn, b, av, bn)
};
let mut doc = serde_json::json!({
"@context": [CONTEXT, SECURITY_CONTEXT],
"id": ap.actor_uri,
"type": "Application",
"preferredUsername": ap.actor_name,
"name": display_name,
"summary": bio,
"inbox": format!("{}/inbox", ap.actor_uri),
"outbox": format!("{}/outbox", ap.actor_uri),
"followers": format!("{}/followers", ap.actor_uri),
"publicKey": {
"id": format!("{}#main-key", ap.actor_uri),
"owner": ap.actor_uri,
"publicKeyPem": ap.keys.public_key_pem,
},
"endpoints": {
"sharedInbox": format!("https://{}/inbox", ap.domain),
},
});
if let Some(ref av) = avatar_path {
let url = format!("https://{}/avatar", ap.domain);
doc["icon"] = serde_json::json!({
"type": "Image",
"mediaType": guess_media_type(av),
"url": url,
});
}
if let Some(ref bn) = banner_path {
let url = format!("https://{}/banner", ap.domain);
doc["image"] = serde_json::json!({
"type": "Image",
"mediaType": guess_media_type(bn),
"url": url,
});
}
let body = serde_json::to_string(&doc).unwrap();
Ok((
[(header::CONTENT_TYPE, "application/activity+json; charset=utf-8")],
body,
).into_response())
}
async fn group_actor_document(ap: &crate::activitypub::ApState, theme: &str) -> Result<Response, Response> {
let group_name = format!("{}-{}", ap.actor_name, theme);
let group_uri = format!("https://{}/@{}", ap.domain, group_name);
let actor = Actor {
context: serde_json::json!([CONTEXT, SECURITY_CONTEXT]),
id: group_uri.clone(),
actor_type: "Group".into(),
preferred_username: group_name.clone(),
name: format!("cruciverb {} puzzles", theme),
summary: format!("themed crossword collection - {}", theme),
inbox: format!("{}/inbox", group_uri),
outbox: format!("{}/outbox", group_uri),
followers: format!("{}/followers", group_uri),
public_key: ActorPublicKey {
id: format!("{}#main-key", group_uri),
owner: group_uri.clone(),
public_key_pem: ap.keys.public_key_pem.clone(),
},
endpoints: Some(serde_json::json!({
"sharedInbox": format!("https://{}/inbox", ap.domain),
})),
};
let body = serde_json::to_string(&actor).unwrap();
Ok((
[(header::CONTENT_TYPE, "application/activity+json; charset=utf-8")],
body,
).into_response())
}
fn guess_media_type(url: &str) -> &'static str {
let lower = url.to_lowercase();
if lower.ends_with(".png") { "image/png" }
else if lower.ends_with(".gif") { "image/gif" }
else if lower.ends_with(".webp") { "image/webp" }
else if lower.ends_with(".svg") { "image/svg+xml" }
else { "image/jpeg" }
}
pub async fn followers(
State(state): State<Arc<AppState>>,
) -> Result<Json<OrderedCollection>, Response> {
let ap = state.ap.as_ref().ok_or_else(|| (axum::http::StatusCode::NOT_FOUND, "not found").into_response())?;
let dbi = state.db.lock().await;
let mut stmt = dbi.db.prepare(
"SELECT actor_uri FROM ap_followers WHERE accepted = 1"
).unwrap();
let items: Vec<serde_json::Value> = stmt.query_map([], |row| {
Ok(serde_json::json!(row.get::<_, String>(0)?))
}).unwrap().filter_map(|r| r.ok()).collect();
Ok(Json(OrderedCollection {
context: CONTEXT.into(),
id: format!("{}/followers", ap.actor_uri),
collection_type: "OrderedCollection".into(),
total_items: items.len(),
ordered_items: items,
}))
}