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