use axum::extract::State;
use axum::http::{header, HeaderMap, HeaderValue, StatusCode};
use axum::response::{IntoResponse, Response};
use serde_json::{json, Value};
use crate::proxy::AppState;
const ACTIVITY_JSON: &str = "application/activity+json";
#[must_use]
pub fn actor_document(actor_base_url: &str, public_key_pem: &str) -> Value {
let base = actor_base_url.trim_end_matches('/');
json!({
"@context": activitypub_context(),
"id": format!("{base}/actor/code"),
"type": "Service",
"preferredUsername": "code",
"name": "Link Assistant Code Router",
"summary": "ActivityPub and ForgeFed service actor for coding problem federation",
"inbox": format!("{base}/inbox/code"),
"outbox": format!("{base}/outbox/code"),
"followers": format!("{base}/actors/code/followers"),
"aliases": [
format!("urn:link-assistant:router:code"),
format!("{base}/actor/code")
],
"publicKey": {
"id": format!("{base}/actor/code#main-key"),
"owner": format!("{base}/actor/code"),
"publicKeyPem": public_key_pem
}
})
}
#[must_use]
pub fn outbox_document(actor_base_url: &str) -> Value {
let base = actor_base_url.trim_end_matches('/');
json!({
"@context": activitypub_context(),
"id": format!("{base}/outbox/code"),
"type": "OrderedCollection",
"totalItems": 0,
"orderedItems": []
})
}
#[must_use]
pub fn followers_document(actor_base_url: &str) -> Value {
let base = actor_base_url.trim_end_matches('/');
json!({
"@context": activitypub_context(),
"id": format!("{base}/actors/code/followers"),
"type": "OrderedCollection",
"totalItems": 0,
"orderedItems": []
})
}
#[must_use]
pub fn follow_problemsets_activity(actor_base_url: &str) -> Value {
let base = actor_base_url.trim_end_matches('/');
json!({
"@context": activitypub_context(),
"id": format!("{base}/activities/follow-problemsets-code-001"),
"type": "Follow",
"actor": format!("{base}/actor/code"),
"object": "https://problemsets.lefine.pro/actor/code",
"to": ["https://problemsets.lefine.pro/actor/code"]
})
}
pub fn validate_activity(activity: &Value) -> Result<(), &'static str> {
require_string(activity, "id")?;
require_string(activity, "type")?;
if activity.get("actor").and_then(Value::as_str).is_none()
&& activity
.get("attributedTo")
.and_then(Value::as_str)
.is_none()
{
return Err("activity must include actor or attributedTo");
}
Ok(())
}
#[allow(clippy::unused_async)]
pub async fn actor(State(state): State<AppState>) -> impl IntoResponse {
activity_response(actor_document(
&state.activitypub_actor_base_url,
&state.activitypub_public_key_pem,
))
}
#[allow(clippy::unused_async)]
pub async fn outbox(State(state): State<AppState>) -> impl IntoResponse {
activity_response(outbox_document(&state.activitypub_actor_base_url))
}
#[allow(clippy::unused_async)]
pub async fn followers(State(state): State<AppState>) -> impl IntoResponse {
activity_response(followers_document(&state.activitypub_actor_base_url))
}
#[allow(clippy::unused_async)]
pub async fn follow_problemsets(State(state): State<AppState>) -> impl IntoResponse {
activity_response(follow_problemsets_activity(
&state.activitypub_actor_base_url,
))
}
#[allow(clippy::unused_async)]
pub async fn inbox(axum::Json(activity): axum::Json<Value>) -> impl IntoResponse {
match validate_activity(&activity) {
Ok(()) => {
let response = json!({
"@context": activitypub_context(),
"type": "Accept",
"object": activity
});
(
StatusCode::ACCEPTED,
activity_headers(),
axum::Json(response),
)
.into_response()
}
Err(message) => (
StatusCode::BAD_REQUEST,
axum::Json(json!({
"error": "invalid_activity",
"message": message
})),
)
.into_response(),
}
}
fn activity_response(value: Value) -> Response {
(StatusCode::OK, activity_headers(), axum::Json(value)).into_response()
}
fn activity_headers() -> HeaderMap {
let mut headers = HeaderMap::new();
headers.insert(
header::CONTENT_TYPE,
HeaderValue::from_static(r"application/activity+json"),
);
headers.insert(header::ACCEPT, HeaderValue::from_static(ACTIVITY_JSON));
headers
}
fn activitypub_context() -> Value {
json!([
"https://www.w3.org/ns/activitystreams",
"https://forgefed.org/ns",
{
"fep": "https://w3id.org/fep/ef61#",
"aliases": "fep:aliases"
}
])
}
fn require_string<'a>(activity: &'a Value, field: &'static str) -> Result<&'a str, &'static str> {
activity
.get(field)
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
.ok_or(match field {
"id" => "activity id is required",
"type" => "activity type is required",
_ => "required field is missing",
})
}
#[cfg(test)]
mod tests {
use super::*;
const BASE: &str = "https://router.example";
const KEY: &str = "-----BEGIN PUBLIC KEY-----\nabc\n-----END PUBLIC KEY-----";
#[test]
fn actor_contains_required_activitypub_and_forgefed_metadata() {
let doc = actor_document(BASE, KEY);
assert_eq!(doc["id"], "https://router.example/actor/code");
assert_eq!(doc["type"], "Service");
assert_eq!(doc["inbox"], "https://router.example/inbox/code");
assert_eq!(doc["outbox"], "https://router.example/outbox/code");
assert_eq!(
doc["followers"],
"https://router.example/actors/code/followers"
);
assert_eq!(doc["publicKey"]["owner"], doc["id"]);
assert_eq!(doc["publicKey"]["publicKeyPem"], KEY);
assert!(doc["@context"]
.as_array()
.expect("context array")
.iter()
.any(|item| item == "https://forgefed.org/ns"));
assert!(doc["aliases"].as_array().expect("aliases").len() >= 2);
}
#[test]
fn follow_activity_targets_problemsets_actor() {
let activity = follow_problemsets_activity(BASE);
assert_eq!(activity["type"], "Follow");
assert_eq!(activity["actor"], "https://router.example/actor/code");
assert_eq!(
activity["object"],
"https://problemsets.lefine.pro/actor/code"
);
assert_eq!(
activity["to"][0],
"https://problemsets.lefine.pro/actor/code"
);
}
#[test]
fn inbound_activity_requires_core_fields() {
let valid = json!({
"id": "https://remote.example/activity/1",
"type": "Create",
"actor": "https://remote.example/actor"
});
assert!(validate_activity(&valid).is_ok());
let invalid = json!({
"id": "https://remote.example/activity/1",
"type": "Create"
});
assert_eq!(
validate_activity(&invalid),
Err("activity must include actor or attributedTo")
);
}
}