From 944dadb7edcaf17e5a75ee130c7e29daa245b582 Mon Sep 17 00:00:00 2001 From: lashman Date: Thu, 30 Apr 2026 11:27:38 +0300 Subject: [PATCH] ap actor profile --- cruciverb-server/src/activitypub/actor.rs | 185 ++++++++++++++++++++++ 1 file changed, 185 insertions(+) create mode 100644 cruciverb-server/src/activitypub/actor.rs diff --git a/cruciverb-server/src/activitypub/actor.rs b/cruciverb-server/src/activitypub/actor.rs new file mode 100644 index 0000000..19178fb --- /dev/null +++ b/cruciverb-server/src/activitypub/actor.rs @@ -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>, + Query(q): Query, +) -> Result, 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>, + Path(name): Path, +) -> Result { + 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 { + 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>, +) -> Result, 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 = 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, + })) +}