From 69f6587623b0b0972be4c1aa49d4f3a9e8b34efd Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sat, 9 May 2026 15:45:08 +0200 Subject: [PATCH] federation improvements --- Dockerfile | 4 +- crates/adapters/activitypub/src/activities.rs | 28 +++++--- crates/adapters/activitypub/src/federation.rs | 2 + crates/adapters/activitypub/src/repository.rs | 3 +- crates/adapters/activitypub/src/service.rs | 47 +++++++++++-- crates/adapters/activitypub/src/urls.rs | 7 ++ .../migrations/0006_follower_activity_id.sql | 1 + crates/adapters/sqlite/src/federation.rs | 26 ++++++- crates/adapters/template-askama/src/lib.rs | 30 +++++++- .../template-askama/templates/followers.html | 26 +++++++ .../template-askama/templates/profile.html | 1 + crates/application/src/ports.rs | 9 +++ crates/presentation/src/extractors.rs | 3 + crates/presentation/src/handlers.rs | 70 ++++++++++++++++++- crates/presentation/src/routes.rs | 8 +++ 15 files changed, 241 insertions(+), 24 deletions(-) create mode 100644 crates/adapters/sqlite/migrations/0006_follower_activity_id.sql create mode 100644 crates/adapters/template-askama/templates/followers.html diff --git a/Dockerfile b/Dockerfile index 697341e..115c3df 100644 --- a/Dockerfile +++ b/Dockerfile @@ -40,7 +40,9 @@ RUN sqlite3 /build/dev.db \ sqlite3 /build/dev.db \ < crates/adapters/sqlite/migrations/0004_username.sql && \ sqlite3 /build/dev.db \ - < crates/adapters/sqlite/migrations/0005_activitypub_v2.sql + < crates/adapters/sqlite/migrations/0005_activitypub_v2.sql && \ + sqlite3 /build/dev.db \ + < crates/adapters/sqlite/migrations/0006_follower_activity_id.sql ENV DATABASE_URL=sqlite:///build/dev.db diff --git a/crates/adapters/activitypub/src/activities.rs b/crates/adapters/activitypub/src/activities.rs index aa0a6a8..63fa748 100644 --- a/crates/adapters/activitypub/src/activities.rs +++ b/crates/adapters/activitypub/src/activities.rs @@ -61,6 +61,7 @@ impl Activity for FollowActivity { local_actor.user_id.clone(), self.actor.inner().as_str(), FollowerStatus::Pending, + self.id.as_str(), ) .await?; @@ -292,7 +293,7 @@ pub struct UpdateActivity { #[serde(rename = "type", default)] pub(crate) kind: UpdateType, pub(crate) actor: ObjectId, - pub(crate) object: ReviewObject, + pub(crate) object: serde_json::Value, } #[async_trait::async_trait] @@ -309,19 +310,26 @@ impl Activity for UpdateActivity { } async fn verify(&self, _data: &Data) -> Result<(), Self::Error> { - if self.object.attributed_to.inner() != self.actor.inner() { - return Err(Error::bad_request(anyhow::anyhow!( - "update actor does not match object attributed_to" - ))); - } Ok(()) } async fn receive(self, data: &Data) -> Result<(), Self::Error> { - let ap_id = self.object.id.inner().as_str(); - let rating = self.object.rating.min(5); - let comment = self.object.comment.as_deref(); - let watched_at = self.object.watched_at.naive_utc(); + let object: ReviewObject = match serde_json::from_value(self.object) { + Ok(o) => o, + Err(_) => { + tracing::debug!(actor = %self.actor.inner(), "ignoring non-review Update activity"); + return Ok(()); + } + }; + if object.attributed_to.inner() != self.actor.inner() { + return Err(Error::bad_request(anyhow::anyhow!( + "update actor does not match object attributed_to" + ))); + } + let ap_id = object.id.inner().as_str(); + let rating = object.rating.min(5); + let comment = object.comment.as_deref(); + let watched_at = object.watched_at.naive_utc(); data.federation_repo .update_remote_review(ap_id, self.actor.inner().as_str(), rating, comment, watched_at) .await?; diff --git a/crates/adapters/activitypub/src/federation.rs b/crates/adapters/activitypub/src/federation.rs index 4142390..1aa185a 100644 --- a/crates/adapters/activitypub/src/federation.rs +++ b/crates/adapters/activitypub/src/federation.rs @@ -26,6 +26,7 @@ impl ApFederationConfig { .domain(&data.domain) .app_data(data) .debug(true) + .http_signature_compat(true) .url_verifier(Box::new(PermissiveVerifier)) .build() .await? @@ -34,6 +35,7 @@ impl ApFederationConfig { .domain(&data.domain) .app_data(data) .debug(false) + .http_signature_compat(true) .build() .await? }; diff --git a/crates/adapters/activitypub/src/repository.rs b/crates/adapters/activitypub/src/repository.rs index b674ddf..22b91f0 100644 --- a/crates/adapters/activitypub/src/repository.rs +++ b/crates/adapters/activitypub/src/repository.rs @@ -34,7 +34,8 @@ pub struct Follower { #[async_trait] pub trait FederationRepository: Send + Sync { - async fn add_follower(&self, local_user_id: UserId, remote_actor_url: &str, status: FollowerStatus) -> Result<()>; + async fn add_follower(&self, local_user_id: UserId, remote_actor_url: &str, status: FollowerStatus, follow_activity_id: &str) -> Result<()>; + async fn get_follower_follow_activity_id(&self, local_user_id: UserId, remote_actor_url: &str) -> Result>; async fn remove_follower(&self, local_user_id: UserId, remote_actor_url: &str) -> Result<()>; async fn get_followers(&self, local_user_id: UserId) -> Result>; async fn update_follower_status(&self, local_user_id: UserId, remote_actor_url: &str, status: FollowerStatus) -> Result<()>; diff --git a/crates/adapters/activitypub/src/service.rs b/crates/adapters/activitypub/src/service.rs index d668374..fe64c8d 100644 --- a/crates/adapters/activitypub/src/service.rs +++ b/crates/adapters/activitypub/src/service.rs @@ -244,7 +244,12 @@ impl ActivityPubService { .await? .ok_or_else(|| anyhow::anyhow!("remote actor not found"))?; - let follow_id = crate::urls::activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?; + let follow_id_str = data + .federation_repo + .get_follower_follow_activity_id(local_user_id.clone(), remote_actor_url) + .await? + .ok_or_else(|| anyhow::anyhow!("follow activity id not found for {}", remote_actor_url))?; + let follow_id = Url::parse(&follow_id_str)?; let follow = FollowActivity { id: follow_id, kind: Default::default(), @@ -341,6 +346,37 @@ impl ActivityPubService { .await } + pub async fn get_accepted_followers( + &self, + local_user_id: UserId, + ) -> anyhow::Result> { + let data = self.federation_config.to_request_data(); + let followers = data.federation_repo.get_followers(local_user_id).await?; + Ok(followers + .into_iter() + .filter(|f| f.status == FollowerStatus::Accepted) + .map(|f| f.actor) + .collect()) + } + + pub async fn count_accepted_followers(&self, local_user_id: UserId) -> anyhow::Result { + let data = self.federation_config.to_request_data(); + let followers = data.federation_repo.get_followers(local_user_id).await?; + Ok(followers + .into_iter() + .filter(|f| f.status == FollowerStatus::Accepted) + .count()) + } + + pub async fn remove_follower( + &self, + local_user_id: UserId, + actor_url: &str, + ) -> anyhow::Result<()> { + let data = self.federation_config.to_request_data(); + data.federation_repo.remove_follower(local_user_id, actor_url).await + } + fn spawn_backfill(&self, owner_user_id: UserId, follower_inbox_url: String) { let config = self.federation_config.clone(); let base_url = self.base_url.clone(); @@ -369,10 +405,11 @@ impl ActivityPubService { let inbox = Url::parse(&follower_inbox_url)?; let history = data.movie_repo.get_user_history(&owner_user_id).await?; - let local_reviews: Vec<_> = history + let mut local_reviews: Vec<_> = history .into_iter() .filter(|e| matches!(e.review().source(), domain::models::ReviewSource::Local)) .collect(); + local_reviews.reverse(); // oldest first so Mastodon feed is chronological let total = local_reviews.len(); @@ -418,12 +455,14 @@ impl ActivityPubService { use activitypub_federation::traits::Object; use crate::objects::DbReview; - let ap_id = crate::urls::review_url(base_url, review.id()); + let review_id = review.id().clone(); + let ap_id = crate::urls::review_url(base_url, &review_id); let db_review = DbReview { review, ap_id }; let object = db_review.into_json(data).await .map_err(|e| anyhow::anyhow!("{e}"))?; - let activity_id = crate::urls::activity_url(base_url).map_err(|e| anyhow::anyhow!("{e}"))?; + let activity_id = crate::urls::create_activity_url(base_url, &review_id) + .map_err(|e| anyhow::anyhow!("{e}"))?; let create = CreateActivity { id: activity_id, kind: Default::default(), diff --git a/crates/adapters/activitypub/src/urls.rs b/crates/adapters/activitypub/src/urls.rs index d8ad31d..deb1dcf 100644 --- a/crates/adapters/activitypub/src/urls.rs +++ b/crates/adapters/activitypub/src/urls.rs @@ -29,3 +29,10 @@ pub fn review_url(base_url: &str, review_id: &ReviewId) -> Url { Url::parse(&format!("{}/reviews/{}", base_url, review_id.value())) .expect("base_url is always a valid URL prefix") } + +/// Stable Create-activity URL derived from review ID. +/// Deterministic so repeated backfills to different followers don't create duplicate posts. +pub fn create_activity_url(base_url: &str, review_id: &ReviewId) -> Result { + Url::parse(&format!("{}/activities/create/{}", base_url, review_id.value())) + .map_err(|e| Error::bad_request(anyhow::anyhow!(e))) +} diff --git a/crates/adapters/sqlite/migrations/0006_follower_activity_id.sql b/crates/adapters/sqlite/migrations/0006_follower_activity_id.sql new file mode 100644 index 0000000..923eb1d --- /dev/null +++ b/crates/adapters/sqlite/migrations/0006_follower_activity_id.sql @@ -0,0 +1 @@ +ALTER TABLE ap_followers ADD COLUMN follow_activity_id TEXT; diff --git a/crates/adapters/sqlite/src/federation.rs b/crates/adapters/sqlite/src/federation.rs index 8f09b8f..b41a781 100644 --- a/crates/adapters/sqlite/src/federation.rs +++ b/crates/adapters/sqlite/src/federation.rs @@ -42,6 +42,7 @@ impl FederationRepository for SqliteFederationRepository { local_user_id: UserId, remote_actor_url: &str, status: FollowerStatus, + follow_activity_id: &str, ) -> Result<()> { let uid = local_user_id.value().to_string(); let status_str = status_to_str(&status); @@ -49,20 +50,39 @@ impl FederationRepository for SqliteFederationRepository { let created_at = datetime_to_str(&now); sqlx::query( - "INSERT INTO ap_followers (local_user_id, remote_actor_url, status, created_at) - VALUES (?1, ?2, ?3, ?4) - ON CONFLICT(local_user_id, remote_actor_url) DO UPDATE SET status = excluded.status", + "INSERT INTO ap_followers (local_user_id, remote_actor_url, status, created_at, follow_activity_id) + VALUES (?1, ?2, ?3, ?4, ?5) + ON CONFLICT(local_user_id, remote_actor_url) DO UPDATE SET + status = excluded.status, + follow_activity_id = excluded.follow_activity_id", ) .bind(&uid) .bind(remote_actor_url) .bind(status_str) .bind(&created_at) + .bind(follow_activity_id) .execute(&self.pool) .await?; Ok(()) } + async fn get_follower_follow_activity_id( + &self, + local_user_id: UserId, + remote_actor_url: &str, + ) -> Result> { + let uid = local_user_id.value().to_string(); + let row: Option> = sqlx::query_scalar( + "SELECT follow_activity_id FROM ap_followers WHERE local_user_id = ? AND remote_actor_url = ?", + ) + .bind(&uid) + .bind(remote_actor_url) + .fetch_optional(&self.pool) + .await?; + Ok(row.flatten()) + } + async fn remove_follower(&self, local_user_id: UserId, remote_actor_url: &str) -> Result<()> { let uid = local_user_id.value().to_string(); diff --git a/crates/adapters/template-askama/src/lib.rs b/crates/adapters/template-askama/src/lib.rs index d7f3ca0..2249a88 100644 --- a/crates/adapters/template-askama/src/lib.rs +++ b/crates/adapters/template-askama/src/lib.rs @@ -1,8 +1,8 @@ use askama::Template; use chrono::Datelike; use application::ports::{ - ActivityFeedPageData, FollowingPageData, HtmlPageContext, HtmlRenderer, LoginPageData, - NewReviewPageData, ProfilePageData, RegisterPageData, UsersPageData, + ActivityFeedPageData, FollowersPageData, FollowingPageData, HtmlPageContext, HtmlRenderer, + LoginPageData, NewReviewPageData, ProfilePageData, RegisterPageData, UsersPageData, }; use domain::models::{ DiaryEntry, FeedEntry, MonthActivity, MonthlyRating, ReviewSource, UserStats, @@ -124,6 +124,7 @@ struct ProfileTemplate<'a> { is_own_profile: bool, error: Option, following_count: usize, + followers_count: usize, pending_followers: Vec, } @@ -142,6 +143,15 @@ struct FollowingTemplate { error: Option, } +#[derive(Template)] +#[template(path = "followers.html")] +struct FollowersTemplate { + ctx: HtmlPageContext, + user_id: uuid::Uuid, + actors: Vec, + error: Option, +} + struct HeatmapCell { month_label: String, count: i64, @@ -338,6 +348,7 @@ impl HtmlRenderer for AskamaHtmlRenderer { is_own_profile: data.is_own_profile, error: data.error, following_count: data.following_count, + followers_count: data.followers_count, pending_followers: data.pending_followers.into_iter().map(|a| RemoteActorData { handle: a.handle, url: a.url, @@ -362,4 +373,19 @@ impl HtmlRenderer for AskamaHtmlRenderer { .render() .map_err(|e| e.to_string()) } + + fn render_followers_page(&self, data: FollowersPageData) -> Result { + FollowersTemplate { + ctx: data.ctx, + user_id: data.user_id, + actors: data.actors.into_iter().map(|a| RemoteActorData { + handle: a.handle, + display_name: a.display_name, + url: a.url, + }).collect(), + error: data.error, + } + .render() + .map_err(|e| e.to_string()) + } } diff --git a/crates/adapters/template-askama/templates/followers.html b/crates/adapters/template-askama/templates/followers.html new file mode 100644 index 0000000..e94cc10 --- /dev/null +++ b/crates/adapters/template-askama/templates/followers.html @@ -0,0 +1,26 @@ +{% extends "base.html" %} +{% block content %} +

Followers

+{% if let Some(err) = error %} +

{{ err }}

+{% endif %} +{% if actors.is_empty() %} +

No followers yet.

+{% else %} +
    +{% for actor in actors %} +
  • + {{ actor.handle }} + {% if let Some(name) = actor.display_name %} + ({{ name }}) + {% endif %} + {{ actor.url }} +
    + + +
    +
  • +{% endfor %} +
+{% endif %} +{% endblock %} diff --git a/crates/adapters/template-askama/templates/profile.html b/crates/adapters/template-askama/templates/profile.html index e39f22a..0c6befb 100644 --- a/crates/adapters/template-askama/templates/profile.html +++ b/crates/adapters/template-askama/templates/profile.html @@ -36,6 +36,7 @@ {% endif %} View following ({{ following_count }}) + View followers ({{ followers_count }}) {% if !pending_followers.is_empty() %}

Pending follow requests ({{ pending_followers.len() }})

diff --git a/crates/application/src/ports.rs b/crates/application/src/ports.rs index 6aeb6fd..2bdc938 100644 --- a/crates/application/src/ports.rs +++ b/crates/application/src/ports.rs @@ -66,6 +66,7 @@ pub struct ProfilePageData { pub is_own_profile: bool, pub error: Option, pub following_count: usize, + pub followers_count: usize, pub pending_followers: Vec, } @@ -76,6 +77,13 @@ pub struct FollowingPageData { pub error: Option, } +pub struct FollowersPageData { + pub ctx: HtmlPageContext, + pub user_id: Uuid, + pub actors: Vec, + pub error: Option, +} + pub trait HtmlRenderer: Send + Sync { fn render_diary_page(&self, data: &Paginated, ctx: HtmlPageContext) -> Result; fn render_login_page(&self, data: LoginPageData<'_>) -> Result; @@ -85,6 +93,7 @@ pub trait HtmlRenderer: Send + Sync { fn render_users_page(&self, data: UsersPageData) -> Result; fn render_profile_page(&self, data: ProfilePageData) -> Result; fn render_following_page(&self, data: FollowingPageData) -> Result; + fn render_followers_page(&self, data: FollowersPageData) -> Result; } pub trait RssFeedRenderer: Send + Sync { diff --git a/crates/presentation/src/extractors.rs b/crates/presentation/src/extractors.rs index 2c74d79..c7ce0e1 100644 --- a/crates/presentation/src/extractors.rs +++ b/crates/presentation/src/extractors.rs @@ -150,6 +150,7 @@ mod tests { fn render_users_page(&self, _: application::ports::UsersPageData) -> Result { panic!() } fn render_profile_page(&self, _: application::ports::ProfilePageData) -> Result { panic!() } fn render_following_page(&self, _: application::ports::FollowingPageData) -> Result { panic!() } + fn render_followers_page(&self, _: application::ports::FollowersPageData) -> Result { panic!() } } struct PanicRssRenderer; @@ -313,6 +314,7 @@ mod tests { fn render_users_page(&self, _: application::ports::UsersPageData) -> Result { panic!() } fn render_profile_page(&self, _: application::ports::ProfilePageData) -> Result { panic!() } fn render_following_page(&self, _: application::ports::FollowingPageData) -> Result { panic!() } + fn render_followers_page(&self, _: application::ports::FollowersPageData) -> Result { panic!() } } struct PanicRssRenderer2; impl crate::ports::RssFeedRenderer for PanicRssRenderer2 { @@ -375,6 +377,7 @@ mod tests { fn render_users_page(&self, _: application::ports::UsersPageData) -> Result { panic!() } fn render_profile_page(&self, _: application::ports::ProfilePageData) -> Result { panic!() } fn render_following_page(&self, _: application::ports::FollowingPageData) -> Result { panic!() } + fn render_followers_page(&self, _: application::ports::FollowersPageData) -> Result { panic!() } } struct PanicRssRenderer3; impl crate::ports::RssFeedRenderer for PanicRssRenderer3 { diff --git a/crates/presentation/src/handlers.rs b/crates/presentation/src/handlers.rs index 48bde80..b78bc8c 100644 --- a/crates/presentation/src/handlers.rs +++ b/crates/presentation/src/handlers.rs @@ -15,7 +15,7 @@ pub mod html { use application::{ commands::{DeleteReviewCommand, LoginCommand, RegisterCommand}, - ports::{FollowingPageData, HtmlPageContext, LoginPageData, NewReviewPageData, RegisterPageData, RemoteActorView}, + ports::{FollowersPageData, FollowingPageData, HtmlPageContext, LoginPageData, NewReviewPageData, RegisterPageData, RemoteActorView}, use_cases::{delete_review, log_review, login as login_uc, register as register_uc}, }; use domain::{errors::DomainError, value_objects::UserId}; @@ -340,8 +340,7 @@ pub mod html { let following_count = if is_own_profile { if let Some(ref uid) = user_id { - state.ap_service.count_following(uid.clone()).await - .unwrap_or(0) + state.ap_service.count_following(uid.clone()).await.unwrap_or(0) } else { 0 } @@ -349,6 +348,15 @@ pub mod html { 0 }; + let followers_count = if is_own_profile { + state.ap_service + .count_accepted_followers(domain::value_objects::UserId::from_uuid(profile_user_uuid)) + .await + .unwrap_or(0) + } else { + 0 + }; + let pending_followers = if is_own_profile { state.ap_service .get_pending_followers(domain::value_objects::UserId::from_uuid(profile_user_uuid)) @@ -396,6 +404,7 @@ pub mod html { is_own_profile, error: params.error, following_count, + followers_count, pending_followers, }; match state.html_renderer.render_profile_page(data) { @@ -516,6 +525,61 @@ pub mod html { } } } + + pub async fn get_followers_page( + RequiredCookieUser(user_id): RequiredCookieUser, + State(state): State, + Path(profile_user_uuid): Path, + Query(params): Query, + ) -> impl IntoResponse { + if user_id.value() != profile_user_uuid { + return StatusCode::FORBIDDEN.into_response(); + } + let mut ctx = build_page_context(&state, Some(user_id.clone())).await; + ctx.page_title = "Followers — Movies Diary".to_string(); + ctx.canonical_url = format!("{}/users/{}/followers-list", state.app_ctx.config.base_url, profile_user_uuid); + match state.ap_service.get_accepted_followers(user_id).await { + Ok(followers) => { + let actors = followers.into_iter().map(|a| RemoteActorView { + handle: a.handle, + display_name: a.display_name, + url: a.url, + }).collect(); + let data = FollowersPageData { + ctx, + user_id: profile_user_uuid, + actors, + error: params.error, + }; + match state.html_renderer.render_followers_page(data) { + Ok(html) => Html(html).into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(), + } + } + Err(e) => { + tracing::error!("get_followers error: {:?}", e); + (StatusCode::INTERNAL_SERVER_ERROR, "Failed to load followers list").into_response() + } + } + } + + pub async fn remove_follower( + RequiredCookieUser(user_id): RequiredCookieUser, + State(state): State, + Path(profile_user_uuid): Path, + Form(form): Form, + ) -> impl IntoResponse { + if user_id.value() != profile_user_uuid { + return StatusCode::FORBIDDEN.into_response(); + } + match state.ap_service.remove_follower(user_id, &form.actor_url).await { + Ok(_) => Redirect::to(&format!("/users/{}/followers-list", profile_user_uuid)).into_response(), + Err(e) => { + let msg = encode_error(&e.to_string()); + Redirect::to(&format!("/users/{}/followers-list?error={}", profile_user_uuid, msg)).into_response() + } + } + } } pub mod posters { diff --git a/crates/presentation/src/routes.rs b/crates/presentation/src/routes.rs index 2031be8..ef87f1c 100644 --- a/crates/presentation/src/routes.rs +++ b/crates/presentation/src/routes.rs @@ -124,6 +124,14 @@ fn html_routes(rate_limit: u64) -> Router { "/users/{id}/following-list", routing::get(handlers::html::get_following_page), ) + .route( + "/users/{id}/followers-list", + routing::get(handlers::html::get_followers_page), + ) + .route( + "/users/{id}/followers/remove", + routing::post(handlers::html::remove_follower), + ) .merge(auth) .route( "/reviews/new",