ap actor profile
This commit is contained in:
@@ -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,
|
||||
}))
|
||||
}
|
||||
Reference in New Issue
Block a user