feat: v2 rewrite — hexagonal arch, ActivityPub federation, NATS, deployment-ready #1

Merged
GKaszewski merged 334 commits from v2 into master 2026-05-16 09:42:43 +00:00
8 changed files with 118 additions and 2 deletions
Showing only changes of commit c536cc2cd4 - Show all commits

View File

@@ -110,3 +110,20 @@ pub struct RemoteActorResponse {
pub following_url: Option<String>,
pub attachment: Vec<ProfileField>,
}
#[derive(Serialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct ActorConnectionResponse {
pub handle: String,
pub display_name: Option<String>,
pub avatar_url: Option<String>,
pub url: String,
}
#[derive(Serialize, utoipa::ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct ActorConnectionPageResponse {
pub items: Vec<ActorConnectionResponse>,
pub page: u32,
pub has_more: bool,
}

View File

@@ -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<dyn domain::ports::FederationActionPort>,
ap_repo: Arc::new(PgActivityPubRepository::new(pool.clone())),
remote_actor_connections: Arc::new(PgRemoteActorConnectionRepository::new(pool.clone())),
};
Infrastructure { state, ap_service }

View File

@@ -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<AppState>,
Path(handle): Path<String>,
Query(q): Query<PaginationQuery>,
) -> Result<Json<ActorConnectionPageResponse>, ApiError> {
actor_connections_handler(s, handle, "followers", q.page() as u32).await
}
pub async fn actor_following_handler(
State(s): State<AppState>,
Path(handle): Path<String>,
Query(q): Query<PaginationQuery>,
) -> Result<Json<ActorConnectionPageResponse>, 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<Json<ActorConnectionPageResponse>, 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(),
}
}

View File

@@ -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())
}

View File

@@ -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(),
}
}

View File

@@ -275,6 +275,7 @@ mod tests {
events: store.clone(),
federation: store.clone(),
ap_repo: store.clone(),
remote_actor_connections: store.clone(),
}
}

View File

@@ -69,6 +69,14 @@ pub fn router() -> Router<AppState> {
"/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

View File

@@ -21,4 +21,5 @@ pub struct AppState {
pub events: Arc<dyn EventPublisher>,
pub federation: Arc<dyn FederationActionPort>,
pub ap_repo: Arc<dyn ActivityPubRepository>,
pub remote_actor_connections: Arc<dyn RemoteActorConnectionRepository>,
}