diff --git a/crates/api-types/src/responses.rs b/crates/api-types/src/responses.rs index f61a406..dfcf77e 100644 --- a/crates/api-types/src/responses.rs +++ b/crates/api-types/src/responses.rs @@ -110,3 +110,20 @@ pub struct RemoteActorResponse { pub following_url: Option, pub attachment: Vec, } + +#[derive(Serialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ActorConnectionResponse { + pub handle: String, + pub display_name: Option, + pub avatar_url: Option, + pub url: String, +} + +#[derive(Serialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ActorConnectionPageResponse { + pub items: Vec, + pub page: u32, + pub has_more: bool, +} diff --git a/crates/bootstrap/src/factory.rs b/crates/bootstrap/src/factory.rs index 0b08bba..335b8aa 100644 --- a/crates/bootstrap/src/factory.rs +++ b/crates/bootstrap/src/factory.rs @@ -8,6 +8,7 @@ use domain::{errors::DomainError, events::DomainEvent, ports::EventPublisher}; use event_transport::EventPublisherAdapter; use nats::NatsTransport; use postgres::activitypub::PgActivityPubRepository; +use postgres::remote_actor_connections::PgRemoteActorConnectionRepository; use postgres_federation::{PostgresApUserRepository, PostgresFederationRepository}; use presentation::state::AppState; @@ -111,6 +112,7 @@ pub async fn build(cfg: &Config) -> Infrastructure { events: event_publisher, federation: ap_service.clone() as Arc, ap_repo: Arc::new(PgActivityPubRepository::new(pool.clone())), + remote_actor_connections: Arc::new(PgRemoteActorConnectionRepository::new(pool.clone())), }; Infrastructure { state, ap_service } diff --git a/crates/presentation/src/handlers/federation_actors.rs b/crates/presentation/src/handlers/federation_actors.rs index 004e412..f6f3136 100644 --- a/crates/presentation/src/handlers/federation_actors.rs +++ b/crates/presentation/src/handlers/federation_actors.rs @@ -2,7 +2,10 @@ use crate::{ errors::ApiError, extractors::OptionalAuthUser, handlers::feed::to_thought_response, state::AppState, }; -use api_types::requests::PaginationQuery; +use api_types::{ + requests::PaginationQuery, + responses::{ActorConnectionPageResponse, ActorConnectionResponse}, +}; use application::use_cases::feed::get_user_feed; use axum::{ extract::{Path, Query, State}, @@ -71,6 +74,85 @@ pub async fn remote_actor_posts_handler( }))) } +const CACHE_TTL_SECS: i64 = 3600; + +pub async fn actor_followers_handler( + State(s): State, + Path(handle): Path, + Query(q): Query, +) -> Result, ApiError> { + actor_connections_handler(s, handle, "followers", q.page() as u32).await +} + +pub async fn actor_following_handler( + State(s): State, + Path(handle): Path, + Query(q): Query, +) -> Result, ApiError> { + actor_connections_handler(s, handle, "following", q.page() as u32).await +} + +async fn actor_connections_handler( + s: AppState, + handle: String, + connection_type: &str, + page: u32, +) -> Result, ApiError> { + const PAGE_SIZE: usize = 20; + + let actor = s.federation.lookup_actor(&handle).await?; + + let collection_url = match connection_type { + "followers" => actor + .followers_url + .ok_or_else(|| ApiError::BadRequest("actor has no followers URL".into()))?, + _ => actor + .following_url + .ok_or_else(|| ApiError::BadRequest("actor has no following URL".into()))?, + }; + + let items = s + .remote_actor_connections + .list_connections(&actor.url, connection_type, page) + .await?; + + let stale = match s + .remote_actor_connections + .connection_page_age(&actor.url, connection_type, page) + .await? + { + None => true, + Some(age) => chrono::Utc::now().signed_duration_since(age).num_seconds() > CACHE_TTL_SECS, + }; + + if stale { + let _ = s + .events + .publish(&DomainEvent::FetchActorConnections { + actor_ap_url: actor.url.clone(), + collection_url, + connection_type: connection_type.to_string(), + page, + }) + .await; + } + + let has_more = items.len() >= PAGE_SIZE; + Ok(Json(ActorConnectionPageResponse { + items: items + .into_iter() + .map(|a| ActorConnectionResponse { + handle: a.handle, + display_name: a.display_name, + avatar_url: a.avatar_url, + url: a.url, + }) + .collect(), + page, + has_more, + })) +} + #[cfg(test)] mod tests { use super::*; @@ -127,6 +209,7 @@ mod tests { events: store.clone(), federation: store.clone(), ap_repo: store.clone(), + remote_actor_connections: store.clone(), } } diff --git a/crates/presentation/src/handlers/notifications.rs b/crates/presentation/src/handlers/notifications.rs index 729c153..aab6174 100644 --- a/crates/presentation/src/handlers/notifications.rs +++ b/crates/presentation/src/handlers/notifications.rs @@ -113,13 +113,15 @@ mod tests { hasher: Arc::new(NoOpHasher), events: store.clone(), federation: store.clone(), + ap_repo: store.clone(), + remote_actor_connections: store.clone(), } } fn app() -> Router { Router::new() .route("/notifications", patch(mark_all_read)) - .route("/notifications/:id", patch(mark_notification_read)) + .route("/notifications/{id}", patch(mark_notification_read)) .with_state(make_state()) } diff --git a/crates/presentation/src/handlers/social.rs b/crates/presentation/src/handlers/social.rs index 3a7c1b1..8c8e9e7 100644 --- a/crates/presentation/src/handlers/social.rs +++ b/crates/presentation/src/handlers/social.rs @@ -186,6 +186,8 @@ mod tests { hasher: Arc::new(NoOpHasher), events: store.clone(), federation: store.clone(), + ap_repo: store.clone(), + remote_actor_connections: store.clone(), } } diff --git a/crates/presentation/src/handlers/users.rs b/crates/presentation/src/handlers/users.rs index 85fd0b1..e655c60 100644 --- a/crates/presentation/src/handlers/users.rs +++ b/crates/presentation/src/handlers/users.rs @@ -275,6 +275,7 @@ mod tests { events: store.clone(), federation: store.clone(), ap_repo: store.clone(), + remote_actor_connections: store.clone(), } } diff --git a/crates/presentation/src/routes.rs b/crates/presentation/src/routes.rs index 25e0af6..9061134 100644 --- a/crates/presentation/src/routes.rs +++ b/crates/presentation/src/routes.rs @@ -69,6 +69,14 @@ pub fn router() -> Router { "/federation/actors/{handle}/posts", get(federation_actors::remote_actor_posts_handler), ) + .route( + "/federation/actors/{handle}/followers-list", + get(federation_actors::actor_followers_handler), + ) + .route( + "/federation/actors/{handle}/following-list", + get(federation_actors::actor_following_handler), + ) .route("/tags/popular", get(feed::get_popular_tags)) .route("/tags/{name}", get(feed::tag_thoughts_handler)) // notifications diff --git a/crates/presentation/src/state.rs b/crates/presentation/src/state.rs index 5b9fbfb..4f6e2d7 100644 --- a/crates/presentation/src/state.rs +++ b/crates/presentation/src/state.rs @@ -21,4 +21,5 @@ pub struct AppState { pub events: Arc, pub federation: Arc, pub ap_repo: Arc, + pub remote_actor_connections: Arc, }