feat: image storage generalization, user profile, and federation polish

- Replace PosterStorage with generic ImageStorage port (IMAGE_STORAGE_BACKEND/PATH env vars)
- Rename poster-storage crate to image-storage; serve at /images/{*key}
- Add bio and avatar_path to User model (migration 0009_user_profile)
- update_profile use case with avatar upload, mime validation, old avatar cleanup
- GET/PUT /api/v1/profile and GET/POST /settings/profile HTML page
- Enrich AP Person actor with summary, icon, url, discoverable fields
- Store remote actor avatar_url (migration 0010_ap_remote_actor_avatar)
- Shared inbox delivery via collect_inboxes deduplication
- Broadcast Update(Person) to followers on UserUpdated event
- Paginated outbox: OrderedCollection + OrderedCollectionPage with cursor
- Announce/boost tracking in ap_announces table (migration 0011_ap_announces)
This commit is contained in:
2026-05-11 22:59:52 +02:00
parent 8a254346f4
commit 80f620c840
89 changed files with 2231 additions and 499 deletions

View File

@@ -9,6 +9,10 @@ use activitypub_federation::{
use serde::{Deserialize, Serialize};
use url::Url;
#[derive(Clone, Default, Debug, Serialize, Deserialize)]
#[serde(rename = "Announce")]
pub struct AnnounceType;
use crate::actors::DbActor;
use crate::data::FederationData;
use crate::error::Error;
@@ -330,6 +334,54 @@ impl Activity for UpdateActivity {
}
}
// --- Announce ---
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AnnounceActivity {
pub(crate) id: Url,
#[serde(rename = "type", default)]
pub(crate) kind: AnnounceType,
pub(crate) actor: ObjectId<DbActor>,
pub(crate) object: Url,
pub(crate) published: Option<chrono::DateTime<chrono::Utc>>,
}
#[async_trait::async_trait]
impl Activity for AnnounceActivity {
type DataType = FederationData;
type Error = Error;
fn id(&self) -> &Url {
&self.id
}
fn actor(&self) -> &Url {
self.actor.inner()
}
async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> {
Ok(())
}
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
let object_domain = self.object.host_str().unwrap_or("");
if object_domain != data.domain {
return Ok(());
}
data.federation_repo
.add_announce(
self.id.as_str(),
self.object.as_str(),
self.actor.inner().as_str(),
self.published.unwrap_or_else(chrono::Utc::now),
)
.await?;
tracing::info!(actor = %self.actor.inner(), object = %self.object, "received announce");
Ok(())
}
}
// --- Inbox dispatch enum ---
#[derive(Debug, Deserialize, Serialize)]
@@ -350,4 +402,6 @@ pub enum InboxActivities {
Delete(DeleteActivity),
#[serde(rename = "Update")]
Update(UpdateActivity),
#[serde(rename = "Announce")]
Announce(AnnounceActivity),
}

View File

@@ -26,6 +26,15 @@ pub struct DbActor {
pub following_url: Url,
pub ap_id: Url,
pub last_refreshed_at: DateTime<Utc>,
pub bio: Option<String>,
pub avatar_path: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ApImageObject {
#[serde(rename = "type")]
pub kind: String,
pub url: Url,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
@@ -41,6 +50,15 @@ pub struct Person {
following: Url,
public_key: PublicKey,
name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
summary: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
icon: Option<ApImageObject>,
#[serde(skip_serializing_if = "Option::is_none")]
url: Option<Url>,
#[serde(skip_serializing_if = "Option::is_none")]
discoverable: Option<bool>,
manually_approves_followers: bool,
}
pub async fn get_local_actor(
@@ -86,6 +104,8 @@ pub async fn get_local_actor(
following_url,
ap_id,
last_refreshed_at: Utc::now(),
bio: user.bio,
avatar_path: user.avatar_path,
})
}
@@ -143,16 +163,27 @@ impl Object for DbActor {
following_url,
ap_id,
last_refreshed_at: Utc::now(),
bio: None,
avatar_path: None,
}))
}
async fn into_json(self, _data: &Data<Self::DataType>) -> Result<Self::Kind, Self::Error> {
async fn into_json(self, data: &Data<Self::DataType>) -> Result<Self::Kind, Self::Error> {
let public_key = PublicKey {
id: format!("{}#main-key", &self.ap_id),
owner: self.ap_id.clone(),
public_key_pem: self.public_key_pem.clone(),
};
let icon = self.avatar_path.as_ref().map(|p| ApImageObject {
kind: "Image".to_string(),
url: Url::parse(&format!("{}/images/{}", data.base_url, p))
.expect("valid avatar url"),
});
let profile_url =
Url::parse(&format!("{}/u/{}", data.base_url, self.username))
.expect("valid profile url");
Ok(Person {
kind: Default::default(),
id: self.ap_id.clone().into(),
@@ -163,6 +194,11 @@ impl Object for DbActor {
following: self.following_url.clone(),
public_key,
name: Some(self.username.clone()),
summary: self.bio.clone(),
icon,
url: Some(profile_url),
discoverable: Some(true),
manually_approves_followers: false,
})
}
@@ -182,6 +218,7 @@ impl Object for DbActor {
inbox_url: json.inbox.to_string(),
shared_inbox_url: None,
display_name: json.name.clone(),
avatar_url: json.icon.as_ref().map(|i| i.url.to_string()),
};
data.federation_repo.upsert_remote_actor(actor).await?;
@@ -204,6 +241,8 @@ impl Object for DbActor {
following_url,
ap_id,
last_refreshed_at: Utc::now(),
bio: None,
avatar_path: None,
})
}
}
@@ -221,3 +260,40 @@ impl Actor for DbActor {
self.inbox_url.clone()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn person_serializes_with_enriched_fields() {
let person = Person {
kind: Default::default(),
id: "https://example.com/users/1".parse::<url::Url>().unwrap().into(),
preferred_username: "alice".to_string(),
inbox: "https://example.com/users/1/inbox".parse().unwrap(),
outbox: "https://example.com/users/1/outbox".parse().unwrap(),
followers: "https://example.com/users/1/followers".parse().unwrap(),
following: "https://example.com/users/1/following".parse().unwrap(),
public_key: PublicKey {
id: "https://example.com/users/1#main-key".to_string(),
owner: "https://example.com/users/1".parse().unwrap(),
public_key_pem: "pem".to_string(),
},
name: Some("Alice".to_string()),
summary: Some("Bio text".to_string()),
icon: Some(ApImageObject {
kind: "Image".to_string(),
url: "https://example.com/images/avatars/1".parse().unwrap(),
}),
url: Some("https://example.com/u/alice".parse().unwrap()),
discoverable: Some(true),
manually_approves_followers: false,
};
let json = serde_json::to_value(&person).unwrap();
assert_eq!(json["discoverable"], true);
assert_eq!(json["summary"], "Bio text");
assert_eq!(json["icon"]["type"], "Image");
assert!(json.get("manuallyApprovesFollowers").is_some());
}
}

View File

@@ -1,4 +1,5 @@
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use url::Url;
#[async_trait]
@@ -10,6 +11,15 @@ pub trait ApObjectHandler: Send + Sync {
user_id: uuid::Uuid,
) -> anyhow::Result<Vec<(Url, serde_json::Value)>>;
/// Returns up to `limit` objects ordered newest-first, published before `before`.
/// Returns (ap_id, object_json, published_at).
async fn get_local_objects_page(
&self,
user_id: uuid::Uuid,
before: Option<DateTime<Utc>>,
limit: usize,
) -> anyhow::Result<Vec<(Url, serde_json::Value, DateTime<Utc>)>>;
/// Incoming Create activity — persist remote content.
async fn on_create(
&self,

View File

@@ -1,9 +1,20 @@
use activitypub_federation::{axum::json::FederationJson, config::Data};
use axum::extract::Path;
use axum::extract::{Path, Query};
use axum::response::IntoResponse;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use url::Url;
use crate::data::FederationData;
use crate::error::Error;
use activitypub_federation::{config::Data, fetch::object_id::ObjectId, kinds::activity::CreateType};
use crate::{activities::CreateActivity, data::FederationData, error::Error};
const PAGE_SIZE: usize = 20;
#[derive(Deserialize)]
pub struct OutboxQuery {
page: Option<bool>,
before: Option<String>,
}
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -14,13 +25,28 @@ pub struct OrderedCollection {
kind: String,
id: String,
total_items: u64,
first: String,
}
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OrderedCollectionPage {
#[serde(rename = "@context")]
context: String,
#[serde(rename = "type")]
kind: String,
id: String,
part_of: String,
ordered_items: Vec<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
next: Option<String>,
}
pub async fn outbox_handler(
Path(user_id_str): Path<String>,
Query(query): Query<OutboxQuery>,
data: Data<FederationData>,
) -> Result<FederationJson<OrderedCollection>, Error> {
) -> Result<axum::response::Response, Error> {
let uuid = uuid::Uuid::parse_str(&user_id_str)
.map_err(|_| Error::bad_request(anyhow::anyhow!("invalid user id")))?;
@@ -30,19 +56,80 @@ pub async fn outbox_handler(
.map_err(Error::from)?
.ok_or_else(|| Error::not_found(anyhow::anyhow!("user not found")))?;
let objects = data
.object_handler
.get_local_objects_for_user(uuid)
.await
.map_err(|e| Error::from(anyhow::anyhow!(e)))?;
let outbox_url = format!("{}/users/{}/outbox", data.base_url, user_id_str);
Ok(FederationJson(OrderedCollection {
context: "https://www.w3.org/ns/activitystreams".to_string(),
kind: "OrderedCollection".to_string(),
id: outbox_url,
total_items: objects.len() as u64,
ordered_items: vec![],
}))
if query.page.unwrap_or(false) {
let before: Option<DateTime<Utc>> = query.before.as_deref().and_then(|s| s.parse().ok());
let items = data
.object_handler
.get_local_objects_page(uuid, before, PAGE_SIZE)
.await
.map_err(|e| Error::from(anyhow::anyhow!("{}", e)))?;
let actor_url: Url = format!("{}/users/{}", data.base_url, user_id_str)
.parse()
.expect("valid url");
let has_more = items.len() == PAGE_SIZE;
let oldest_ts = items.last().map(|(_, _, ts)| *ts);
let ordered_items: Vec<serde_json::Value> = items
.into_iter()
.map(|(ap_id, object, _)| {
let create_id =
Url::parse(&format!("{}/activity", ap_id)).expect("valid url");
serde_json::to_value(CreateActivity {
id: create_id,
kind: CreateType::default(),
actor: ObjectId::from(actor_url.clone()),
object,
})
.expect("serializable")
})
.collect();
let page_id = match &query.before {
Some(b) => format!("{}?page=true&before={}", outbox_url, b),
None => format!("{}?page=true", outbox_url),
};
let next = if has_more {
oldest_ts.map(|ts| {
// Use RFC 3339 with Z suffix (no + sign) to avoid percent-encoding
let ts_str = ts
.format("%Y-%m-%dT%H:%M:%S%.3fZ")
.to_string();
format!("{}?page=true&before={}", outbox_url, ts_str)
})
} else {
None
};
Ok(axum::Json(OrderedCollectionPage {
context: "https://www.w3.org/ns/activitystreams".to_string(),
kind: "OrderedCollectionPage".to_string(),
id: page_id,
part_of: outbox_url,
ordered_items,
next,
})
.into_response())
} else {
let total = data
.object_handler
.get_local_objects_for_user(uuid)
.await
.map_err(|e| Error::from(anyhow::anyhow!("{}", e)))?
.len() as u64;
Ok(axum::Json(OrderedCollection {
context: "https://www.w3.org/ns/activitystreams".to_string(),
kind: "OrderedCollection".to_string(),
id: outbox_url.clone(),
total_items: total,
first: format!("{}?page=true", outbox_url),
})
.into_response())
}
}

View File

@@ -21,6 +21,7 @@ pub struct RemoteActor {
pub inbox_url: String,
pub shared_inbox_url: Option<String>,
pub display_name: Option<String>,
pub avatar_url: Option<String>,
}
#[derive(Debug, Clone)]
@@ -88,4 +89,12 @@ pub trait FederationRepository: Send + Sync {
remote_actor_url: &str,
status: FollowingStatus,
) -> Result<()>;
async fn add_announce(
&self,
activity_id: &str,
object_url: &str,
actor_url: &str,
announced_at: chrono::DateTime<chrono::Utc>,
) -> Result<()>;
async fn count_announces(&self, object_url: &str) -> Result<usize>;
}

View File

@@ -10,7 +10,7 @@ use axum::{Router, routing::get, routing::post};
use url::Url;
use crate::{
activities::{AcceptActivity, CreateActivity, FollowActivity, RejectActivity, UndoActivity},
activities::{AcceptActivity, CreateActivity, FollowActivity, RejectActivity, UndoActivity, UpdateActivity},
actors::{DbActor, get_local_actor},
content::ApObjectHandler,
data::FederationData,
@@ -24,6 +24,24 @@ use crate::{
webfinger::webfinger_handler,
};
fn collect_inboxes(followers: &[crate::repository::Follower]) -> Vec<Url> {
let mut seen = std::collections::HashSet::new();
let mut inboxes = Vec::new();
for f in followers {
let inbox_str = f
.actor
.shared_inbox_url
.as_deref()
.unwrap_or(&f.actor.inbox_url);
if seen.insert(inbox_str.to_string()) {
if let Ok(url) = Url::parse(inbox_str) {
inboxes.push(url);
}
}
}
inboxes
}
pub(crate) async fn send_with_retry(
sends: Vec<SendActivityTask>,
data: &activitypub_federation::config::Data<FederationData>,
@@ -150,6 +168,7 @@ impl ActivityPubService {
inbox_url: remote_actor.inbox_url.to_string(),
shared_inbox_url: None,
display_name: Some(remote_actor.username.clone()),
avatar_url: None,
};
data.federation_repo
.add_following(local_user_id, remote, &follow_id_str)
@@ -289,7 +308,11 @@ impl ActivityPubService {
);
}
self.spawn_backfill(local_user_id, remote_actor.inbox_url.clone());
let target_inbox = remote_actor
.shared_inbox_url
.clone()
.unwrap_or_else(|| remote_actor.inbox_url.clone());
self.spawn_backfill(local_user_id, target_inbox);
Ok(())
}
@@ -437,10 +460,7 @@ impl ActivityPubService {
};
let create_with_ctx = WithContext::new_default(create);
let inboxes: Vec<Url> = accepted
.iter()
.filter_map(|f| Url::parse(&f.actor.inbox_url).ok())
.collect();
let inboxes = collect_inboxes(&accepted);
let sends =
SendActivityTask::prepare(&create_with_ctx, &local_actor, inboxes, &data).await?;
@@ -455,6 +475,57 @@ impl ActivityPubService {
Ok(())
}
pub async fn broadcast_actor_update(&self, user_id: uuid::Uuid) -> anyhow::Result<()> {
use activitypub_federation::traits::Object;
let data = self.federation_config.to_request_data();
let local_actor = get_local_actor(user_id, &data)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
let person = local_actor.clone().into_json(&data).await
.map_err(|e| anyhow::anyhow!("{e}"))?;
let person_json = serde_json::to_value(&person)?;
let update_id = Url::parse(&format!(
"{}/activities/update/{}",
self.base_url,
uuid::Uuid::new_v4()
))?;
let update = UpdateActivity {
id: update_id,
kind: Default::default(),
actor: ObjectId::from(local_actor.ap_id.clone()),
object: person_json,
};
let followers = data.federation_repo.get_followers(user_id).await?;
let accepted: Vec<_> = followers
.into_iter()
.filter(|f| f.status == FollowerStatus::Accepted)
.collect();
if accepted.is_empty() {
return Ok(());
}
let inboxes = collect_inboxes(&accepted);
let sends = SendActivityTask::prepare(
&WithContext::new_default(update),
&local_actor,
inboxes,
&data,
)
.await?;
let failures = send_with_retry(sends, &data).await;
if !failures.is_empty() {
tracing::warn!(count = failures.len(), "actor update delivery failures");
}
Ok(())
}
async fn follow_local(
&self,
local_user_id: uuid::Uuid,
@@ -493,6 +564,7 @@ impl ActivityPubService {
inbox_url: target_inbox_url,
shared_inbox_url: None,
display_name: Some(target.username),
avatar_url: None,
};
data.federation_repo
.add_following(local_user_id, target_as_remote, &follow_id)
@@ -618,3 +690,47 @@ impl ActivityPubService {
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::repository::{Follower, FollowerStatus, RemoteActor};
fn make_follower(inbox: &str, shared: Option<&str>) -> Follower {
Follower {
actor: RemoteActor {
url: format!("https://remote/{}", inbox),
handle: "user".to_string(),
inbox_url: inbox.to_string(),
shared_inbox_url: shared.map(|s| s.to_string()),
display_name: None,
avatar_url: None,
},
status: FollowerStatus::Accepted,
}
}
#[test]
fn collect_inboxes_deduplicates_shared() {
let followers = vec![
make_follower("https://mastodon.social/users/a/inbox", Some("https://mastodon.social/inbox")),
make_follower("https://mastodon.social/users/b/inbox", Some("https://mastodon.social/inbox")),
make_follower("https://other.instance/users/c/inbox", None),
];
let inboxes = collect_inboxes(&followers);
assert_eq!(inboxes.len(), 2);
let strs: Vec<_> = inboxes.iter().map(|u| u.as_str()).collect();
assert!(strs.contains(&"https://mastodon.social/inbox"));
assert!(strs.contains(&"https://other.instance/users/c/inbox"));
}
#[test]
fn collect_inboxes_falls_back_to_individual_inbox() {
let followers = vec![
make_follower("https://example.com/users/x/inbox", None),
];
let inboxes = collect_inboxes(&followers);
assert_eq!(inboxes.len(), 1);
assert_eq!(inboxes[0].as_str(), "https://example.com/users/x/inbox");
}
}

View File

@@ -4,6 +4,8 @@ use async_trait::async_trait;
pub struct ApUser {
pub id: uuid::Uuid,
pub username: String,
pub bio: Option<String>,
pub avatar_path: Option<String>,
}
#[async_trait]