From 908789e639043dc1d49c2148affb5c0bcc7dbad4 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Thu, 14 May 2026 21:42:38 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20content=20negotiation=20for=20followers/?= =?UTF-8?q?following=20=E2=80=94=20resolve=20AP=20router=20conflict?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../adapters/activitypub-base/src/service.rs | 96 +++++++++++++++++++ crates/bootstrap/src/main.rs | 9 -- crates/domain/src/ports.rs | 10 ++ crates/domain/src/testing.rs | 16 ++++ crates/presentation/src/handlers/feed.rs | 75 ++++++++++++--- 5 files changed, 185 insertions(+), 21 deletions(-) diff --git a/crates/adapters/activitypub-base/src/service.rs b/crates/adapters/activitypub-base/src/service.rs index 81e07f0..475ff23 100644 --- a/crates/adapters/activitypub-base/src/service.rs +++ b/crates/adapters/activitypub-base/src/service.rs @@ -1415,6 +1415,102 @@ impl domain::ports::FederationActionPort for ActivityPubService { .await .map_err(|e| domain::errors::DomainError::ExternalService(e.to_string())) } + + async fn followers_collection_json( + &self, + user_id: &domain::value_objects::UserId, + page: Option, + ) -> Result { + let data = self.federation_config.to_request_data(); + let uuid = user_id.as_uuid(); + let collection_id = format!("{}/users/{}/followers", self.base_url, uuid); + let total = data + .federation_repo + .count_followers(uuid) + .await + .map_err(|e| domain::errors::DomainError::ExternalService(e.to_string()))?; + let obj = if let Some(p) = page { + let p = p.max(1); + let offset = (p.saturating_sub(1) as usize) * 20; + let followers = data + .federation_repo + .get_followers_page(uuid, offset as u32, 20) + .await + .map_err(|e| domain::errors::DomainError::ExternalService(e.to_string()))?; + let has_next = offset + followers.len() < total; + let items: Vec = followers.into_iter().map(|f| f.actor.url).collect(); + let mut obj = serde_json::json!({ + "@context": "https://www.w3.org/ns/activitystreams", + "type": "OrderedCollectionPage", + "id": format!("{}?page={}", collection_id, p), + "partOf": collection_id, + "totalItems": total, + "orderedItems": items, + }); + if has_next { + obj["next"] = serde_json::json!(format!("{}?page={}", collection_id, p + 1)); + } + obj + } else { + serde_json::json!({ + "@context": "https://www.w3.org/ns/activitystreams", + "type": "OrderedCollection", + "id": collection_id, + "totalItems": total, + "first": format!("{}?page=1", collection_id), + }) + }; + serde_json::to_string(&obj) + .map_err(|e| domain::errors::DomainError::ExternalService(e.to_string())) + } + + async fn following_collection_json( + &self, + user_id: &domain::value_objects::UserId, + page: Option, + ) -> Result { + let data = self.federation_config.to_request_data(); + let uuid = user_id.as_uuid(); + let collection_id = format!("{}/users/{}/following", self.base_url, uuid); + let total = data + .federation_repo + .count_following(uuid) + .await + .map_err(|e| domain::errors::DomainError::ExternalService(e.to_string()))?; + let obj = if let Some(p) = page { + let p = p.max(1); + let offset = (p.saturating_sub(1) as usize) * 20; + let following = data + .federation_repo + .get_following_page(uuid, offset as u32, 20) + .await + .map_err(|e| domain::errors::DomainError::ExternalService(e.to_string()))?; + let has_next = offset + following.len() < total; + let items: Vec = following.into_iter().map(|a| a.url).collect(); + let mut obj = serde_json::json!({ + "@context": "https://www.w3.org/ns/activitystreams", + "type": "OrderedCollectionPage", + "id": format!("{}?page={}", collection_id, p), + "partOf": collection_id, + "totalItems": total, + "orderedItems": items, + }); + if has_next { + obj["next"] = serde_json::json!(format!("{}?page={}", collection_id, p + 1)); + } + obj + } else { + serde_json::json!({ + "@context": "https://www.w3.org/ns/activitystreams", + "type": "OrderedCollection", + "id": collection_id, + "totalItems": total, + "first": format!("{}?page=1", collection_id), + }) + }; + serde_json::to_string(&obj) + .map_err(|e| domain::errors::DomainError::ExternalService(e.to_string())) + } } #[cfg(test)] diff --git a/crates/bootstrap/src/main.rs b/crates/bootstrap/src/main.rs index ece9f06..1b0e00c 100644 --- a/crates/bootstrap/src/main.rs +++ b/crates/bootstrap/src/main.rs @@ -2,7 +2,6 @@ mod config; mod factory; use activitypub_base::{ - followers_handler::{followers_handler, following_handler}, inbox::inbox_handler, nodeinfo::{nodeinfo_handler, nodeinfo_well_known_handler}, outbox::outbox_handler, @@ -57,14 +56,6 @@ async fn main() { "/users/{username}/outbox", axum::routing::get(outbox_handler), ) - .route( - "/users/{username}/followers", - axum::routing::get(followers_handler), - ) - .route( - "/users/{username}/following", - axum::routing::get(following_handler), - ) .layer(infra.ap_service.federation_config().middleware()); let base = presentation::routes::router() diff --git a/crates/domain/src/ports.rs b/crates/domain/src/ports.rs index 95e8b28..ab8b3cc 100644 --- a/crates/domain/src/ports.rs +++ b/crates/domain/src/ports.rs @@ -199,6 +199,16 @@ pub trait FederationActionPort: Send + Sync { async fn lookup_actor(&self, handle: &str) -> Result; async fn follow_remote(&self, local_user_id: &UserId, handle: &str) -> Result<(), DomainError>; async fn actor_json(&self, user_id: &UserId) -> Result; + async fn followers_collection_json( + &self, + user_id: &UserId, + page: Option, + ) -> Result; + async fn following_collection_json( + &self, + user_id: &UserId, + page: Option, + ) -> Result; } #[async_trait] diff --git a/crates/domain/src/testing.rs b/crates/domain/src/testing.rs index 2590789..0b13746 100644 --- a/crates/domain/src/testing.rs +++ b/crates/domain/src/testing.rs @@ -551,6 +551,22 @@ impl FederationActionPort for TestStore { async fn actor_json(&self, _user_id: &UserId) -> Result { Err(DomainError::NotFound) } + + async fn followers_collection_json( + &self, + _user_id: &UserId, + _page: Option, + ) -> Result { + Err(DomainError::NotFound) + } + + async fn following_collection_json( + &self, + _user_id: &UserId, + _page: Option, + ) -> Result { + Err(DomainError::NotFound) + } } #[async_trait] diff --git a/crates/presentation/src/handlers/feed.rs b/crates/presentation/src/handlers/feed.rs index cd195b3..c40267e 100644 --- a/crates/presentation/src/handlers/feed.rs +++ b/crates/presentation/src/handlers/feed.rs @@ -14,9 +14,12 @@ use application::use_cases::profile::get_user_by_username; use application::use_cases::search::{search_thoughts, search_users}; use axum::{ extract::{Path, Query, State}, + http::{header, HeaderMap}, + response::{IntoResponse, Response}, Json, }; use domain::models::feed::PageParams; +use domain::value_objects::UserId; fn to_thought_response(e: &domain::models::feed::FeedEntry) -> ThoughtResponse { ThoughtResponse { @@ -151,34 +154,82 @@ pub async fn search_handler( pub async fn get_following_handler( State(s): State, - Path(username): Path, + Path(param): Path, Query(q): Query, -) -> Result, ApiError> { - let user = get_user_by_username(&*s.users, &username).await?; + headers: HeaderMap, +) -> Result { + let accept = headers + .get(header::ACCEPT) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + + if accept.contains("application/activity+json") { + let user_id = resolve_user_id(&s, ¶m).await?; + let page = q.page().try_into().ok(); + let json = s + .federation + .following_collection_json(&user_id, page) + .await?; + return Ok(([(header::CONTENT_TYPE, "application/activity+json")], json).into_response()); + } + + let user = get_user_by_username(&*s.users, ¶m).await?; let page = PageParams { page: q.page(), per_page: q.per_page(), }; let result = get_following(&*s.follows, &user.id, page).await?; - Ok(Json( - serde_json::json!({ "total": result.total, "items": result.items.iter().map(to_user_response).collect::>() }), - )) + Ok(Json(serde_json::json!({ + "total": result.total, + "items": result.items.iter().map(to_user_response).collect::>() + })) + .into_response()) } pub async fn get_followers_handler( State(s): State, - Path(username): Path, + Path(param): Path, Query(q): Query, -) -> Result, ApiError> { - let user = get_user_by_username(&*s.users, &username).await?; + headers: HeaderMap, +) -> Result { + let accept = headers + .get(header::ACCEPT) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + + if accept.contains("application/activity+json") { + let user_id = resolve_user_id(&s, ¶m).await?; + let page = q.page().try_into().ok(); + let json = s + .federation + .followers_collection_json(&user_id, page) + .await?; + return Ok(([(header::CONTENT_TYPE, "application/activity+json")], json).into_response()); + } + + let user = get_user_by_username(&*s.users, ¶m).await?; let page = PageParams { page: q.page(), per_page: q.per_page(), }; let result = get_followers(&*s.follows, &user.id, page).await?; - Ok(Json( - serde_json::json!({ "total": result.total, "items": result.items.iter().map(to_user_response).collect::>() }), - )) + Ok(Json(serde_json::json!({ + "total": result.total, + "items": result.items.iter().map(to_user_response).collect::>() + })) + .into_response()) +} + +async fn resolve_user_id(s: &AppState, param: &str) -> Result { + if let Ok(uuid) = uuid::Uuid::parse_str(param) { + s.users + .find_by_id(&UserId::from_uuid(uuid)) + .await? + .map(|u| u.id) + .ok_or_else(|| ApiError::from(domain::errors::DomainError::NotFound)) + } else { + Ok(get_user_by_username(&*s.users, param).await?.id) + } } #[utoipa::path(