feat(ap): ActivityPub spec compliance and profile completeness

Phase 1 — spec compliance:
- Add AS_PUBLIC constant; add to/cc fields to CreateActivity, DeleteActivity,
  UpdateActivity, AddActivity; populate on all broadcast call sites
- Add @context to outbox CreateActivity items
- Set manuallyApprovesFollowers: true to match actual Pending follow flow
- Gate PermissiveVerifier behind FEDERATION_DEBUG env var
- Add updated timestamp to Person actor JSON
- Improve actor update delivery logging

Phase 2a Batch 1 — AP layer:
- Add /inbox shared inbox route; add endpoints.sharedInbox to Person
- Paginate followers and following collections (20/page, OrderedCollectionPage)

Phase 2a Batch 2 — profile completeness:
- DB migrations: banner_path, also_known_as columns; user_profile_fields table
- ProfileField value object; UserProfileFieldsRepository port
- Banner image upload (stored via image-converter, surfaced as image in Person)
- alsoKnownAs field in Person (account migration support)
- Custom profile fields (up to 4 PropertyValue attachments in Person)
- Profile settings UI: banner preview/upload, alsoKnownAs input, fields form
- PUT /api/v1/profile/fields API endpoint
This commit is contained in:
2026-05-13 22:21:41 +02:00
parent 0a97fe5544
commit 815178e6a4
56 changed files with 1388 additions and 246 deletions

View File

@@ -2,30 +2,45 @@ use std::sync::Arc;
use activitypub_base::{ApUser, ApUserRepository};
use async_trait::async_trait;
use domain::{ports::UserRepository, value_objects::UserId};
use domain::{
models::ProfileField,
ports::{UserProfileFieldsRepository, UserRepository},
value_objects::UserId,
};
use url::Url;
pub struct DomainUserRepoAdapter {
pub repo: Arc<dyn UserRepository>,
pub fields_repo: Arc<dyn UserProfileFieldsRepository>,
pub base_url: String,
}
impl DomainUserRepoAdapter {
pub fn new(repo: Arc<dyn UserRepository>, base_url: String) -> Self {
Self { repo, base_url }
pub fn new(
repo: Arc<dyn UserRepository>,
fields_repo: Arc<dyn UserProfileFieldsRepository>,
base_url: String,
) -> Self {
Self { repo, fields_repo, base_url }
}
fn build_user(&self, u: &domain::models::User) -> ApUser {
fn build_user(&self, u: &domain::models::User, fields: Vec<ProfileField>) -> ApUser {
let avatar_url = u.avatar_path().and_then(|p| {
Url::parse(&format!("{}/images/{}", self.base_url, p)).ok()
});
let banner_url = u.banner_path().and_then(|p| {
Url::parse(&format!("{}/images/{}", self.base_url, p)).ok()
});
let profile_url = Url::parse(&format!("{}/u/{}", self.base_url, u.username().value())).ok();
ApUser {
id: u.id().value(),
username: u.username().value().to_string(),
bio: u.bio().map(|s| s.to_string()),
avatar_url,
banner_url,
also_known_as: u.also_known_as().map(|s| s.to_string()),
profile_url,
attachment: fields,
}
}
}
@@ -34,13 +49,23 @@ impl DomainUserRepoAdapter {
impl ApUserRepository for DomainUserRepoAdapter {
async fn find_by_id(&self, id: uuid::Uuid) -> anyhow::Result<Option<ApUser>> {
let user_id = UserId::from_uuid(id);
Ok(self.repo.find_by_id(&user_id).await?.as_ref().map(|u| self.build_user(u)))
let user = match self.repo.find_by_id(&user_id).await? {
Some(u) => u,
None => return Ok(None),
};
let fields = self.fields_repo.get_fields(&user_id).await.unwrap_or_default();
Ok(Some(self.build_user(&user, fields)))
}
async fn find_by_username(&self, username: &str) -> anyhow::Result<Option<ApUser>> {
use domain::value_objects::Username;
let uname = Username::new(username.to_string()).map_err(|e| anyhow::anyhow!(e.to_string()))?;
Ok(self.repo.find_by_username(&uname).await?.as_ref().map(|u| self.build_user(u)))
let user = match self.repo.find_by_username(&uname).await? {
Some(u) => u,
None => return Ok(None),
};
let fields = self.fields_repo.get_fields(user.id()).await.unwrap_or_default();
Ok(Some(self.build_user(&user, fields)))
}
async fn count_users(&self) -> anyhow::Result<usize> {