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

@@ -22,7 +22,7 @@ use application::{
delete_review, export_diary as export_diary_uc, get_activity_feed as get_feed_uc,
get_diary, get_movie_social_page, get_movies, get_review_history,
get_user_profile as get_user_profile_uc, get_users, log_review, login as login_uc,
register as register_uc, sync_poster, update_profile,
register as register_uc, sync_poster, update_profile, update_profile_fields,
search as search_uc, get_person, get_person_credits,
add_to_watchlist, remove_from_watchlist, get_watchlist, is_on_watchlist,
},
@@ -433,22 +433,30 @@ pub async fn update_profile_handler(
let mut bio: Option<String> = None;
let mut avatar_bytes: Option<Vec<u8>> = None;
let mut avatar_content_type: Option<String> = None;
let mut banner_bytes: Option<Vec<u8>> = None;
let mut banner_content_type: Option<String> = None;
let mut also_known_as: Option<String> = None;
while let Ok(Some(field)) = multipart.next_field().await {
let name = field.name().unwrap_or("").to_string();
match name.as_str() {
"bio" => {
"bio" => { if let Ok(text) = field.text().await { bio = Some(text); } }
"also_known_as" => {
if let Ok(text) = field.text().await {
bio = Some(text);
also_known_as = Some(text).filter(|s| !s.is_empty());
}
}
"avatar" => {
let content_type = field.content_type().map(|s| s.to_string());
if let Ok(bytes) = field.bytes().await
&& !bytes.is_empty() {
avatar_bytes = Some(bytes.to_vec());
avatar_content_type = content_type;
}
let ct = field.content_type().map(|s| s.to_string());
if let Ok(bytes) = field.bytes().await {
if !bytes.is_empty() { avatar_bytes = Some(bytes.to_vec()); avatar_content_type = ct; }
}
}
"banner" => {
let ct = field.content_type().map(|s| s.to_string());
if let Ok(bytes) = field.bytes().await {
if !bytes.is_empty() { banner_bytes = Some(bytes.to_vec()); banner_content_type = ct; }
}
}
_ => {}
}
@@ -459,6 +467,9 @@ pub async fn update_profile_handler(
bio,
avatar_bytes,
avatar_content_type,
banner_bytes,
banner_content_type,
also_known_as,
};
match update_profile::execute(&state.app_ctx, cmd).await {
@@ -474,6 +485,42 @@ pub async fn update_profile_handler(
}
}
pub async fn update_profile_fields_handler(
State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser,
axum::Json(body): axum::Json<serde_json::Value>,
) -> impl IntoResponse {
let raw_fields = match body.get("fields").and_then(|f| f.as_array()) {
Some(arr) => arr.clone(),
None => return StatusCode::BAD_REQUEST.into_response(),
};
let fields: Vec<domain::models::ProfileField> = raw_fields
.iter()
.filter_map(|f| {
let name = f.get("name").and_then(|n| n.as_str())?.to_string();
let value = f.get("value").and_then(|v| v.as_str())?.to_string();
Some(domain::models::ProfileField { name, value })
})
.collect();
let cmd = application::commands::UpdateProfileFieldsCommand {
user_id: user_id.value(),
fields,
};
match update_profile_fields::execute(&state.app_ctx, cmd).await {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(domain::errors::DomainError::ValidationError(msg)) => {
(StatusCode::BAD_REQUEST, msg).into_response()
}
Err(e) => {
tracing::error!("update_profile_fields error: {:?}", e);
StatusCode::INTERNAL_SERVER_ERROR.into_response()
}
}
}
fn movie_to_dto(movie: &Movie) -> MovieDto {
MovieDto {
id: movie.id().value(),

View File

@@ -28,7 +28,7 @@ use application::{
use_cases::{
add_to_watchlist, delete_review, export_diary as export_diary_uc, get_movie_social_page,
get_watchlist, is_on_watchlist, log_review, login as login_uc, register as register_uc,
remove_from_watchlist, update_profile,
remove_from_watchlist, update_profile, update_profile_fields,
},
};
use domain::models::ExportFormat;
@@ -1249,6 +1249,17 @@ pub async fn get_profile_settings(
let avatar_url = user
.avatar_path()
.map(|path| format!("{}/images/{}", base_url, path));
let banner_url = user
.banner_path()
.map(|path| format!("{}/images/{}", base_url, path));
let profile_fields = state.app_ctx.profile_fields_repository
.get_fields(&user_id)
.await
.unwrap_or_default()
.into_iter()
.map(|f| (f.name, f.value))
.collect();
let saved = params.saved.as_deref() == Some("1");
@@ -1256,6 +1267,9 @@ pub async fn get_profile_settings(
ctx,
bio: user.bio().map(|s| s.to_string()),
avatar_url,
banner_url,
also_known_as: user.also_known_as().map(|s| s.to_string()),
profile_fields,
saved,
};
@@ -1430,22 +1444,46 @@ pub async fn post_profile_settings(
let mut bio: Option<String> = None;
let mut avatar_bytes: Option<Vec<u8>> = None;
let mut avatar_content_type: Option<String> = None;
let mut banner_bytes: Option<Vec<u8>> = None;
let mut banner_content_type: Option<String> = None;
let mut also_known_as: Option<String> = None;
let mut field_names: std::collections::HashMap<usize, String> = std::collections::HashMap::new();
let mut field_values: std::collections::HashMap<usize, String> = std::collections::HashMap::new();
while let Ok(Some(field)) = multipart.next_field().await {
let name = field.name().unwrap_or("").to_string();
match name.as_str() {
"bio" => {
"bio" => { if let Ok(text) = field.text().await { bio = Some(text); } }
"also_known_as" => {
if let Ok(text) = field.text().await {
bio = Some(text);
also_known_as = Some(text).filter(|s| !s.is_empty());
}
}
"avatar" => {
let content_type = field.content_type().map(|s| s.to_string());
if let Ok(bytes) = field.bytes().await
&& !bytes.is_empty() {
avatar_bytes = Some(bytes.to_vec());
avatar_content_type = content_type;
let ct = field.content_type().map(|s| s.to_string());
if let Ok(bytes) = field.bytes().await {
if !bytes.is_empty() { avatar_bytes = Some(bytes.to_vec()); avatar_content_type = ct; }
}
}
"banner" => {
let ct = field.content_type().map(|s| s.to_string());
if let Ok(bytes) = field.bytes().await {
if !bytes.is_empty() { banner_bytes = Some(bytes.to_vec()); banner_content_type = ct; }
}
}
n if n.starts_with("field_name_") => {
if let Ok(idx) = n["field_name_".len()..].parse::<usize>() {
if let Ok(text) = field.text().await {
if !text.is_empty() { field_names.insert(idx, text); }
}
}
}
n if n.starts_with("field_value_") => {
if let Ok(idx) = n["field_value_".len()..].parse::<usize>() {
if let Ok(text) = field.text().await {
if !text.is_empty() { field_values.insert(idx, text); }
}
}
}
_ => {}
}
@@ -1456,9 +1494,26 @@ pub async fn post_profile_settings(
bio,
avatar_bytes,
avatar_content_type,
banner_bytes,
banner_content_type,
also_known_as,
};
let _ = update_profile::execute(&state.app_ctx, cmd).await;
let fields: Vec<domain::models::ProfileField> = (0..4)
.filter_map(|i| {
field_names.get(&i).map(|name| domain::models::ProfileField {
name: name.clone(),
value: field_values.get(&i).cloned().unwrap_or_default(),
})
})
.collect();
let fields_cmd = application::commands::UpdateProfileFieldsCommand {
user_id: user_id.value(),
fields,
};
let _ = update_profile_fields::execute(&state.app_ctx, fields_cmd).await;
Redirect::to("/settings/profile?saved=1").into_response()
}

View File

@@ -72,6 +72,15 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
_ => anyhow::bail!("DATABASE_BACKEND={backend} is not supported by this build (sqlite feature is not enabled)"),
};
let profile_fields_repo = match &db_pool {
#[cfg(feature = "postgres")]
DbPool::Postgres(pool) => postgres::create_profile_fields_repo(pool.clone()),
#[cfg(feature = "sqlite")]
DbPool::Sqlite(pool) => sqlite::create_profile_fields_repo(pool.clone()),
#[cfg(not(feature = "sqlite"))]
_ => anyhow::bail!("no profile fields repo for this backend"),
};
// Wire up event channel, federation service, and ap_router
let event_bus = EventBusBackend::from_env()?;
@@ -114,6 +123,7 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
review_store,
remote_watchlist_repo.clone(),
Arc::clone(&user_repository),
Arc::clone(&profile_fields_repo),
Arc::clone(&movie_repository),
Arc::clone(&review_repository),
Arc::clone(&diary_repository),
@@ -173,6 +183,7 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
import_profile_repository: import_profile_repository as Arc<dyn ImportProfileRepository>,
movie_profile_repository,
watchlist_repository,
profile_fields_repository: profile_fields_repo,
#[cfg(feature = "federation")]
remote_watchlist_repository: remote_watchlist_repo,
person_command,

View File

@@ -223,6 +223,7 @@ fn api_routes(rate_limit: u64) -> Router<AppState> {
.route("/import/profiles", routing::get(handlers::import::api_get_profiles).post(handlers::import::api_post_profile))
.route("/import/profiles/{id}", routing::delete(handlers::import::api_delete_profile))
.route("/profile", routing::get(handlers::api::get_profile).put(handlers::api::update_profile_handler))
.route("/profile/fields", routing::put(handlers::api::update_profile_fields_handler))
.route("/search", routing::get(handlers::api::get_search))
.route("/people/{id}", routing::get(handlers::api::get_person_handler))
.route("/people/{id}/credits", routing::get(handlers::api::get_person_credits_handler))