From 470b29c9e17fc601cf700d811f39491f15aed390 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sat, 9 May 2026 13:53:45 +0200 Subject: [PATCH] federation refinement --- ...283a78b18c57cb72978921ccc0df963a756ba.json | 38 +++ ...32d9ea7eabc99d9f1a44694e5d10762606f82.json | 12 - ...537419b7067ff665a03e248e6ea0fe7b6919b.json | 12 + ...3fa7ea30eeb985c4bfd12b0fc3997d1ba03bb.json | 38 +++ ...75dec1e6681739d722481233a3d9e7a01955.json} | 12 +- ...3f78b29854518c48c2da3cc11a94d6d37bb1.json} | 8 +- ...0c4f15ca554e229778b6cfa04889b7231b36.json} | 12 +- ...37ef4993fe5bff095dc43e681b848a398f318.json | 38 --- Dockerfile | 5 +- crates/adapters/activitypub/src/activities.rs | 163 +++++---- .../adapters/activitypub/src/actor_handler.rs | 2 +- crates/adapters/activitypub/src/actors.rs | 47 +-- crates/adapters/activitypub/src/data.rs | 5 +- crates/adapters/activitypub/src/error.rs | 37 +- .../adapters/activitypub/src/event_handler.rs | 67 ++-- crates/adapters/activitypub/src/federation.rs | 38 ++- .../activitypub/src/followers_handler.rs | 66 ++-- crates/adapters/activitypub/src/lib.rs | 1 + crates/adapters/activitypub/src/objects.rs | 56 +-- crates/adapters/activitypub/src/outbox.rs | 22 +- crates/adapters/activitypub/src/repository.rs | 36 +- crates/adapters/activitypub/src/service.rs | 318 ++++++++++++++++-- crates/adapters/activitypub/src/urls.rs | 31 ++ crates/adapters/activitypub/src/webfinger.rs | 18 +- crates/adapters/event-publisher/src/lib.rs | 19 +- crates/adapters/metadata/src/omdb.rs | 4 +- .../sqlite/migrations/0004_username.sql | 33 ++ .../sqlite/migrations/0005_activitypub_v2.sql | 11 + crates/adapters/sqlite/src/federation.rs | 146 +++++++- crates/adapters/sqlite/src/lib.rs | 8 +- crates/adapters/sqlite/src/models.rs | 14 +- crates/adapters/sqlite/src/users.rs | 103 +++--- crates/adapters/template-askama/src/lib.rs | 52 ++- .../templates/activity_feed.html | 8 +- .../template-askama/templates/profile.html | 27 +- .../template-askama/templates/register.html | 6 + .../template-askama/templates/users.html | 8 +- crates/application/src/commands.rs | 1 + crates/application/src/movie_resolver.rs | 4 +- crates/application/src/ports.rs | 1 + crates/application/src/queries.rs | 35 +- .../src/use_cases/get_user_profile.rs | 69 ++-- crates/application/src/use_cases/register.rs | 13 +- crates/domain/src/events.rs | 7 + crates/domain/src/models/mod.rs | 63 ++-- crates/domain/src/ports.rs | 6 +- crates/domain/src/services/review_history.rs | 24 +- crates/domain/src/value_objects.rs | 101 +++--- crates/presentation/src/dtos.rs | 7 + crates/presentation/src/event_handlers.rs | 1 + crates/presentation/src/extractors.rs | 26 +- crates/presentation/src/handlers.rs | 92 ++++- crates/presentation/src/main.rs | 1 + crates/presentation/src/routes.rs | 10 +- crates/presentation/tests/api_test.rs | 6 +- static/style.css | 69 ++++ 56 files changed, 1513 insertions(+), 544 deletions(-) create mode 100644 .sqlx/query-0e36417429360e7e332f60768c2283a78b18c57cb72978921ccc0df963a756ba.json delete mode 100644 .sqlx/query-18de90feb13b9f467f06d0ce25332d9ea7eabc99d9f1a44694e5d10762606f82.json create mode 100644 .sqlx/query-21751a2efb5fc58f5c1057f332f537419b7067ff665a03e248e6ea0fe7b6919b.json create mode 100644 .sqlx/query-2c7353f34c4748d4d4be6abbf343fa7ea30eeb985c4bfd12b0fc3997d1ba03bb.json rename .sqlx/{query-1bc5a51762717e45292626052f0a65ac0b8a001798a2476ea86143c5565df399.json => query-4eeae6aa887319cab4a9fd673c3a75dec1e6681739d722481233a3d9e7a01955.json} (63%) rename .sqlx/{query-1d62f367cd14f6fa82aff8aa289e499a56832d1c90eac2f5ba06b019c3f86541.json => query-70843058606802f0958d216a47473f78b29854518c48c2da3cc11a94d6d37bb1.json} (80%) rename .sqlx/{query-167481bb1692cc81531d9a5cd85425e43d09a6df97c335ac347f7cfd61acd171.json => query-e51c6da943bd326a09632aa0cfd30c4f15ca554e229778b6cfa04889b7231b36.json} (63%) delete mode 100644 .sqlx/query-f259059d76f29cade94e249735d37ef4993fe5bff095dc43e681b848a398f318.json create mode 100644 crates/adapters/activitypub/src/urls.rs create mode 100644 crates/adapters/sqlite/migrations/0004_username.sql create mode 100644 crates/adapters/sqlite/migrations/0005_activitypub_v2.sql diff --git a/.sqlx/query-0e36417429360e7e332f60768c2283a78b18c57cb72978921ccc0df963a756ba.json b/.sqlx/query-0e36417429360e7e332f60768c2283a78b18c57cb72978921ccc0df963a756ba.json new file mode 100644 index 0000000..8c06140 --- /dev/null +++ b/.sqlx/query-0e36417429360e7e332f60768c2283a78b18c57cb72978921ccc0df963a756ba.json @@ -0,0 +1,38 @@ +{ + "db_name": "SQLite", + "query": "SELECT id, email, username, password_hash FROM users WHERE username = ?", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "email", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "username", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "password_hash", + "ordinal": 3, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + true, + false, + false, + false + ] + }, + "hash": "0e36417429360e7e332f60768c2283a78b18c57cb72978921ccc0df963a756ba" +} diff --git a/.sqlx/query-18de90feb13b9f467f06d0ce25332d9ea7eabc99d9f1a44694e5d10762606f82.json b/.sqlx/query-18de90feb13b9f467f06d0ce25332d9ea7eabc99d9f1a44694e5d10762606f82.json deleted file mode 100644 index 78a50ea..0000000 --- a/.sqlx/query-18de90feb13b9f467f06d0ce25332d9ea7eabc99d9f1a44694e5d10762606f82.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT OR IGNORE INTO users (id, email, password_hash, created_at) VALUES (?, ?, ?, ?)", - "describe": { - "columns": [], - "parameters": { - "Right": 4 - }, - "nullable": [] - }, - "hash": "18de90feb13b9f467f06d0ce25332d9ea7eabc99d9f1a44694e5d10762606f82" -} diff --git a/.sqlx/query-21751a2efb5fc58f5c1057f332f537419b7067ff665a03e248e6ea0fe7b6919b.json b/.sqlx/query-21751a2efb5fc58f5c1057f332f537419b7067ff665a03e248e6ea0fe7b6919b.json new file mode 100644 index 0000000..af5b8a8 --- /dev/null +++ b/.sqlx/query-21751a2efb5fc58f5c1057f332f537419b7067ff665a03e248e6ea0fe7b6919b.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO users (id, email, username, password_hash, created_at) VALUES (?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 5 + }, + "nullable": [] + }, + "hash": "21751a2efb5fc58f5c1057f332f537419b7067ff665a03e248e6ea0fe7b6919b" +} diff --git a/.sqlx/query-2c7353f34c4748d4d4be6abbf343fa7ea30eeb985c4bfd12b0fc3997d1ba03bb.json b/.sqlx/query-2c7353f34c4748d4d4be6abbf343fa7ea30eeb985c4bfd12b0fc3997d1ba03bb.json new file mode 100644 index 0000000..71a8608 --- /dev/null +++ b/.sqlx/query-2c7353f34c4748d4d4be6abbf343fa7ea30eeb985c4bfd12b0fc3997d1ba03bb.json @@ -0,0 +1,38 @@ +{ + "db_name": "SQLite", + "query": "SELECT u.id AS \"id!: String\",\n u.email AS \"email!: String\",\n COUNT(DISTINCT r.movie_id) AS \"total_movies!: i64\",\n AVG(CAST(r.rating AS REAL)) AS avg_rating\n FROM users u\n LEFT JOIN reviews r ON r.user_id = u.id AND r.remote_actor_url IS NULL\n GROUP BY u.id, u.email\n ORDER BY u.email ASC", + "describe": { + "columns": [ + { + "name": "id!: String", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "email!: String", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "total_movies!: i64", + "ordinal": 2, + "type_info": "Integer" + }, + { + "name": "avg_rating", + "ordinal": 3, + "type_info": "Float" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + true, + false, + false, + true + ] + }, + "hash": "2c7353f34c4748d4d4be6abbf343fa7ea30eeb985c4bfd12b0fc3997d1ba03bb" +} diff --git a/.sqlx/query-1bc5a51762717e45292626052f0a65ac0b8a001798a2476ea86143c5565df399.json b/.sqlx/query-4eeae6aa887319cab4a9fd673c3a75dec1e6681739d722481233a3d9e7a01955.json similarity index 63% rename from .sqlx/query-1bc5a51762717e45292626052f0a65ac0b8a001798a2476ea86143c5565df399.json rename to .sqlx/query-4eeae6aa887319cab4a9fd673c3a75dec1e6681739d722481233a3d9e7a01955.json index 56618eb..e49a847 100644 --- a/.sqlx/query-1bc5a51762717e45292626052f0a65ac0b8a001798a2476ea86143c5565df399.json +++ b/.sqlx/query-4eeae6aa887319cab4a9fd673c3a75dec1e6681739d722481233a3d9e7a01955.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT id, email, password_hash FROM users WHERE id = ?", + "query": "SELECT id, email, username, password_hash FROM users WHERE email = ?", "describe": { "columns": [ { @@ -14,19 +14,25 @@ "type_info": "Text" }, { - "name": "password_hash", + "name": "username", "ordinal": 2, "type_info": "Text" + }, + { + "name": "password_hash", + "ordinal": 3, + "type_info": "Text" } ], "parameters": { "Right": 1 }, "nullable": [ + true, false, false, false ] }, - "hash": "1bc5a51762717e45292626052f0a65ac0b8a001798a2476ea86143c5565df399" + "hash": "4eeae6aa887319cab4a9fd673c3a75dec1e6681739d722481233a3d9e7a01955" } diff --git a/.sqlx/query-1d62f367cd14f6fa82aff8aa289e499a56832d1c90eac2f5ba06b019c3f86541.json b/.sqlx/query-70843058606802f0958d216a47473f78b29854518c48c2da3cc11a94d6d37bb1.json similarity index 80% rename from .sqlx/query-1d62f367cd14f6fa82aff8aa289e499a56832d1c90eac2f5ba06b019c3f86541.json rename to .sqlx/query-70843058606802f0958d216a47473f78b29854518c48c2da3cc11a94d6d37bb1.json index aade0da..f0fefaa 100644 --- a/.sqlx/query-1d62f367cd14f6fa82aff8aa289e499a56832d1c90eac2f5ba06b019c3f86541.json +++ b/.sqlx/query-70843058606802f0958d216a47473f78b29854518c48c2da3cc11a94d6d37bb1.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,\n r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url,\n u.email AS user_email\n FROM reviews r\n INNER JOIN movies m ON m.id = r.movie_id\n INNER JOIN users u ON u.id = r.user_id\n ORDER BY r.watched_at DESC\n LIMIT ? OFFSET ?", + "query": "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,\n r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url,\n COALESCE(u.email, r.remote_actor_url) AS \"user_email!: String\"\n FROM reviews r\n INNER JOIN movies m ON m.id = r.movie_id\n LEFT JOIN users u ON u.id = r.user_id\n ORDER BY r.watched_at DESC\n LIMIT ? OFFSET ?", "describe": { "columns": [ { @@ -74,7 +74,7 @@ "type_info": "Text" }, { - "name": "user_email", + "name": "user_email!: String", "ordinal": 14, "type_info": "Text" } @@ -97,8 +97,8 @@ false, false, true, - false + true ] }, - "hash": "1d62f367cd14f6fa82aff8aa289e499a56832d1c90eac2f5ba06b019c3f86541" + "hash": "70843058606802f0958d216a47473f78b29854518c48c2da3cc11a94d6d37bb1" } diff --git a/.sqlx/query-167481bb1692cc81531d9a5cd85425e43d09a6df97c335ac347f7cfd61acd171.json b/.sqlx/query-e51c6da943bd326a09632aa0cfd30c4f15ca554e229778b6cfa04889b7231b36.json similarity index 63% rename from .sqlx/query-167481bb1692cc81531d9a5cd85425e43d09a6df97c335ac347f7cfd61acd171.json rename to .sqlx/query-e51c6da943bd326a09632aa0cfd30c4f15ca554e229778b6cfa04889b7231b36.json index 80aefc3..4f4fb4b 100644 --- a/.sqlx/query-167481bb1692cc81531d9a5cd85425e43d09a6df97c335ac347f7cfd61acd171.json +++ b/.sqlx/query-e51c6da943bd326a09632aa0cfd30c4f15ca554e229778b6cfa04889b7231b36.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT id, email, password_hash FROM users WHERE email = ?", + "query": "SELECT id, email, username, password_hash FROM users WHERE id = ?", "describe": { "columns": [ { @@ -14,19 +14,25 @@ "type_info": "Text" }, { - "name": "password_hash", + "name": "username", "ordinal": 2, "type_info": "Text" + }, + { + "name": "password_hash", + "ordinal": 3, + "type_info": "Text" } ], "parameters": { "Right": 1 }, "nullable": [ + true, false, false, false ] }, - "hash": "167481bb1692cc81531d9a5cd85425e43d09a6df97c335ac347f7cfd61acd171" + "hash": "e51c6da943bd326a09632aa0cfd30c4f15ca554e229778b6cfa04889b7231b36" } diff --git a/.sqlx/query-f259059d76f29cade94e249735d37ef4993fe5bff095dc43e681b848a398f318.json b/.sqlx/query-f259059d76f29cade94e249735d37ef4993fe5bff095dc43e681b848a398f318.json deleted file mode 100644 index a6e461e..0000000 --- a/.sqlx/query-f259059d76f29cade94e249735d37ef4993fe5bff095dc43e681b848a398f318.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "db_name": "SQLite", - "query": "SELECT u.id,\n u.email,\n COUNT(DISTINCT r.movie_id) AS \"total_movies!: i64\",\n AVG(CAST(r.rating AS REAL)) AS avg_rating\n FROM users u\n LEFT JOIN reviews r ON r.user_id = u.id\n GROUP BY u.id, u.email\n ORDER BY u.email ASC", - "describe": { - "columns": [ - { - "name": "id", - "ordinal": 0, - "type_info": "Text" - }, - { - "name": "email", - "ordinal": 1, - "type_info": "Text" - }, - { - "name": "total_movies!: i64", - "ordinal": 2, - "type_info": "Integer" - }, - { - "name": "avg_rating", - "ordinal": 3, - "type_info": "Float" - } - ], - "parameters": { - "Right": 0 - }, - "nullable": [ - false, - false, - false, - true - ] - }, - "hash": "f259059d76f29cade94e249735d37ef4993fe5bff095dc43e681b848a398f318" -} diff --git a/Dockerfile b/Dockerfile index 2d56d87..221c11b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,6 +7,7 @@ WORKDIR /build # Cache dependency compilation separately from source COPY Cargo.toml Cargo.lock ./ +COPY crates/adapters/activitypub/Cargo.toml crates/adapters/activitypub/Cargo.toml COPY crates/adapters/auth/Cargo.toml crates/adapters/auth/Cargo.toml COPY crates/adapters/event-publisher/Cargo.toml crates/adapters/event-publisher/Cargo.toml COPY crates/adapters/metadata/Cargo.toml crates/adapters/metadata/Cargo.toml @@ -33,7 +34,9 @@ COPY crates ./crates RUN sqlite3 /build/dev.db \ < crates/adapters/sqlite/migrations/0001_initial.sql && \ sqlite3 /build/dev.db \ - < crates/adapters/sqlite/migrations/0002_users.sql + < crates/adapters/sqlite/migrations/0002_users.sql && \ + sqlite3 /build/dev.db \ + < crates/adapters/sqlite/migrations/0003_activitypub.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 da9ef8e..aa0a6a8 100644 --- a/crates/adapters/activitypub/src/activities.rs +++ b/crates/adapters/activitypub/src/activities.rs @@ -1,8 +1,8 @@ use activitypub_federation::{ config::Data, fetch::object_id::ObjectId, - kinds::activity::{AcceptType, CreateType, DeleteType, FollowType, RejectType, UndoType}, - traits::{Activity, Actor, Object}, + kinds::activity::{AcceptType, CreateType, DeleteType, FollowType, RejectType, UndoType, UpdateType}, + traits::{Activity, Object}, }; use serde::{Deserialize, Serialize}; use url::Url; @@ -11,7 +11,7 @@ use crate::actors::DbActor; use crate::data::FederationData; use crate::error::Error; use crate::objects::{DbReview, ReviewObject}; -use crate::repository::FollowerStatus; +use crate::repository::{FollowerStatus, FollowingStatus}; // --- Follow --- @@ -19,7 +19,7 @@ use crate::repository::FollowerStatus; #[serde(rename_all = "camelCase")] pub struct FollowActivity { pub(crate) id: Url, - #[serde(rename = "type")] + #[serde(rename = "type", default)] pub(crate) kind: FollowType, pub(crate) actor: ObjectId, pub(crate) object: ObjectId, @@ -39,56 +39,36 @@ impl Activity for FollowActivity { } async fn verify(&self, data: &Data) -> Result<(), Self::Error> { - // Verify the target is a local actor let target_url = self.object.inner(); - if target_url.domain() != Some(&data.domain) { - return Err(Error(anyhow::anyhow!( - "follow target is not a local actor" - ))); + // Url::domain() strips the port, so build host:port explicitly + let target_domain = match (target_url.host_str(), target_url.port()) { + (Some(host), Some(port)) => format!("{}:{}", host, port), + (Some(host), None) => host.to_string(), + _ => return Err(Error::bad_request(anyhow::anyhow!("invalid follow target URL"))), + }; + if target_domain != data.domain { + return Err(Error::bad_request(anyhow::anyhow!("follow target is not a local actor"))); } Ok(()) } async fn receive(self, data: &Data) -> Result<(), Self::Error> { - let follower = self.actor.dereference(data).await?; + let _follower = self.actor.dereference(data).await?; let local_actor = self.object.dereference(data).await?; data.federation_repo .add_follower( local_actor.user_id.clone(), self.actor.inner().as_str(), - FollowerStatus::Accepted, + FollowerStatus::Pending, ) .await?; - // Send Accept back - let accept_id = - Url::parse(&format!("{}/activities/{}", data.base_url, uuid::Uuid::new_v4())) - .expect("valid url"); - let accept = AcceptActivity { - id: accept_id, - kind: Default::default(), - actor: self.object.clone(), - object: self.clone(), - }; - - use activitypub_federation::activity_sending::SendActivityTask; - use activitypub_federation::protocol::context::WithContext; - - let accept_with_ctx = WithContext::new_default(accept); - let sends = - SendActivityTask::prepare(&accept_with_ctx, &local_actor, vec![follower.inbox()], data) - .await?; - for send in sends { - send.sign_and_send(data).await?; - } - tracing::info!( follower = %self.actor.inner(), local_user = %local_actor.user_id.value(), - "accepted follow" + "follow request pending approval" ); - Ok(()) } } @@ -99,7 +79,7 @@ impl Activity for FollowActivity { #[serde(rename_all = "camelCase")] pub struct AcceptActivity { pub(crate) id: Url, - #[serde(rename = "type")] + #[serde(rename = "type", default)] pub(crate) kind: AcceptType, pub(crate) actor: ObjectId, pub(crate) object: FollowActivity, @@ -122,10 +102,17 @@ impl Activity for AcceptActivity { Ok(()) } - async fn receive(self, _data: &Data) -> Result<(), Self::Error> { - let remote_actor_url = self.actor.into_inner().to_string(); - tracing::info!(remote_actor_url = %remote_actor_url, "Follow accepted by remote instance"); - // TODO(ap): update ap_following to track accepted status + async fn receive(self, data: &Data) -> Result<(), Self::Error> { + let local_user_id = crate::urls::extract_user_id_from_url(self.object.actor.inner()) + .ok_or_else(|| Error::bad_request(anyhow::anyhow!("invalid actor URL in Follow")))?; + let remote_actor_url = self.actor.inner().as_str(); + data.federation_repo + .update_following_status(local_user_id, remote_actor_url, FollowingStatus::Accepted) + .await?; + tracing::info!( + remote_actor = %self.actor.inner(), + "follow accepted by remote" + ); Ok(()) } } @@ -136,7 +123,7 @@ impl Activity for AcceptActivity { #[serde(rename_all = "camelCase")] pub struct RejectActivity { pub(crate) id: Url, - #[serde(rename = "type")] + #[serde(rename = "type", default)] pub(crate) kind: RejectType, pub(crate) actor: ObjectId, pub(crate) object: FollowActivity, @@ -162,14 +149,10 @@ impl Activity for RejectActivity { async fn receive(self, data: &Data) -> Result<(), Self::Error> { // The actor rejected our follow. Extract the local user from the original Follow's actor. let local_user_url = self.object.actor.inner(); - let path = local_user_url.path(); - if let Some(uid_str) = path.strip_prefix("/users/").and_then(|s| s.split('/').next()) { - if let Ok(uuid) = uuid::Uuid::parse_str(uid_str) { - let user_id = domain::value_objects::UserId::from_uuid(uuid); - data.federation_repo - .remove_following(user_id, self.actor.inner().as_str()) - .await?; - } + if let Some(user_id) = crate::urls::extract_user_id_from_url(local_user_url) { + data.federation_repo + .remove_following(user_id, self.actor.inner().as_str()) + .await?; } tracing::info!(actor = %self.actor.inner(), "follow rejected"); Ok(()) @@ -182,7 +165,7 @@ impl Activity for RejectActivity { #[serde(rename_all = "camelCase")] pub struct UndoActivity { pub(crate) id: Url, - #[serde(rename = "type")] + #[serde(rename = "type", default)] pub(crate) kind: UndoType, pub(crate) actor: ObjectId, pub(crate) object: FollowActivity, @@ -208,14 +191,10 @@ impl Activity for UndoActivity { async fn receive(self, data: &Data) -> Result<(), Self::Error> { // Remote actor is unfollowing a local user let local_user_url = self.object.object.inner(); - let path = local_user_url.path(); - if let Some(uid_str) = path.strip_prefix("/users/").and_then(|s| s.split('/').next()) { - if let Ok(uuid) = uuid::Uuid::parse_str(uid_str) { - let user_id = domain::value_objects::UserId::from_uuid(uuid); - data.federation_repo - .remove_follower(user_id, self.actor.inner().as_str()) - .await?; - } + if let Some(user_id) = crate::urls::extract_user_id_from_url(local_user_url) { + data.federation_repo + .remove_follower(user_id, self.actor.inner().as_str()) + .await?; } tracing::info!(actor = %self.actor.inner(), "unfollowed"); Ok(()) @@ -228,7 +207,7 @@ impl Activity for UndoActivity { #[serde(rename_all = "camelCase")] pub struct CreateActivity { pub(crate) id: Url, - #[serde(rename = "type")] + #[serde(rename = "type", default)] pub(crate) kind: CreateType, pub(crate) actor: ObjectId, pub(crate) object: ReviewObject, @@ -248,6 +227,11 @@ impl Activity for CreateActivity { } 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!( + "activity actor does not match object attributed_to" + ))); + } Ok(()) } @@ -264,7 +248,7 @@ impl Activity for CreateActivity { #[serde(rename_all = "camelCase")] pub struct DeleteActivity { pub(crate) id: Url, - #[serde(rename = "type")] + #[serde(rename = "type", default)] pub(crate) kind: DeleteType, pub(crate) actor: ObjectId, pub(crate) object: Url, @@ -287,8 +271,61 @@ impl Activity for DeleteActivity { Ok(()) } - async fn receive(self, _data: &Data) -> Result<(), Self::Error> { - tracing::info!(actor = %self.actor.inner(), object = %self.object, "delete received (no-op)"); + async fn receive(self, data: &Data) -> Result<(), Self::Error> { + data.federation_repo + .delete_remote_review_by_ap_id( + self.object.as_str(), + self.actor.inner().as_str(), + ) + .await?; + tracing::info!(object = %self.object, "remote review deleted"); + Ok(()) + } +} + +// --- Update --- + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateActivity { + pub(crate) id: Url, + #[serde(rename = "type", default)] + pub(crate) kind: UpdateType, + pub(crate) actor: ObjectId, + pub(crate) object: ReviewObject, +} + +#[async_trait::async_trait] +impl Activity for UpdateActivity { + type DataType = FederationData; + type Error = Error; + + fn id(&self) -> &Url { + &self.id + } + + fn actor(&self) -> &Url { + self.actor.inner() + } + + 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(); + data.federation_repo + .update_remote_review(ap_id, self.actor.inner().as_str(), rating, comment, watched_at) + .await?; + tracing::info!(actor = %self.actor.inner(), ap_id = %ap_id, "remote review updated"); Ok(()) } } @@ -311,4 +348,6 @@ pub enum InboxActivities { Create(CreateActivity), #[serde(rename = "Delete")] Delete(DeleteActivity), + #[serde(rename = "Update")] + Update(UpdateActivity), } diff --git a/crates/adapters/activitypub/src/actor_handler.rs b/crates/adapters/activitypub/src/actor_handler.rs index c244372..adcf3e8 100644 --- a/crates/adapters/activitypub/src/actor_handler.rs +++ b/crates/adapters/activitypub/src/actor_handler.rs @@ -14,7 +14,7 @@ pub async fn actor_handler( data: Data, ) -> Result>, Error> { let uuid = uuid::Uuid::parse_str(&user_id_str) - .map_err(|_| Error(anyhow::anyhow!("invalid user id")))?; + .map_err(|_| Error::bad_request(anyhow::anyhow!("invalid user id")))?; let user_id = UserId::from_uuid(uuid); let db_actor = get_local_actor(user_id, &data).await?; diff --git a/crates/adapters/activitypub/src/actors.rs b/crates/adapters/activitypub/src/actors.rs index 10f3fc3..3cc2f7a 100644 --- a/crates/adapters/activitypub/src/actors.rs +++ b/crates/adapters/activitypub/src/actors.rs @@ -19,7 +19,7 @@ use crate::repository::RemoteActor; #[derive(Debug, Clone)] pub struct DbActor { pub user_id: UserId, - pub email: String, + pub username: String, pub public_key_pem: String, pub private_key_pem: Option, pub inbox_url: Url, @@ -45,10 +45,6 @@ pub struct Person { name: Option, } -pub fn actor_url(base_url: &str, user_id: &UserId) -> Url { - Url::parse(&format!("{}/users/{}", base_url, user_id.value())).expect("valid actor url") -} - pub async fn get_local_actor( user_id: UserId, data: &Data, @@ -57,8 +53,8 @@ pub async fn get_local_actor( .user_repo .find_by_id(&user_id) .await - .map_err(|e| Error(e.into()))? - .ok_or_else(|| Error(anyhow::anyhow!("user not found: {}", user_id.value())))?; + .map_err(Error::from)? + .ok_or_else(|| Error::not_found(anyhow::anyhow!("user not found: {}", user_id.value())))?; let (public_key, private_key) = match data .federation_repo @@ -79,7 +75,7 @@ pub async fn get_local_actor( } }; - let ap_id = actor_url(&data.base_url, user.id()); + let ap_id = crate::urls::actor_url(&data.base_url, user.id()); let inbox_url = Url::parse(&format!("{}/inbox", &ap_id)).expect("valid inbox url"); let outbox_url = Url::parse(&format!("{}/outbox", &ap_id)).expect("valid outbox url"); let followers_url = Url::parse(&format!("{}/followers", &ap_id)).expect("valid followers url"); @@ -87,7 +83,7 @@ pub async fn get_local_actor( Ok(DbActor { user_id: user.id().clone(), - email: user.email().value().to_string(), + username: user.username().value().to_string(), public_key_pem: public_key, private_key_pem: Some(private_key), inbox_url, @@ -118,22 +114,10 @@ impl Object for DbActor { data: &Data, ) -> Result, Self::Error> { // Extract user_id from URL path: /users/{uuid} - let path = object_id.path(); - let user_id_str = path - .strip_prefix("/users/") - .and_then(|s| s.split('/').next()); - - let user_id_str = match user_id_str { - Some(s) => s, + let user_id = match crate::urls::extract_user_id_from_url(&object_id) { + Some(id) => id, None => return Ok(None), }; - - let uuid = match uuid::Uuid::parse_str(user_id_str) { - Ok(u) => u, - Err(_) => return Ok(None), - }; - - let user_id = UserId::from_uuid(uuid); let user = match data.user_repo.find_by_id(&user_id).await { Ok(Some(u)) => u, _ => return Ok(None), @@ -149,7 +133,7 @@ impl Object for DbActor { None => return Ok(None), }; - let ap_id = actor_url(&data.base_url, user.id()); + let ap_id = crate::urls::actor_url(&data.base_url, user.id()); let inbox_url = Url::parse(&format!("{}/inbox", &ap_id)).expect("valid url"); let outbox_url = Url::parse(&format!("{}/outbox", &ap_id)).expect("valid url"); let followers_url = Url::parse(&format!("{}/followers", &ap_id)).expect("valid url"); @@ -157,7 +141,7 @@ impl Object for DbActor { Ok(Some(DbActor { user_id: user.id().clone(), - email: user.email().value().to_string(), + username: user.username().value().to_string(), public_key_pem: public_key, private_key_pem: private_key, inbox_url, @@ -179,18 +163,13 @@ impl Object for DbActor { Ok(Person { kind: Default::default(), id: self.ap_id.clone().into(), - preferred_username: self - .email - .split('@') - .next() - .unwrap_or(&self.email) - .to_string(), + preferred_username: self.username.clone(), inbox: self.inbox_url.clone(), outbox: self.outbox_url.clone(), followers: self.followers_url.clone(), following: self.following_url.clone(), public_key, - name: Some(self.email.clone()), + name: Some(self.username.clone()), }) } @@ -228,9 +207,7 @@ impl Object for DbActor { Ok(DbActor { user_id, - email: json - .name - .unwrap_or_else(|| json.preferred_username.clone()), + username: json.preferred_username.clone(), public_key_pem: json.public_key.public_key_pem, private_key_pem: None, inbox_url, diff --git a/crates/adapters/activitypub/src/data.rs b/crates/adapters/activitypub/src/data.rs index c519d67..6499291 100644 --- a/crates/adapters/activitypub/src/data.rs +++ b/crates/adapters/activitypub/src/data.rs @@ -1,6 +1,6 @@ use std::sync::Arc; -use domain::ports::UserRepository; +use domain::ports::{MovieRepository, UserRepository}; use crate::repository::FederationRepository; @@ -8,6 +8,7 @@ use crate::repository::FederationRepository; pub struct FederationData { pub(crate) federation_repo: Arc, pub(crate) user_repo: Arc, + pub(crate) movie_repo: Arc, pub(crate) base_url: String, pub(crate) domain: String, } @@ -16,6 +17,7 @@ impl FederationData { pub fn new( federation_repo: Arc, user_repo: Arc, + movie_repo: Arc, base_url: String, ) -> Self { let domain = base_url @@ -28,6 +30,7 @@ impl FederationData { Self { federation_repo, user_repo, + movie_repo, base_url, domain, } diff --git a/crates/adapters/activitypub/src/error.rs b/crates/adapters/activitypub/src/error.rs index 058692c..bd60e2d 100644 --- a/crates/adapters/activitypub/src/error.rs +++ b/crates/adapters/activitypub/src/error.rs @@ -1,7 +1,20 @@ use std::fmt::{Display, Formatter}; +use axum::http::StatusCode; + #[derive(Debug)] -pub struct Error(pub(crate) anyhow::Error); +pub struct Error(pub(crate) anyhow::Error, pub(crate) StatusCode); + +impl Error { + pub fn not_found(e: impl Into) -> Self { + Self(e.into(), StatusCode::NOT_FOUND) + } + + pub fn bad_request(e: impl Into) -> Self { + Self(e.into(), StatusCode::BAD_REQUEST) + } + +} impl Display for Error { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { @@ -14,23 +27,23 @@ where T: Into, { fn from(t: T) -> Self { - Error(t.into()) + Error(t.into(), StatusCode::INTERNAL_SERVER_ERROR) } } impl axum::response::IntoResponse for Error { fn into_response(self) -> axum::response::Response { - let msg = self.0.to_string(); - let status = if msg.contains("not found") { - tracing::debug!(error = %msg, "AP: not found"); - (axum::http::StatusCode::NOT_FOUND, "Not found") - } else if msg.contains("invalid") || msg.contains("bad") { - tracing::debug!(error = %msg, "AP: bad request"); - (axum::http::StatusCode::BAD_REQUEST, "Bad request") + let status = self.1; + if status.is_server_error() { + tracing::error!(error = %self.0, status = status.as_u16(), "federation error"); } else { - tracing::error!(error = %msg, "AP: internal error"); - (axum::http::StatusCode::INTERNAL_SERVER_ERROR, "Internal server error") + tracing::debug!(error = %self.0, status = status.as_u16(), "federation response"); + } + let body = if status.is_server_error() { + "internal server error".to_string() + } else { + self.0.to_string() }; - status.into_response() + (status, body).into_response() } } diff --git a/crates/adapters/activitypub/src/event_handler.rs b/crates/adapters/activitypub/src/event_handler.rs index 13f7109..68e5a5f 100644 --- a/crates/adapters/activitypub/src/event_handler.rs +++ b/crates/adapters/activitypub/src/event_handler.rs @@ -2,9 +2,9 @@ use activitypub_federation::{ activity_sending::SendActivityTask, fetch::object_id::ObjectId, protocol::context::WithContext, + traits::Object, }; use async_trait::async_trait; -use chrono::{DateTime, Utc}; use domain::{ errors::DomainError, events::DomainEvent, @@ -15,9 +15,9 @@ use url::Url; use crate::{ activities::CreateActivity, - actors::{actor_url, get_local_actor}, + actors::get_local_actor, federation::ApFederationConfig, - objects::{review_url, ReviewObject}, + objects::DbReview, repository::FollowerStatus, }; @@ -42,11 +42,9 @@ impl EventHandler for ActivityPubEventHandler { DomainEvent::ReviewLogged { review_id, user_id, - rating, - watched_at, .. } => self - .on_review_logged(user_id, review_id, rating.value(), *watched_at) + .on_review_logged(user_id, review_id) .await .map_err(|e| DomainError::InfrastructureError(e.to_string())), _ => Ok(()), @@ -59,47 +57,42 @@ impl ActivityPubEventHandler { &self, user_id: &UserId, review_id: &ReviewId, - rating: u8, - watched_at: chrono::NaiveDateTime, ) -> anyhow::Result<()> { let data = self.federation_config.to_request_data(); let followers = data.federation_repo.get_followers(user_id.clone()).await?; + tracing::debug!(user_id = %user_id.value(), count = followers.len(), "AP: got followers for review"); + let accepted: Vec<_> = followers .into_iter() .filter(|f| f.status == FollowerStatus::Accepted) .collect(); + tracing::debug!(accepted = accepted.len(), "AP: accepted followers"); + if accepted.is_empty() { return Ok(()); } + let review = match data.movie_repo.get_review_by_id(review_id).await? { + Some(r) => r, + None => return Ok(()), + }; + let local_actor = get_local_actor(user_id.clone(), &data) .await .map_err(|e| anyhow::anyhow!("{e}"))?; - let review_id_url = review_url(&self.base_url, review_id); - let actor_id = actor_url(&self.base_url, user_id); - let activity_id = Url::parse(&format!( - "{}/activities/{}", - self.base_url, - uuid::Uuid::new_v4() - ))?; + let activity_id = crate::urls::activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?; - let stars = "\u{2B50}".repeat(rating as usize); - let now = DateTime::from_naive_utc_and_offset(watched_at, Utc); - - let object = ReviewObject { - kind: "Review".to_string(), - id: review_id_url.into(), - attributed_to: actor_id.into(), - content: format!("{} (movie review)", stars), - published: Utc::now(), - movie_title: "Unknown".to_string(), // TODO: fetch from MovieRepository - rating, - comment: None, - watched_at: now, + let db_review = DbReview { + ap_id: crate::urls::review_url(&self.base_url, review_id), + review, }; + let object = db_review + .into_json(&data) + .await + .map_err(|e| anyhow::anyhow!("{e}"))?; let create = CreateActivity { id: activity_id, @@ -111,15 +104,23 @@ impl ActivityPubEventHandler { let inboxes: Vec = accepted .iter() - .filter_map(|f| Url::parse(&f.actor.inbox_url).ok()) + .filter_map(|f| { + let url = Url::parse(&f.actor.inbox_url); + if url.is_err() { + tracing::warn!(inbox = %f.actor.inbox_url, "AP: invalid inbox URL, skipping follower"); + } + url.ok() + }) .collect(); + tracing::debug!(inboxes = inboxes.len(), "AP: delivering to inboxes"); + let sends = SendActivityTask::prepare(&create_with_ctx, &local_actor, inboxes, &data).await?; - for send in sends { - if let Err(e) = send.sign_and_send(&data).await { - tracing::warn!(error = %e, "failed to deliver activity to follower"); - } + tracing::debug!(sends = sends.len(), "AP: prepared sends"); + let failures = crate::service::send_with_retry(sends, &data).await; + if !failures.is_empty() { + tracing::warn!(count = failures.len(), "some activity deliveries failed permanently"); } Ok(()) diff --git a/crates/adapters/activitypub/src/federation.rs b/crates/adapters/activitypub/src/federation.rs index 2e6635e..4142390 100644 --- a/crates/adapters/activitypub/src/federation.rs +++ b/crates/adapters/activitypub/src/federation.rs @@ -1,18 +1,42 @@ -use activitypub_federation::config::{Data, FederationConfig, FederationMiddleware}; +use activitypub_federation::config::{Data, FederationConfig, FederationMiddleware, UrlVerifier}; +use activitypub_federation::error::Error as FedError; +use url::Url; use crate::data::FederationData; +// In debug mode, allow all URLs (including http://localhost:3000 where the +// port colon would otherwise fail the default domain character check). +#[derive(Clone)] +struct PermissiveVerifier; + +#[async_trait::async_trait] +impl UrlVerifier for PermissiveVerifier { + async fn verify(&self, _url: &Url) -> Result<(), FedError> { + Ok(()) + } +} + #[derive(Clone)] pub struct ApFederationConfig(pub FederationConfig); impl ApFederationConfig { pub async fn new(data: FederationData, debug: bool) -> anyhow::Result { - let config = FederationConfig::builder() - .domain(&data.domain) - .app_data(data) - .debug(debug) - .build() - .await?; + let config = if debug { + FederationConfig::builder() + .domain(&data.domain) + .app_data(data) + .debug(true) + .url_verifier(Box::new(PermissiveVerifier)) + .build() + .await? + } else { + FederationConfig::builder() + .domain(&data.domain) + .app_data(data) + .debug(false) + .build() + .await? + }; Ok(Self(config)) } diff --git a/crates/adapters/activitypub/src/followers_handler.rs b/crates/adapters/activitypub/src/followers_handler.rs index 8def073..955bd82 100644 --- a/crates/adapters/activitypub/src/followers_handler.rs +++ b/crates/adapters/activitypub/src/followers_handler.rs @@ -6,6 +6,17 @@ use domain::value_objects::UserId; use crate::data::FederationData; use crate::error::Error; +use crate::repository::FollowerStatus; + +fn ordered_collection(id: String, total: usize, items: Vec) -> serde_json::Value { + json!({ + "@context": "https://www.w3.org/ns/activitystreams", + "type": "OrderedCollection", + "id": id, + "totalItems": total, + "orderedItems": items, + }) +} pub async fn followers_handler( Path(user_id_str): Path, @@ -13,25 +24,29 @@ pub async fn followers_handler( ) -> Result, Error> { let user_id = UserId::from_uuid( uuid::Uuid::parse_str(&user_id_str) - .map_err(|_| Error(anyhow::anyhow!("invalid user id")))?, + .map_err(|_| Error::bad_request(anyhow::anyhow!("invalid user id")))?, ); - // verify user exists data.user_repo .find_by_id(&user_id) .await - .map_err(|e| Error(e.into()))? - .ok_or_else(|| Error(anyhow::anyhow!("user not found")))?; + .map_err(Error::from)? + .ok_or_else(|| Error::not_found(anyhow::anyhow!("user not found")))?; + + let followers = data + .federation_repo + .get_followers(user_id) + .await + .map_err(Error::from)?; + + let items: Vec = followers + .into_iter() + .filter(|f| f.status == FollowerStatus::Accepted) + .map(|f| f.actor.url) + .collect(); let id = format!("{}/users/{}/followers", data.base_url, user_id_str); - // TODO(ap): implement pagination - Ok(FederationJson(json!({ - "@context": "https://www.w3.org/ns/activitystreams", - "type": "OrderedCollection", - "id": id, - "totalItems": 0, - "orderedItems": [] - }))) + Ok(FederationJson(ordered_collection(id, items.len(), items))) } pub async fn following_handler( @@ -40,23 +55,26 @@ pub async fn following_handler( ) -> Result, Error> { let user_id = UserId::from_uuid( uuid::Uuid::parse_str(&user_id_str) - .map_err(|_| Error(anyhow::anyhow!("invalid user id")))?, + .map_err(|_| Error::bad_request(anyhow::anyhow!("invalid user id")))?, ); - // verify user exists data.user_repo .find_by_id(&user_id) .await - .map_err(|e| Error(e.into()))? - .ok_or_else(|| Error(anyhow::anyhow!("user not found")))?; + .map_err(Error::from)? + .ok_or_else(|| Error::not_found(anyhow::anyhow!("user not found")))?; + + let following = data + .federation_repo + .get_following(user_id) + .await + .map_err(Error::from)?; + + let items: Vec = following + .into_iter() + .map(|a| a.url) + .collect(); let id = format!("{}/users/{}/following", data.base_url, user_id_str); - // TODO(ap): implement pagination - Ok(FederationJson(json!({ - "@context": "https://www.w3.org/ns/activitystreams", - "type": "OrderedCollection", - "id": id, - "totalItems": 0, - "orderedItems": [] - }))) + Ok(FederationJson(ordered_collection(id, items.len(), items))) } diff --git a/crates/adapters/activitypub/src/lib.rs b/crates/adapters/activitypub/src/lib.rs index 842b225..c678093 100644 --- a/crates/adapters/activitypub/src/lib.rs +++ b/crates/adapters/activitypub/src/lib.rs @@ -11,6 +11,7 @@ pub mod objects; pub mod outbox; pub mod repository; pub mod service; +pub(crate) mod urls; pub mod webfinger; pub use data::FederationData; diff --git a/crates/adapters/activitypub/src/objects.rs b/crates/adapters/activitypub/src/objects.rs index 3f5bd3c..77089e4 100644 --- a/crates/adapters/activitypub/src/objects.rs +++ b/crates/adapters/activitypub/src/objects.rs @@ -1,6 +1,7 @@ use activitypub_federation::{ config::Data, fetch::object_id::ObjectId, + kinds::object::NoteType, protocol::verification::verify_domains_match, traits::Object, }; @@ -19,12 +20,16 @@ use crate::error::Error; #[serde(rename_all = "camelCase")] pub struct ReviewObject { #[serde(rename = "type")] - pub(crate) kind: String, + pub(crate) kind: NoteType, pub(crate) id: ObjectId, pub(crate) attributed_to: ObjectId, pub(crate) content: String, pub(crate) published: DateTime, pub(crate) movie_title: String, + #[serde(default)] + pub(crate) release_year: u16, // 0 = unknown; default for old AP messages + #[serde(default)] + pub(crate) poster_url: Option, pub(crate) rating: u8, pub(crate) comment: Option, pub(crate) watched_at: DateTime, @@ -36,10 +41,6 @@ pub struct DbReview { pub ap_id: Url, } -pub fn review_url(base_url: &str, review_id: &ReviewId) -> Url { - Url::parse(&format!("{}/reviews/{}", base_url, review_id.value())).expect("valid review url") -} - #[async_trait::async_trait] impl Object for DbReview { type DataType = FederationData; @@ -60,26 +61,39 @@ impl Object for DbReview { async fn into_json(self, data: &Data) -> Result { let r = &self.review; - let ap_id = review_url(&data.base_url, r.id()); - let actor_url = crate::actors::actor_url(&data.base_url, r.user_id()); + let ap_id = crate::urls::review_url(&data.base_url, r.id()); + let actor_url = crate::urls::actor_url(&data.base_url, r.user_id()); + + let movie = data.movie_repo.get_movie_by_id(r.movie_id()).await + .ok().flatten(); + let movie_title = movie.as_ref() + .map(|m| m.title().value().to_string()) + .unwrap_or_else(|| "Unknown".to_string()); + let release_year = movie.as_ref() + .map(|m| m.release_year().value()) + .unwrap_or(0); + let poster_url = movie.as_ref() + .and_then(|m| m.poster_path()) + .map(|p| format!("{}/posters/{}", data.base_url, p.value())); let stars: String = "\u{2B50}".repeat(r.rating().value() as usize); let comment_text = r.comment().map(|c| c.value().to_string()); - // TODO(ap): fetch movie title from MovieRepository via FederationData - let movie_title = "Unknown".to_string(); - - let fallback = match &comment_text { - Some(c) => format!("{} Watched '{}': {}", stars, movie_title, c), - None => format!("{} Watched '{}'", stars, movie_title), + let year_str = if release_year > 0 { format!(" ({})", release_year) } else { String::new() }; + let watched_str = format!("Watched: {}", r.watched_at().format("%b %-d, %Y")); + let content = match &comment_text { + Some(c) => format!("{} {}{}\n{}\n{}", stars, movie_title, year_str, c, watched_str), + None => format!("{} {}{}\n{}", stars, movie_title, year_str, watched_str), }; Ok(ReviewObject { - kind: "Review".to_string(), + kind: NoteType::default(), id: ap_id.into(), attributed_to: actor_url.into(), - content: fallback, + content, published: DateTime::from_naive_utc_and_offset(*r.created_at(), Utc), movie_title, + release_year, + poster_url, rating: r.rating().value(), comment: comment_text, watched_at: DateTime::from_naive_utc_and_offset(*r.watched_at(), Utc), @@ -102,19 +116,17 @@ impl Object for DbReview { let actor_url = json.attributed_to.inner().to_string(); let review_id = ReviewId::generate(); - // TODO(ap): create stub movie/user entries in DB so feed JOIN queries work. - // For now, use deterministic UUIDs from content hash; reviews will be orphaned in JOINs. let movie_id_uuid = uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_URL, json.movie_title.as_bytes()); let movie_id = domain::value_objects::MovieId::from_uuid(movie_id_uuid); let user_id_uuid = uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_URL, actor_url.as_bytes()); let user_id = domain::value_objects::UserId::from_uuid(user_id_uuid); let rating = domain::value_objects::Rating::new(json.rating.min(5)) - .map_err(|e| Error(anyhow::anyhow!("{}", e)))?; + .map_err(|e| Error::bad_request(anyhow::anyhow!("{}", e)))?; let comment = json .comment .map(|c| domain::value_objects::Comment::new(c)) .transpose() - .map_err(|e| Error(anyhow::anyhow!("{}", e)))?; + .map_err(|e| Error::bad_request(anyhow::anyhow!("{}", e)))?; let watched_at = json.watched_at.naive_utc(); let created_at = json.published.naive_utc(); @@ -129,9 +141,9 @@ impl Object for DbReview { ReviewSource::Remote { actor_url }, ); - let ap_id = review_url(&data.base_url, review.id()); - data.federation_repo.save_remote_review(&review).await?; + let ap_id_url = json.id.into_inner(); + data.federation_repo.save_remote_review(&review, ap_id_url.as_str(), &json.movie_title, json.release_year, json.poster_url.as_deref()).await?; - Ok(DbReview { review, ap_id }) + Ok(DbReview { review, ap_id: ap_id_url }) } } diff --git a/crates/adapters/activitypub/src/outbox.rs b/crates/adapters/activitypub/src/outbox.rs index 703c9e9..4dc47ca 100644 --- a/crates/adapters/activitypub/src/outbox.rs +++ b/crates/adapters/activitypub/src/outbox.rs @@ -2,7 +2,7 @@ use activitypub_federation::{axum::json::FederationJson, config::Data}; use axum::extract::Path; use serde::{Deserialize, Serialize}; -use domain::value_objects::UserId; +use domain::{models::ReviewSource, value_objects::UserId}; use crate::data::FederationData; use crate::error::Error; @@ -24,15 +24,25 @@ pub async fn outbox_handler( data: Data, ) -> Result, Error> { let uuid = uuid::Uuid::parse_str(&user_id_str) - .map_err(|_| Error(anyhow::anyhow!("invalid user id")))?; + .map_err(|_| Error::bad_request(anyhow::anyhow!("invalid user id")))?; let user_id = UserId::from_uuid(uuid); - // verify user exists data.user_repo .find_by_id(&user_id) .await - .map_err(|e| Error(e.into()))? - .ok_or_else(|| Error(anyhow::anyhow!("user not found")))?; + .map_err(Error::from)? + .ok_or_else(|| Error::not_found(anyhow::anyhow!("user not found")))?; + + let history = data + .movie_repo + .get_user_history(&user_id) + .await + .map_err(Error::from)?; + + let local_count = history + .iter() + .filter(|e| matches!(e.review().source(), ReviewSource::Local)) + .count(); let outbox_url = format!("{}/users/{}/outbox", data.base_url, user_id_str); @@ -40,7 +50,7 @@ pub async fn outbox_handler( context: "https://www.w3.org/ns/activitystreams".to_string(), kind: "OrderedCollection".to_string(), id: outbox_url, - total_items: 0, + total_items: local_count as u64, ordered_items: vec![], })) } diff --git a/crates/adapters/activitypub/src/repository.rs b/crates/adapters/activitypub/src/repository.rs index e686312..b674ddf 100644 --- a/crates/adapters/activitypub/src/repository.rs +++ b/crates/adapters/activitypub/src/repository.rs @@ -1,5 +1,6 @@ use anyhow::Result; use async_trait::async_trait; +use chrono::NaiveDateTime; use domain::models::Review; use domain::value_objects::UserId; @@ -10,6 +11,12 @@ pub enum FollowerStatus { Rejected, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FollowingStatus { + Pending, + Accepted, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct RemoteActor { pub url: String, @@ -31,13 +38,38 @@ pub trait FederationRepository: Send + Sync { 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<()>; - async fn add_following(&self, local_user_id: UserId, actor: RemoteActor) -> Result<()>; + async fn add_following(&self, local_user_id: UserId, actor: RemoteActor, follow_activity_id: &str) -> Result<()>; + async fn get_follow_activity_id(&self, local_user_id: UserId, remote_actor_url: &str) -> Result>; async fn remove_following(&self, local_user_id: UserId, actor_url: &str) -> Result<()>; async fn get_following(&self, local_user_id: UserId) -> Result>; async fn count_following(&self, local_user_id: UserId) -> Result; async fn upsert_remote_actor(&self, actor: RemoteActor) -> Result<()>; async fn get_remote_actor(&self, actor_url: &str) -> Result>; - async fn save_remote_review(&self, review: &Review) -> Result<()>; + async fn save_remote_review( + &self, + review: &Review, + ap_id: &str, + movie_title: &str, + release_year: u16, + poster_url: Option<&str>, + ) -> Result<()>; + async fn delete_remote_review_by_ap_id(&self, ap_id: &str, actor_url: &str) -> Result<()>; + async fn update_remote_review( + &self, + ap_id: &str, + actor_url: &str, + rating: u8, + comment: Option<&str>, + watched_at: NaiveDateTime, + ) -> Result<()>; async fn get_local_actor_keypair(&self, user_id: UserId) -> Result>; async fn save_local_actor_keypair(&self, user_id: UserId, public_key: String, private_key: String) -> Result<()>; + async fn delete_remote_reviews_by_actor(&self, actor_url: &str) -> Result<()>; + async fn get_pending_followers(&self, local_user_id: UserId) -> Result>; + async fn update_following_status( + &self, + local_user_id: UserId, + remote_actor_url: &str, + status: FollowingStatus, + ) -> Result<()>; } diff --git a/crates/adapters/activitypub/src/service.rs b/crates/adapters/activitypub/src/service.rs index d95af79..d668374 100644 --- a/crates/adapters/activitypub/src/service.rs +++ b/crates/adapters/activitypub/src/service.rs @@ -12,7 +12,7 @@ use domain::{ports::UserRepository, value_objects::UserId}; use url::Url; use crate::{ - activities::{FollowActivity, UndoActivity}, + activities::{AcceptActivity, CreateActivity, FollowActivity, RejectActivity, UndoActivity}, actor_handler::actor_handler, actors::{get_local_actor, DbActor}, data::FederationData, @@ -21,10 +21,37 @@ use crate::{ followers_handler::{followers_handler, following_handler}, inbox::inbox_handler, outbox::outbox_handler, - repository::{FederationRepository, RemoteActor}, + repository::{FederationRepository, FollowerStatus, RemoteActor}, webfinger::webfinger_handler, }; +pub(crate) async fn send_with_retry( + sends: Vec, + data: &Data, +) -> Vec { + let mut failures = vec![]; + for send in sends { + let mut delay = std::time::Duration::from_secs(1); + for attempt in 1..=3u32 { + match send.clone().sign_and_send(data).await { + Ok(()) => { + break; + } + Err(e) if attempt < 3 => { + tracing::warn!(attempt, error = %e, "delivery failed, retrying"); + tokio::time::sleep(delay).await; + delay *= 2; + } + Err(e) => { + tracing::error!(attempt, error = %e, "delivery failed permanently"); + failures.push(anyhow::anyhow!(e)); + } + } + } + } + failures +} + pub struct ActivityPubService { federation_config: ApFederationConfig, base_url: String, @@ -34,10 +61,11 @@ impl ActivityPubService { pub async fn new( repo: Arc, user_repo: Arc, + movie_repo: Arc, base_url: String, debug: bool, ) -> anyhow::Result { - let data = FederationData::new(repo, user_repo, base_url.clone()); + let data = FederationData::new(repo, user_repo, movie_repo, base_url.clone()); let federation_config = ApFederationConfig::new(data, debug).await?; Ok(Self { federation_config, @@ -53,6 +81,22 @@ impl ActivityPubService { self.federation_config.to_request_data() } + // Returns the AP actor document JSON for a local user. + // Used for content negotiation in the HTML profile handler. + pub async fn actor_json(&self, user_id_str: &str) -> anyhow::Result { + use activitypub_federation::traits::Object; + use crate::actors::get_local_actor; + let uuid = uuid::Uuid::parse_str(user_id_str)?; + let user_id = UserId::from_uuid(uuid); + let data = self.federation_config.to_request_data(); + let actor = get_local_actor(user_id, &data).await + .map_err(|e| anyhow::anyhow!("{e}"))?; + let person = actor.into_json(&data).await + .map_err(|e| anyhow::anyhow!("{e}"))?; + let with_context = WithContext::new_default(person); + Ok(serde_json::to_string(&with_context)?) + } + pub fn router(&self) -> Router { Router::new() .route("/.well-known/webfinger", get(webfinger_handler)) @@ -79,11 +123,8 @@ impl ActivityPubService { .await .map_err(|e| anyhow::anyhow!("{e}"))?; - let follow_id = Url::parse(&format!( - "{}/activities/{}", - self.base_url, - uuid::Uuid::new_v4() - ))?; + let follow_id = crate::urls::activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?; + let follow_id_str = follow_id.to_string(); let follow = FollowActivity { id: follow_id, kind: Default::default(), @@ -99,24 +140,20 @@ impl ActivityPubService { &data, ) .await?; - for send in sends { - send.sign_and_send(&data).await?; + let failures = send_with_retry(sends, &data).await; + if !failures.is_empty() { + tracing::warn!(count = failures.len(), "some activity deliveries failed permanently"); } let remote = RemoteActor { url: remote_actor.ap_id.to_string(), - handle: remote_actor - .email - .split('@') - .next() - .unwrap_or(&remote_actor.email) - .to_string(), + handle: remote_actor.username.clone(), inbox_url: remote_actor.inbox_url.to_string(), shared_inbox_url: None, - display_name: Some(remote_actor.email.clone()), + display_name: Some(remote_actor.username.clone()), }; data.federation_repo - .add_following(local_user_id, remote) + .add_following(local_user_id, remote, &follow_id_str) .await?; Ok(()) @@ -138,11 +175,14 @@ impl ActivityPubService { let remote_ap_id = Url::parse(actor_url_str)?; let inbox = Url::parse(&remote.inbox_url)?; - let follow_id = Url::parse(&format!( - "{}/activities/{}", - self.base_url, - uuid::Uuid::new_v4() - ))?; + let follow_activity_id_str = data + .federation_repo + .get_follow_activity_id(local_user_id.clone(), actor_url_str) + .await?; + let follow_id = match follow_activity_id_str { + Some(id) => Url::parse(&id)?, + None => crate::urls::activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?, + }; let follow = FollowActivity { id: follow_id, kind: Default::default(), @@ -150,11 +190,7 @@ impl ActivityPubService { object: ObjectId::from(remote_ap_id), }; - let undo_id = Url::parse(&format!( - "{}/activities/{}", - self.base_url, - uuid::Uuid::new_v4() - ))?; + let undo_id = crate::urls::activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?; let undo = UndoActivity { id: undo_id, kind: Default::default(), @@ -165,14 +201,20 @@ impl ActivityPubService { let sends = SendActivityTask::prepare(&undo_with_ctx, &local_actor, vec![inbox], &data).await?; - for send in sends { - send.sign_and_send(&data).await?; + let failures = send_with_retry(sends, &data).await; + if !failures.is_empty() { + tracing::warn!(count = failures.len(), "some activity deliveries failed permanently"); } data.federation_repo .remove_following(local_user_id, actor_url_str) .await?; + data.federation_repo + .delete_remote_reviews_by_actor(actor_url_str) + .await + .map_err(|e| anyhow::anyhow!("{e}"))?; + Ok(()) } @@ -185,4 +227,220 @@ impl ActivityPubService { let data = self.federation_config.to_request_data(); data.federation_repo.count_following(local_user_id).await } + + pub async fn accept_follower( + &self, + local_user_id: UserId, + remote_actor_url: &str, + ) -> anyhow::Result<()> { + let data = self.federation_config.to_request_data(); + let local_actor = get_local_actor(local_user_id.clone(), &data) + .await + .map_err(|e| anyhow::anyhow!("{e}"))?; + + let remote_actor = data + .federation_repo + .get_remote_actor(remote_actor_url) + .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 = FollowActivity { + id: follow_id, + kind: Default::default(), + actor: ObjectId::from(Url::parse(remote_actor_url)?), + object: ObjectId::from(local_actor.ap_id.clone()), + }; + let accept = AcceptActivity { + id: crate::urls::activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?, + kind: Default::default(), + actor: ObjectId::from(local_actor.ap_id.clone()), + object: follow, + }; + + // Update status first so local state is correct even if delivery fails + data.federation_repo + .update_follower_status(local_user_id.clone(), remote_actor_url, FollowerStatus::Accepted) + .await?; + + let inbox = Url::parse(&remote_actor.inbox_url)?; + let sends = SendActivityTask::prepare( + &WithContext::new_default(accept), + &local_actor, + vec![inbox.clone()], + &data, + ) + .await?; + let failures = send_with_retry(sends, &data).await; + if !failures.is_empty() { + tracing::warn!("failed to deliver Accept activity, but follower is marked accepted locally"); + } + + self.spawn_backfill(local_user_id, remote_actor.inbox_url.clone()); + + Ok(()) + } + + pub async fn reject_follower( + &self, + local_user_id: UserId, + remote_actor_url: &str, + ) -> anyhow::Result<()> { + let data = self.federation_config.to_request_data(); + let local_actor = get_local_actor(local_user_id.clone(), &data) + .await + .map_err(|e| anyhow::anyhow!("{e}"))?; + + let remote_actor = data + .federation_repo + .get_remote_actor(remote_actor_url) + .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 = FollowActivity { + id: follow_id, + kind: Default::default(), + actor: ObjectId::from(Url::parse(remote_actor_url)?), + object: ObjectId::from(local_actor.ap_id.clone()), + }; + let reject = RejectActivity { + id: crate::urls::activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?, + kind: Default::default(), + actor: ObjectId::from(local_actor.ap_id.clone()), + object: follow, + }; + + let inbox = Url::parse(&remote_actor.inbox_url)?; + let sends = SendActivityTask::prepare( + &WithContext::new_default(reject), + &local_actor, + vec![inbox], + &data, + ) + .await?; + let failures = send_with_retry(sends, &data).await; + if !failures.is_empty() { + tracing::warn!(count = failures.len(), "some activity deliveries failed permanently"); + } + + data.federation_repo + .remove_follower(local_user_id, remote_actor_url) + .await?; + + Ok(()) + } + + pub async fn get_pending_followers( + &self, + local_user_id: UserId, + ) -> anyhow::Result> { + let data = self.federation_config.to_request_data(); + data.federation_repo + .get_pending_followers(local_user_id) + .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(); + + tokio::spawn(async move { + if let Err(e) = ActivityPubService::run_backfill( + config, base_url, owner_user_id, follower_inbox_url, + ).await { + tracing::warn!(error = %e, "backfill: task failed"); + } + }); + } + + async fn run_backfill( + config: ApFederationConfig, + base_url: String, + owner_user_id: UserId, + follower_inbox_url: String, + ) -> anyhow::Result<()> { + const BATCH_SIZE: usize = 20; + + let data = config.to_request_data(); + let local_actor = get_local_actor(owner_user_id.clone(), &data) + .await + .map_err(|e| anyhow::anyhow!("{e}"))?; + let inbox = Url::parse(&follower_inbox_url)?; + + let history = data.movie_repo.get_user_history(&owner_user_id).await?; + let local_reviews: Vec<_> = history + .into_iter() + .filter(|e| matches!(e.review().source(), domain::models::ReviewSource::Local)) + .collect(); + + let total = local_reviews.len(); + + let mut success_count = 0usize; + let mut failure_count = 0usize; + + for chunk in local_reviews.chunks(BATCH_SIZE) { + for entry in chunk { + match ActivityPubService::deliver_review_to_inbox( + entry.review().clone(), + &local_actor, + inbox.clone(), + &data, + &base_url, + ) + .await + { + Ok(_) => success_count += 1, + Err(_) => failure_count += 1, + } + } + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } + + tracing::info!( + user_id = %owner_user_id.value(), + follower = %follower_inbox_url, + sent = success_count, + failed = failure_count, + total = total, + "backfill complete" + ); + Ok(()) + } + + async fn deliver_review_to_inbox( + review: domain::models::Review, + local_actor: &DbActor, + inbox: Url, + data: &Data, + base_url: &str, + ) -> anyhow::Result<()> { + use activitypub_federation::traits::Object; + use crate::objects::DbReview; + + 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 create = CreateActivity { + id: activity_id, + kind: Default::default(), + actor: ObjectId::from(local_actor.ap_id.clone()), + object, + }; + + let sends = SendActivityTask::prepare( + &WithContext::new_default(create), + local_actor, + vec![inbox], + data, + ).await?; + let failures = send_with_retry(sends, data).await; + if let Some(e) = failures.into_iter().next() { + return Err(e); + } + Ok(()) + } } diff --git a/crates/adapters/activitypub/src/urls.rs b/crates/adapters/activitypub/src/urls.rs new file mode 100644 index 0000000..d8ad31d --- /dev/null +++ b/crates/adapters/activitypub/src/urls.rs @@ -0,0 +1,31 @@ +use url::Url; +use uuid::Uuid; +use domain::value_objects::{UserId, ReviewId}; +use crate::error::Error; + +/// Extracts a UserId from a URL like `https://example.com/users/{uuid}[/...]` +pub fn extract_user_id_from_url(url: &Url) -> Option { + let path = url.path(); + path.strip_prefix("/users/") + .and_then(|s| s.split('/').next()) + .and_then(|uid_str| Uuid::parse_str(uid_str).ok()) + .map(UserId::from_uuid) +} + +/// Generates a fresh activity URL: `{base_url}/activities/{uuid}` +pub fn activity_url(base_url: &str) -> Result { + Url::parse(&format!("{}/activities/{}", base_url, Uuid::new_v4())) + .map_err(|e| Error::bad_request(anyhow::anyhow!(e))) +} + +/// Builds the canonical actor URL: `{base_url}/users/{user_id}` +pub fn actor_url(base_url: &str, user_id: &UserId) -> Url { + Url::parse(&format!("{}/users/{}", base_url, user_id.value())) + .expect("base_url is always a valid URL prefix") +} + +/// Builds the canonical review URL: `{base_url}/reviews/{review_id}` +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") +} diff --git a/crates/adapters/activitypub/src/webfinger.rs b/crates/adapters/activitypub/src/webfinger.rs index 1ce056b..2c013c7 100644 --- a/crates/adapters/activitypub/src/webfinger.rs +++ b/crates/adapters/activitypub/src/webfinger.rs @@ -9,7 +9,8 @@ use axum::{ }; use serde::Deserialize; -use crate::actors::actor_url; +use domain::value_objects::Username; + use crate::data::FederationData; use crate::error::Error; @@ -24,19 +25,16 @@ pub async fn webfinger_handler( ) -> Result { let name = extract_webfinger_name(&query.resource, &data)?; - // Look up user by email username@domain - let email_str = format!("{}@{}", name, data.domain); - let email = domain::value_objects::Email::new(email_str) - .map_err(|e| Error(anyhow::anyhow!("{}", e)))?; - + let username = Username::new(name.to_string()) + .map_err(|e| Error::bad_request(anyhow::anyhow!(e.to_string())))?; let user = data .user_repo - .find_by_email(&email) + .find_by_username(&username) .await - .map_err(|e| Error(e.into()))? - .ok_or_else(|| Error(anyhow::anyhow!("user not found")))?; + .map_err(Error::from)? + .ok_or_else(|| Error::not_found(anyhow::anyhow!("user not found")))?; - let ap_id = actor_url(&data.base_url, user.id()); + let ap_id = crate::urls::actor_url(&data.base_url, user.id()); let wf: Webfinger = build_webfinger_response(query.resource, ap_id); let body = serde_json::to_string(&wf) diff --git a/crates/adapters/event-publisher/src/lib.rs b/crates/adapters/event-publisher/src/lib.rs index dc4ecb9..29b61dd 100644 --- a/crates/adapters/event-publisher/src/lib.rs +++ b/crates/adapters/event-publisher/src/lib.rs @@ -60,6 +60,22 @@ impl EventWorker { "event: review_logged" ); } + DomainEvent::ReviewUpdated { + review_id, + movie_id, + user_id, + rating, + watched_at, + } => { + tracing::info!( + review_id = %review_id.value(), + movie_id = %movie_id.value(), + user_id = %user_id.value(), + rating = rating.value(), + watched_at = %watched_at, + "event: review_updated" + ); + } DomainEvent::MovieDiscovered { movie_id, external_metadata_id, @@ -122,8 +138,9 @@ mod tests { impl EventHandler for RecordingHandler { async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError> { let label = match event { - DomainEvent::MovieDiscovered { .. } => "movie_discovered", DomainEvent::ReviewLogged { .. } => "review_logged", + DomainEvent::ReviewUpdated { .. } => "review_updated", + DomainEvent::MovieDiscovered { .. } => "movie_discovered", }; self.calls.lock().unwrap().push(label.to_string()); Ok(()) diff --git a/crates/adapters/metadata/src/omdb.rs b/crates/adapters/metadata/src/omdb.rs index 87de19d..74f530a 100644 --- a/crates/adapters/metadata/src/omdb.rs +++ b/crates/adapters/metadata/src/omdb.rs @@ -56,9 +56,9 @@ impl MetadataProvider for OmdbProvider { params.append_pair("i", id.value()); } MetadataSearchCriteria::Title { title, year } => { - params.append_pair("t", title); + params.append_pair("t", title.value()); if let Some(y) = year { - params.append_pair("y", &y.to_string()); + params.append_pair("y", &y.value().to_string()); } } } diff --git a/crates/adapters/sqlite/migrations/0004_username.sql b/crates/adapters/sqlite/migrations/0004_username.sql new file mode 100644 index 0000000..33f4344 --- /dev/null +++ b/crates/adapters/sqlite/migrations/0004_username.sql @@ -0,0 +1,33 @@ +-- Recreate users table with username column +CREATE TABLE users_new ( + id TEXT PRIMARY KEY, + email TEXT NOT NULL UNIQUE, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + created_at TEXT NOT NULL +); + +-- Derive username from email local part, sanitising common invalid chars. +-- REPLACE chains handle the most common email chars. The NOT NULL UNIQUE +-- constraint will surface any remaining collision (rare for personal instances). +INSERT INTO users_new (id, email, username, password_hash, created_at) +SELECT + id, + email, + CASE + WHEN LENGTH(REPLACE(REPLACE(REPLACE(REPLACE( + LOWER(SUBSTR(email, 1, INSTR(email, '@') - 1)), + '.', '_'), '+', '_'), '-', '-'), ' ', '_')) < 2 + THEN REPLACE(REPLACE(REPLACE(REPLACE( + LOWER(SUBSTR(email, 1, INSTR(email, '@') - 1)), + '.', '_'), '+', '_'), '-', '-'), ' ', '_') || '_x' + ELSE REPLACE(REPLACE(REPLACE(REPLACE( + LOWER(SUBSTR(email, 1, INSTR(email, '@') - 1)), + '.', '_'), '+', '_'), '-', '-'), ' ', '_') + END, + password_hash, + created_at +FROM users; + +DROP TABLE users; +ALTER TABLE users_new RENAME TO users; diff --git a/crates/adapters/sqlite/migrations/0005_activitypub_v2.sql b/crates/adapters/sqlite/migrations/0005_activitypub_v2.sql new file mode 100644 index 0000000..1dda5e0 --- /dev/null +++ b/crates/adapters/sqlite/migrations/0005_activitypub_v2.sql @@ -0,0 +1,11 @@ +-- Store the original Follow activity URL so Undo/Reject can reference it correctly +ALTER TABLE ap_following ADD COLUMN follow_activity_id TEXT; + +-- Track whether our outbound follow was accepted by the remote server +ALTER TABLE ap_following ADD COLUMN status TEXT NOT NULL DEFAULT 'pending'; + +-- Store the AP object URL on reviews so DeleteActivity can target by ID +ALTER TABLE reviews ADD COLUMN ap_id TEXT; + +-- Partial unique index: ap_id is only set on remote reviews; local reviews have NULL +CREATE UNIQUE INDEX IF NOT EXISTS idx_reviews_ap_id ON reviews (ap_id) WHERE ap_id IS NOT NULL; diff --git a/crates/adapters/sqlite/src/federation.rs b/crates/adapters/sqlite/src/federation.rs index 3db32e0..8f09b8f 100644 --- a/crates/adapters/sqlite/src/federation.rs +++ b/crates/adapters/sqlite/src/federation.rs @@ -3,7 +3,7 @@ use async_trait::async_trait; use chrono::Utc; use sqlx::{Row, SqlitePool}; -use activitypub::repository::{FederationRepository, Follower, FollowerStatus, RemoteActor}; +use activitypub::repository::{FederationRepository, Follower, FollowerStatus, FollowingStatus, RemoteActor}; use domain::models::{Review, ReviewSource}; use domain::value_objects::UserId; @@ -146,7 +146,7 @@ impl FederationRepository for SqliteFederationRepository { Ok(()) } - async fn add_following(&self, local_user_id: UserId, actor: RemoteActor) -> Result<()> { + async fn add_following(&self, local_user_id: UserId, actor: RemoteActor, follow_activity_id: &str) -> Result<()> { let uid = local_user_id.value().to_string(); let now = Utc::now().naive_utc(); let created_at = datetime_to_str(&now); @@ -154,11 +154,12 @@ impl FederationRepository for SqliteFederationRepository { self.upsert_remote_actor(actor.clone()).await?; sqlx::query( - "INSERT OR IGNORE INTO ap_following (local_user_id, remote_actor_url, created_at) - VALUES (?, ?, ?)", + "INSERT OR IGNORE INTO ap_following (local_user_id, remote_actor_url, follow_activity_id, created_at) + VALUES (?, ?, ?, ?)", ) .bind(&uid) .bind(&actor.url) + .bind(follow_activity_id) .bind(&created_at) .execute(&self.pool) .await?; @@ -166,6 +167,18 @@ impl FederationRepository for SqliteFederationRepository { Ok(()) } + async fn get_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_following 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_following(&self, local_user_id: UserId, actor_url: &str) -> Result<()> { let uid = local_user_id.value().to_string(); @@ -187,7 +200,7 @@ impl FederationRepository for SqliteFederationRepository { "SELECT a.url, a.handle, a.inbox_url, a.shared_inbox_url, a.display_name FROM ap_following f INNER JOIN ap_remote_actors a ON a.url = f.remote_actor_url - WHERE f.local_user_id = ?", + WHERE f.local_user_id = ? AND f.status = 'accepted'", ) .bind(&uid) .fetch_all(&self.pool) @@ -210,7 +223,7 @@ impl FederationRepository for SqliteFederationRepository { async fn count_following(&self, local_user_id: UserId) -> Result { let uid = local_user_id.value().to_string(); let count: i64 = sqlx::query_scalar( - "SELECT COUNT(*) FROM ap_following WHERE local_user_id = ?", + "SELECT COUNT(*) FROM ap_following WHERE local_user_id = ? AND status = 'accepted'", ) .bind(&uid) .fetch_one(&self.pool) @@ -296,7 +309,7 @@ impl FederationRepository for SqliteFederationRepository { Ok(()) } - async fn save_remote_review(&self, review: &Review) -> Result<()> { + async fn save_remote_review(&self, review: &Review, ap_id: &str, movie_title: &str, release_year: u16, poster_url: Option<&str>) -> Result<()> { let actor_url = match review.source() { ReviewSource::Remote { actor_url } => actor_url.clone(), ReviewSource::Local => { @@ -304,8 +317,25 @@ impl FederationRepository for SqliteFederationRepository { } }; - let id = review.id().value().to_string(); let movie_id = review.movie_id().value().to_string(); + + // Stub movie so the feed INNER JOIN on movies always resolves. + // release_year 0 means unknown — clamp to 1888 (valid ReleaseYear range: 1888-2200). + // ON CONFLICT updates poster_path if a newer review carries one. + let _ = sqlx::query( + "INSERT INTO movies (id, external_metadata_id, title, release_year, director, poster_path) + VALUES (?, NULL, ?, ?, NULL, ?) + ON CONFLICT(id) DO UPDATE SET + poster_path = COALESCE(excluded.poster_path, movies.poster_path)", + ) + .bind(&movie_id) + .bind(movie_title) + .bind(release_year.max(1888) as i64) // ReleaseYear requires >= 1888 + .bind(poster_url) + .execute(&self.pool) + .await?; + + let id = review.id().value().to_string(); let user_id = review.user_id().value().to_string(); let rating = review.rating().value() as i64; let comment = review.comment().map(|c| c.value().to_string()); @@ -313,8 +343,8 @@ impl FederationRepository for SqliteFederationRepository { let created_at = datetime_to_str(review.created_at()); sqlx::query( - "INSERT OR IGNORE INTO reviews (id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + "INSERT OR IGNORE INTO reviews (id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url, ap_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", ) .bind(&id) .bind(&movie_id) @@ -324,9 +354,105 @@ impl FederationRepository for SqliteFederationRepository { .bind(&watched_at) .bind(&created_at) .bind(&actor_url) + .bind(ap_id) .execute(&self.pool) .await?; Ok(()) } + + async fn delete_remote_review_by_ap_id(&self, ap_id: &str, actor_url: &str) -> Result<()> { + sqlx::query("DELETE FROM reviews WHERE ap_id = ? AND remote_actor_url = ?") + .bind(ap_id) + .bind(actor_url) + .execute(&self.pool) + .await?; + Ok(()) + } + + async fn update_remote_review( + &self, + ap_id: &str, + actor_url: &str, + rating: u8, + comment: Option<&str>, + watched_at: chrono::NaiveDateTime, + ) -> Result<()> { + let watched_at_str = datetime_to_str(&watched_at); + sqlx::query( + "UPDATE reviews SET rating = ?, comment = ?, watched_at = ? + WHERE ap_id = ? AND remote_actor_url = ?", + ) + .bind(rating as i64) + .bind(comment) + .bind(&watched_at_str) + .bind(ap_id) + .bind(actor_url) + .execute(&self.pool) + .await?; + Ok(()) + } + + async fn delete_remote_reviews_by_actor(&self, actor_url: &str) -> Result<()> { + sqlx::query("DELETE FROM reviews WHERE remote_actor_url = ?") + .bind(actor_url) + .execute(&self.pool) + .await?; + Ok(()) + } + + async fn update_following_status( + &self, + local_user_id: UserId, + remote_actor_url: &str, + status: FollowingStatus, + ) -> Result<()> { + let uid = local_user_id.value().to_string(); + let status_str = match status { + FollowingStatus::Pending => "pending", + FollowingStatus::Accepted => "accepted", + }; + + let result = sqlx::query( + "UPDATE ap_following SET status = ? WHERE local_user_id = ? AND remote_actor_url = ?", + ) + .bind(status_str) + .bind(&uid) + .bind(remote_actor_url) + .execute(&self.pool) + .await?; + + if result.rows_affected() == 0 { + tracing::warn!( + local_user_id = %local_user_id.value(), + remote_actor_url = remote_actor_url, + "update_following_status: no row found" + ); + } + + Ok(()) + } + + async fn get_pending_followers(&self, local_user_id: UserId) -> Result> { + let uid = local_user_id.value().to_string(); + + let rows = sqlx::query( + "SELECT f.remote_actor_url, + a.handle, a.inbox_url, a.shared_inbox_url, a.display_name + FROM ap_followers f + LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url + WHERE f.local_user_id = ? AND f.status = 'pending'", + ) + .bind(&uid) + .fetch_all(&self.pool) + .await?; + + Ok(rows.into_iter().map(|row| RemoteActor { + url: row.get("remote_actor_url"), + handle: row.try_get("handle").unwrap_or_default(), + inbox_url: row.try_get("inbox_url").unwrap_or_default(), + shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(), + display_name: row.try_get("display_name").ok().flatten(), + }).collect()) + } } diff --git a/crates/adapters/sqlite/src/lib.rs b/crates/adapters/sqlite/src/lib.rs index a4d49ae..90c4d5a 100644 --- a/crates/adapters/sqlite/src/lib.rs +++ b/crates/adapters/sqlite/src/lib.rs @@ -224,14 +224,14 @@ impl SqliteMovieRepository { ) -> Result, DomainError> { sqlx::query_as!( FeedRow, - "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, + r#"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url, - u.email AS user_email + COALESCE(u.email, r.remote_actor_url) AS "user_email!: String" FROM reviews r INNER JOIN movies m ON m.id = r.movie_id - INNER JOIN users u ON u.id = r.user_id + LEFT JOIN users u ON u.id = r.user_id ORDER BY r.watched_at DESC - LIMIT ? OFFSET ?", + LIMIT ? OFFSET ?"#, limit, offset ) .fetch_all(&self.pool) diff --git a/crates/adapters/sqlite/src/models.rs b/crates/adapters/sqlite/src/models.rs index 269177a..a6fdec6 100644 --- a/crates/adapters/sqlite/src/models.rs +++ b/crates/adapters/sqlite/src/models.rs @@ -3,7 +3,7 @@ use domain::{ errors::DomainError, models::{DiaryEntry, FeedEntry, Movie, Review, ReviewSource, UserSummary}, value_objects::{ - Comment, ExternalMetadataId, MovieId, MovieTitle, PosterPath, Rating, ReleaseYear, + Comment, Email, ExternalMetadataId, MovieId, MovieTitle, PosterPath, Rating, ReleaseYear, ReviewId, UserId, }, }; @@ -171,12 +171,12 @@ pub(crate) struct UserSummaryRow { impl UserSummaryRow { pub fn to_domain(self) -> Result { - Ok(UserSummary { - user_id: UserId::from_uuid(parse_uuid(&self.id)?), - email: self.email, - total_movies: self.total_movies, - avg_rating: self.avg_rating, - }) + Ok(UserSummary::new( + UserId::from_uuid(parse_uuid(&self.id)?), + Email::new(self.email)?, + self.total_movies, + self.avg_rating, + )) } } diff --git a/crates/adapters/sqlite/src/users.rs b/crates/adapters/sqlite/src/users.rs index 84660de..fdd03d6 100644 --- a/crates/adapters/sqlite/src/users.rs +++ b/crates/adapters/sqlite/src/users.rs @@ -6,7 +6,7 @@ use domain::{ errors::DomainError, models::User, ports::UserRepository, - value_objects::{Email, PasswordHash, UserId}, + value_objects::{Email, PasswordHash, UserId, Username}, }; use super::models::UserSummaryRow; @@ -15,14 +15,29 @@ pub struct SqliteUserRepository { } impl SqliteUserRepository { - pub fn new(pool: SqlitePool) -> Self { - Self { pool } - } + pub fn new(pool: SqlitePool) -> Self { Self { pool } } fn map_err(e: sqlx::Error) -> DomainError { tracing::error!("Database error: {:?}", e); DomainError::InfrastructureError("Database operation failed".into()) } + + fn row_to_user( + id_str: String, + email_str: String, + username_str: String, + hash_str: String, + ) -> Result { + let id = uuid::Uuid::parse_str(&id_str) + .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; + let email = Email::new(email_str) + .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; + let username = Username::new(username_str) + .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; + let hash = PasswordHash::new(hash_str) + .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; + Ok(User::from_persistence(UserId::from_uuid(id), email, username, hash)) + } } #[async_trait] @@ -30,84 +45,81 @@ impl UserRepository for SqliteUserRepository { async fn find_by_email(&self, email: &Email) -> Result, DomainError> { let email_str = email.value(); let row = sqlx::query!( - "SELECT id, email, password_hash FROM users WHERE email = ?", + "SELECT id, email, username, password_hash FROM users WHERE email = ?", email_str ) .fetch_optional(&self.pool) .await .map_err(Self::map_err)?; - match row { - None => Ok(None), - Some(r) => { - let id = uuid::Uuid::parse_str(&r.id) - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; - let email = Email::new(r.email) - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; - let hash = PasswordHash::new(r.password_hash) - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; - Ok(Some(User::from_persistence(UserId::from_uuid(id), email, hash))) - } - } + row.map(|r| Self::row_to_user(r.id.unwrap_or_default(), r.email, r.username, r.password_hash)) + .transpose() + } + + async fn find_by_username(&self, username: &Username) -> Result, DomainError> { + let username_str = username.value(); + let row = sqlx::query!( + "SELECT id, email, username, password_hash FROM users WHERE username = ?", + username_str + ) + .fetch_optional(&self.pool) + .await + .map_err(Self::map_err)?; + + row.map(|r| Self::row_to_user(r.id.unwrap_or_default(), r.email, r.username, r.password_hash)) + .transpose() } async fn save(&self, user: &User) -> Result<(), DomainError> { + // Check email uniqueness first (clearer error than INSERT OR IGNORE) + if self.find_by_email(user.email()).await?.is_some() { + return Err(DomainError::ValidationError("Email already registered".into())); + } + // Check username uniqueness + if self.find_by_username(user.username()).await?.is_some() { + return Err(DomainError::ValidationError("Username already taken".into())); + } + let id = user.id().value().to_string(); let email = user.email().value(); + let username = user.username().value(); let hash = user.password_hash().value(); let created_at = Utc::now().to_rfc3339(); - let result = sqlx::query!( - "INSERT OR IGNORE INTO users (id, email, password_hash, created_at) VALUES (?, ?, ?, ?)", - id, - email, - hash, - created_at + sqlx::query!( + "INSERT INTO users (id, email, username, password_hash, created_at) VALUES (?, ?, ?, ?, ?)", + id, email, username, hash, created_at ) .execute(&self.pool) .await .map_err(Self::map_err)?; - if result.rows_affected() == 0 { - return Err(DomainError::ValidationError("Email already registered".into())); - } - Ok(()) } async fn find_by_id(&self, id: &UserId) -> Result, DomainError> { let id_str = id.value().to_string(); let row = sqlx::query!( - "SELECT id, email, password_hash FROM users WHERE id = ?", + "SELECT id, email, username, password_hash FROM users WHERE id = ?", id_str ) .fetch_optional(&self.pool) .await .map_err(Self::map_err)?; - match row { - None => Ok(None), - Some(r) => { - let uuid = uuid::Uuid::parse_str(&r.id) - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; - let email = Email::new(r.email) - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; - let hash = PasswordHash::new(r.password_hash) - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; - Ok(Some(User::from_persistence(UserId::from_uuid(uuid), email, hash))) - } - } + row.map(|r| Self::row_to_user(r.id.unwrap_or_default(), r.email, r.username, r.password_hash)) + .transpose() } async fn list_with_stats(&self) -> Result, DomainError> { sqlx::query_as!( UserSummaryRow, - r#"SELECT u.id, - u.email, + r#"SELECT u.id AS "id!: String", + u.email AS "email!: String", COUNT(DISTINCT r.movie_id) AS "total_movies!: i64", AVG(CAST(r.rating AS REAL)) AS avg_rating FROM users u - LEFT JOIN reviews r ON r.user_id = u.id + LEFT JOIN reviews r ON r.user_id = u.id AND r.remote_actor_url IS NULL GROUP BY u.id, u.email ORDER BY u.email ASC"# ) @@ -128,7 +140,7 @@ mod tests { async fn setup() -> (SqlitePool, SqliteUserRepository) { let pool = SqlitePool::connect(":memory:").await.unwrap(); sqlx::query( - "CREATE TABLE users (id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, created_at TEXT NOT NULL)" + "CREATE TABLE users (id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, created_at TEXT NOT NULL)" ) .execute(&pool) .await @@ -152,10 +164,11 @@ mod tests { let (pool, repo) = setup().await; let id = uuid::Uuid::new_v4(); sqlx::query( - "INSERT INTO users (id, email, password_hash, created_at) VALUES (?, ?, ?, ?)" + "INSERT INTO users (id, email, username, password_hash, created_at) VALUES (?, ?, ?, ?, ?)" ) .bind(id.to_string()) .bind("test@example.com") + .bind("test") .bind("$argon2id$v=19$m=65536,t=2,p=1$fakesalt$fakehash") .bind("2026-01-01T00:00:00Z") .execute(&pool) diff --git a/crates/adapters/template-askama/src/lib.rs b/crates/adapters/template-askama/src/lib.rs index e099255..d7f3ca0 100644 --- a/crates/adapters/template-askama/src/lib.rs +++ b/crates/adapters/template-askama/src/lib.rs @@ -5,7 +5,7 @@ use application::ports::{ NewReviewPageData, ProfilePageData, RegisterPageData, UsersPageData, }; use domain::models::{ - DiaryEntry, FeedEntry, MonthActivity, MonthlyRating, ReviewSource, UserStats, UserSummary, + DiaryEntry, FeedEntry, MonthActivity, MonthlyRating, ReviewSource, UserStats, UserTrends, collections::Paginated, }; @@ -81,10 +81,18 @@ struct ActivityFeedTemplate<'a> { page_items: Vec, } +struct UserSummaryView { + user_id: uuid::Uuid, + display_name: String, + initial: char, + avg_rating_display: String, + total_movies: i64, +} + #[derive(Template)] #[template(path = "users.html")] struct UsersTemplate<'a> { - users: &'a [UserSummary], + users: Vec, ctx: &'a HtmlPageContext, } @@ -100,6 +108,9 @@ struct ProfileTemplate<'a> { profile_display_name: String, profile_user_id: uuid::Uuid, stats: &'a UserStats, + avg_rating_display: String, + favorite_director_display: String, + most_active_month_display: String, view: &'a str, entries: Option<&'a Paginated>, current_offset: u32, @@ -113,6 +124,7 @@ struct ProfileTemplate<'a> { is_own_profile: bool, error: Option, following_count: usize, + pending_followers: Vec, } struct RemoteActorData { @@ -255,8 +267,23 @@ impl HtmlRenderer for AskamaHtmlRenderer { } fn render_users_page(&self, data: UsersPageData) -> Result { + let users: Vec = data.users.iter().map(|u| { + let email = u.email(); + let display_name = email.split('@').next().unwrap_or(email).to_string(); + let initial = display_name.chars().next().unwrap_or('?').to_ascii_uppercase(); + let avg_rating_display = u.avg_rating + .map(|r| format!("{:.1}", r)) + .unwrap_or_else(|| "—".to_string()); + UserSummaryView { + user_id: u.user_id.value(), + display_name, + initial, + avg_rating_display, + total_movies: u.total_movies, + } + }).collect(); UsersTemplate { - users: &data.users, + users, ctx: &data.ctx, } .render() @@ -279,11 +306,25 @@ impl HtmlRenderer for AskamaHtmlRenderer { .map(|e| if e.limit > 0 { ((e.total_count + e.limit as u64 - 1) / e.limit as u64) as u32 } else { 0 }) .unwrap_or(0); let current_page = if data.limit > 0 { data.current_offset / data.limit } else { 0 }; + let avg_rating_display = data.stats.avg_rating + .map(|r| format!("{:.1}", r)) + .unwrap_or_else(|| "—".to_string()); + let favorite_director_display = data.stats.favorite_director + .as_deref() + .unwrap_or("—") + .to_string(); + let most_active_month_display = data.stats.most_active_month + .as_deref() + .unwrap_or("—") + .to_string(); ProfileTemplate { ctx: &data.ctx, profile_display_name, profile_user_id: data.profile_user_id, stats: &data.stats, + avg_rating_display, + favorite_director_display, + most_active_month_display, view: &data.view, entries: data.entries.as_ref(), current_offset: data.current_offset, @@ -297,6 +338,11 @@ impl HtmlRenderer for AskamaHtmlRenderer { is_own_profile: data.is_own_profile, error: data.error, following_count: data.following_count, + pending_followers: data.pending_followers.into_iter().map(|a| RemoteActorData { + handle: a.handle, + url: a.url, + display_name: a.display_name, + }).collect(), } .render() .map_err(|e| e.to_string()) diff --git a/crates/adapters/template-askama/templates/activity_feed.html b/crates/adapters/template-askama/templates/activity_feed.html index ed5df2d..676df2d 100644 --- a/crates/adapters/template-askama/templates/activity_feed.html +++ b/crates/adapters/template-askama/templates/activity_feed.html @@ -25,12 +25,14 @@
{{ comment.value() }}
{% endif %}
- {{ entry.user_display_name() }} - {{ entry.review().watched_at().format("%b %-d, %Y") }} {% match entry.review().source() %} {% when ReviewSource::Remote with { actor_url } %} - ↗ federated + {{ entry.user_display_name() }} + {{ entry.review().watched_at().format("%b %-d, %Y") }} + ↗ federated {% when ReviewSource::Local %} + {{ entry.user_display_name() }} + {{ entry.review().watched_at().format("%b %-d, %Y") }} {% endmatch %}
{% if ctx.is_current_user(entry.review().user_id().value()) %} diff --git a/crates/adapters/template-askama/templates/profile.html b/crates/adapters/template-askama/templates/profile.html index 937f7fe..e39f22a 100644 --- a/crates/adapters/template-askama/templates/profile.html +++ b/crates/adapters/template-askama/templates/profile.html @@ -10,15 +10,15 @@
movies
-
{{ stats.avg_rating_display() }}★
+
{{ avg_rating_display }}★
avg rating
-
{{ stats.favorite_director_display() }}
+
{{ favorite_director_display }}
fav director
-
{{ stats.most_active_month_display() }}
+
{{ most_active_month_display }}
most active
@@ -36,6 +36,27 @@ {% endif %} View following ({{ following_count }}) + {% if !pending_followers.is_empty() %} +
+

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

+
    + {% for actor in pending_followers %} +
  • + {{ actor.handle }} + {{ actor.url }} +
    + + +
    +
    + + +
    +
  • + {% endfor %} +
+
+ {% endif %} {% endif %}
diff --git a/crates/adapters/template-askama/templates/register.html b/crates/adapters/template-askama/templates/register.html index 7d9f02f..bb924fb 100644 --- a/crates/adapters/template-askama/templates/register.html +++ b/crates/adapters/template-askama/templates/register.html @@ -5,6 +5,12 @@

{{ err }}

{% endif %}
+