diff --git a/crates/api-types/src/responses.rs b/crates/api-types/src/responses.rs index d3d84f1..80254a8 100644 --- a/crates/api-types/src/responses.rs +++ b/crates/api-types/src/responses.rs @@ -88,6 +88,13 @@ pub struct CreatedApiKeyResponse { pub key: String, } +#[derive(Serialize, Clone, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ProfileField { + pub name: String, + pub value: String, +} + #[derive(Serialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct RemoteActorResponse { @@ -95,4 +102,9 @@ pub struct RemoteActorResponse { pub display_name: Option, pub avatar_url: Option, pub url: String, + pub bio: Option, + pub banner_url: Option, + pub also_known_as: Option, + pub outbox_url: Option, + pub attachment: Vec, } diff --git a/crates/presentation/src/handlers/federation_actors.rs b/crates/presentation/src/handlers/federation_actors.rs new file mode 100644 index 0000000..0dc66ba --- /dev/null +++ b/crates/presentation/src/handlers/federation_actors.rs @@ -0,0 +1,136 @@ +use crate::{ + errors::ApiError, extractors::OptionalAuthUser, handlers::feed::to_thought_response, + state::AppState, +}; +use api_types::requests::PaginationQuery; +use application::use_cases::feed::get_user_feed; +use axum::{ + extract::{Path, Query, State}, + Json, +}; +use domain::{events::DomainEvent, models::feed::PageParams}; + +pub async fn remote_actor_posts_handler( + State(s): State, + Path(handle): Path, + Query(q): Query, + OptionalAuthUser(viewer): OptionalAuthUser, +) -> Result, ApiError> { + let actor = s.federation.lookup_actor(&handle).await?; + + let ap_url = url::Url::parse(&actor.url).map_err(|e| ApiError::BadRequest(e.to_string()))?; + + // Get or create interned local UserId for this remote actor + let author_id = match s.ap_repo.find_remote_actor_id(&ap_url).await? { + Some(id) => id, + None => s.ap_repo.intern_remote_actor(&ap_url).await?, + }; + + // Return cached posts from DB + let page = PageParams { + page: q.page(), + per_page: q.per_page(), + }; + let result = get_user_feed(&*s.feed, &author_id, page, viewer.as_ref()).await?; + + // Trigger background outbox fetch (fire and forget) + if let Some(outbox_url) = &actor.outbox_url { + let _ = s + .events + .publish(&DomainEvent::FetchRemoteActorPosts { + actor_ap_url: actor.url.clone(), + outbox_url: outbox_url.clone(), + }) + .await; + } + + Ok(Json(serde_json::json!({ + "total": result.total, + "page": result.page, + "per_page": result.per_page, + "items": result.items.iter().map(to_thought_response).collect::>(), + }))) +} + +#[cfg(test)] +mod tests { + use super::*; + use async_trait::async_trait; + use axum::{body::Body, http::Request, routing::get, Router}; + use domain::{ + errors::DomainError, + ports::{AuthService, GeneratedToken, PasswordHasher}, + testing::TestStore, + value_objects::{PasswordHash, UserId}, + }; + use std::sync::Arc; + use tower::ServiceExt; + + struct NoOpAuth; + impl AuthService for NoOpAuth { + fn generate_token(&self, _uid: &UserId) -> Result { + Err(DomainError::Internal("noop".into())) + } + fn validate_token(&self, _token: &str) -> Result { + Err(DomainError::Unauthorized) + } + } + + struct NoOpHasher; + #[async_trait] + impl PasswordHasher for NoOpHasher { + async fn hash(&self, _plain: &str) -> Result { + Err(DomainError::Internal("noop".into())) + } + async fn verify(&self, _plain: &str, _hash: &PasswordHash) -> Result { + Ok(false) + } + } + + fn make_state() -> crate::state::AppState { + let store = Arc::new(TestStore::default()); + crate::state::AppState { + users: store.clone(), + thoughts: store.clone(), + likes: store.clone(), + boosts: store.clone(), + follows: store.clone(), + blocks: store.clone(), + tags: store.clone(), + api_keys: store.clone(), + top_friends: store.clone(), + notifications: store.clone(), + remote_actors: store.clone(), + feed: store.clone(), + search: store.clone(), + auth: Arc::new(NoOpAuth), + hasher: Arc::new(NoOpHasher), + events: store.clone(), + federation: store.clone(), + ap_repo: store.clone(), + } + } + + fn app() -> Router { + Router::new() + .route( + "/federation/actors/{handle}/posts", + get(remote_actor_posts_handler), + ) + .with_state(make_state()) + } + + #[tokio::test] + async fn unknown_actor_returns_404() { + let resp = app() + .oneshot( + Request::builder() + .uri("/federation/actors/%40alice%40example.com/posts") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), 404); + } +} diff --git a/crates/presentation/src/handlers/feed.rs b/crates/presentation/src/handlers/feed.rs index c40267e..61a0226 100644 --- a/crates/presentation/src/handlers/feed.rs +++ b/crates/presentation/src/handlers/feed.rs @@ -21,7 +21,7 @@ use axum::{ use domain::models::feed::PageParams; use domain::value_objects::UserId; -fn to_thought_response(e: &domain::models::feed::FeedEntry) -> ThoughtResponse { +pub fn to_thought_response(e: &domain::models::feed::FeedEntry) -> ThoughtResponse { ThoughtResponse { id: e.thought.id.as_uuid(), content: e.thought.content.as_str().to_string(), diff --git a/crates/presentation/src/handlers/mod.rs b/crates/presentation/src/handlers/mod.rs index 82c8aca..6649c72 100644 --- a/crates/presentation/src/handlers/mod.rs +++ b/crates/presentation/src/handlers/mod.rs @@ -1,5 +1,6 @@ pub mod api_keys; pub mod auth; +pub mod federation_actors; pub mod feed; pub mod health; pub mod notifications; diff --git a/crates/presentation/src/handlers/users.rs b/crates/presentation/src/handlers/users.rs index 77b38c2..5b9d62c 100644 --- a/crates/presentation/src/handlers/users.rs +++ b/crates/presentation/src/handlers/users.rs @@ -6,7 +6,7 @@ use crate::{ }; use api_types::{ requests::{PaginationQuery, UpdateProfileRequest}, - responses::{ErrorResponse, RemoteActorResponse, UserResponse}, + responses::{ErrorResponse, ProfileField, RemoteActorResponse, UserResponse}, }; use application::use_cases::feed::list_users; use application::use_cases::profile::{get_user_by_username, update_profile}; @@ -200,6 +200,15 @@ pub async fn lookup_handler( display_name: actor.display_name, avatar_url: actor.avatar_url, url: actor.url, + bio: actor.bio, + banner_url: actor.banner_url, + also_known_as: actor.also_known_as, + outbox_url: actor.outbox_url, + attachment: actor + .attachment + .into_iter() + .map(|(name, value)| ProfileField { name, value }) + .collect(), })) } @@ -263,6 +272,7 @@ mod tests { hasher: Arc::new(NoOpHasher), events: store.clone(), federation: store.clone(), + ap_repo: store.clone(), } } diff --git a/crates/presentation/src/routes.rs b/crates/presentation/src/routes.rs index 9a0669a..25e0af6 100644 --- a/crates/presentation/src/routes.rs +++ b/crates/presentation/src/routes.rs @@ -65,6 +65,10 @@ pub fn router() -> Router { .route("/feed", get(feed::home_feed)) .route("/feed/public", get(feed::public_feed)) .route("/search", get(feed::search_handler)) + .route( + "/federation/actors/{handle}/posts", + get(federation_actors::remote_actor_posts_handler), + ) .route("/tags/popular", get(feed::get_popular_tags)) .route("/tags/{name}", get(feed::tag_thoughts_handler)) // notifications