From 815178e6a4dbe933703ca8988f1e9d0aa997dbb2 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Wed, 13 May 2026 22:21:41 +0200 Subject: [PATCH] feat(ap): ActivityPub spec compliance and profile completeness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 — spec compliance: - Add AS_PUBLIC constant; add to/cc fields to CreateActivity, DeleteActivity, UpdateActivity, AddActivity; populate on all broadcast call sites - Add @context to outbox CreateActivity items - Set manuallyApprovesFollowers: true to match actual Pending follow flow - Gate PermissiveVerifier behind FEDERATION_DEBUG env var - Add updated timestamp to Person actor JSON - Improve actor update delivery logging Phase 2a Batch 1 — AP layer: - Add /inbox shared inbox route; add endpoints.sharedInbox to Person - Paginate followers and following collections (20/page, OrderedCollectionPage) Phase 2a Batch 2 — profile completeness: - DB migrations: banner_path, also_known_as columns; user_profile_fields table - ProfileField value object; UserProfileFieldsRepository port - Banner image upload (stored via image-converter, surfaced as image in Person) - alsoKnownAs field in Person (account migration support) - Custom profile fields (up to 4 PropertyValue attachments in Person) - Profile settings UI: banner preview/upload, alsoKnownAs input, fields form - PUT /api/v1/profile/fields API endpoint --- ...072cde8d806ca5b4007bbc882aada5c46ae63.json | 12 ++ ...a4ccfb9c9810baee06cfac5ad4af5749e4c6e.json | 68 +++++++++++ ...e6389a0aca5a732531bb9ca9b99675d0a89f4.json | 44 ------- ...842676aa3149bdfc4012f3f3318677a56336d.json | 12 ++ ...42c0df0b29101223f370a126fb0ee76e3b9bd.json | 26 +++++ ...583598865630241deffbb8b4175fa7a755099.json | 44 ------- ...479b9f9f431691d5f0e4ee460cd559f5412b4.json | 16 ++- ...499fe813b18b9e7d8d241462f0689969dc64f.json | 44 ------- ...b65da6f95b7c3c015f2633fcf00c045b9f08b.json | 16 ++- .../activitypub-base/src/activities.rs | 16 +++ .../adapters/activitypub-base/src/actors.rs | 58 +++++++++- .../activitypub-base/src/followers_handler.rs | 107 ++++++++++++++---- crates/adapters/activitypub-base/src/lib.rs | 1 + .../adapters/activitypub-base/src/outbox.rs | 14 ++- .../activitypub-base/src/repository.rs | 13 +++ .../adapters/activitypub-base/src/service.rs | 32 +++++- .../activitypub-base/src/tests/actors.rs | 14 ++- crates/adapters/activitypub-base/src/urls.rs | 2 + crates/adapters/activitypub-base/src/user.rs | 3 + crates/adapters/activitypub/src/lib.rs | 16 ++- crates/adapters/activitypub/src/objects.rs | 17 ++- .../adapters/activitypub/src/tests/objects.rs | 49 ++++++++ .../adapters/activitypub/src/user_adapter.rs | 37 +++++- .../adapters/postgres-federation/src/lib.rs | 89 +++++++++++++++ .../migrations/0019_profile_extended.sql | 13 +++ crates/adapters/postgres/src/lib.rs | 8 ++ .../adapters/postgres/src/profile_fields.rs | 76 +++++++++++++ crates/adapters/postgres/src/users.rs | 42 +++++-- crates/adapters/sqlite-federation/src/lib.rs | 100 ++++++++++++++++ ...072cde8d806ca5b4007bbc882aada5c46ae63.json | 12 ++ ...a4ccfb9c9810baee06cfac5ad4af5749e4c6e.json | 68 +++++++++++ ...842676aa3149bdfc4012f3f3318677a56336d.json | 12 ++ ...42c0df0b29101223f370a126fb0ee76e3b9bd.json | 26 +++++ ...479b9f9f431691d5f0e4ee460cd559f5412b4.json | 68 +++++++++++ ...65da6f95b7c3c015f2633fcf00c045b9f08b.json} | 16 ++- .../migrations/0019_profile_extended.sql | 13 +++ crates/adapters/sqlite/src/lib.rs | 8 ++ crates/adapters/sqlite/src/profile_fields.rs | 58 ++++++++++ crates/adapters/sqlite/src/tests/users.rs | 8 +- crates/adapters/sqlite/src/users.rs | 36 ++++-- crates/adapters/template-askama/src/lib.rs | 6 + .../templates/profile_settings.html | 31 +++++ crates/application/src/commands.rs | 8 ++ crates/application/src/context.rs | 3 +- crates/application/src/ports.rs | 3 + crates/application/src/use_cases/mod.rs | 1 + .../src/use_cases/update_profile.rs | 35 ++++-- .../src/use_cases/update_profile_fields.rs | 17 +++ crates/domain/src/models/mod.rs | 26 ++++- crates/domain/src/ports.rs | 8 ++ crates/presentation/src/handlers/api.rs | 65 +++++++++-- crates/presentation/src/handlers/html.rs | 73 ++++++++++-- crates/presentation/src/main.rs | 11 ++ crates/presentation/src/routes.rs | 1 + crates/worker/src/db.rs | 12 +- crates/worker/src/main.rs | 20 ++-- 56 files changed, 1388 insertions(+), 246 deletions(-) create mode 100644 .sqlx/query-11f7dd8da277aaf950e2a428f8e072cde8d806ca5b4007bbc882aada5c46ae63.json create mode 100644 .sqlx/query-1dd3efb043635e638f1c3d72923a4ccfb9c9810baee06cfac5ad4af5749e4c6e.json delete mode 100644 .sqlx/query-319b5d09824809a971f6c9546dde6389a0aca5a732531bb9ca9b99675d0a89f4.json create mode 100644 .sqlx/query-5bde1c64a1dec54f348058c9d93842676aa3149bdfc4012f3f3318677a56336d.json create mode 100644 .sqlx/query-5e447e9558515934d8f0c08e91342c0df0b29101223f370a126fb0ee76e3b9bd.json delete mode 100644 .sqlx/query-79af0324db4d0b8e4bd66e12816583598865630241deffbb8b4175fa7a755099.json rename crates/adapters/sqlite/.sqlx/query-1417a8a295bc966637eb7e68e088148a7bef09fb1a3c3ea44d25da32c3908472.json => .sqlx/query-7cb37c7e3df2a945859e12a186a479b9f9f431691d5f0e4ee460cd559f5412b4.json (71%) delete mode 100644 .sqlx/query-c43249558d535343e387586fa00499fe813b18b9e7d8d241462f0689969dc64f.json rename crates/adapters/sqlite/.sqlx/query-1edf77b936b825139735e4f92bc472031e3231235ca5fe40732d7bdfddc4cbba.json => .sqlx/query-e6413dcabae4a72628a2abf33a8b65da6f95b7c3c015f2633fcf00c045b9f08b.json (72%) create mode 100644 crates/adapters/postgres/migrations/0019_profile_extended.sql create mode 100644 crates/adapters/postgres/src/profile_fields.rs create mode 100644 crates/adapters/sqlite/.sqlx/query-11f7dd8da277aaf950e2a428f8e072cde8d806ca5b4007bbc882aada5c46ae63.json create mode 100644 crates/adapters/sqlite/.sqlx/query-1dd3efb043635e638f1c3d72923a4ccfb9c9810baee06cfac5ad4af5749e4c6e.json create mode 100644 crates/adapters/sqlite/.sqlx/query-5bde1c64a1dec54f348058c9d93842676aa3149bdfc4012f3f3318677a56336d.json create mode 100644 crates/adapters/sqlite/.sqlx/query-5e447e9558515934d8f0c08e91342c0df0b29101223f370a126fb0ee76e3b9bd.json create mode 100644 crates/adapters/sqlite/.sqlx/query-7cb37c7e3df2a945859e12a186a479b9f9f431691d5f0e4ee460cd559f5412b4.json rename crates/adapters/sqlite/.sqlx/{query-d6c6b579a18fb106e62148f5f85b8071fceefea51909ace939ae1d09c4597c43.json => query-e6413dcabae4a72628a2abf33a8b65da6f95b7c3c015f2633fcf00c045b9f08b.json} (72%) create mode 100644 crates/adapters/sqlite/migrations/0019_profile_extended.sql create mode 100644 crates/adapters/sqlite/src/profile_fields.rs create mode 100644 crates/application/src/use_cases/update_profile_fields.rs diff --git a/.sqlx/query-11f7dd8da277aaf950e2a428f8e072cde8d806ca5b4007bbc882aada5c46ae63.json b/.sqlx/query-11f7dd8da277aaf950e2a428f8e072cde8d806ca5b4007bbc882aada5c46ae63.json new file mode 100644 index 0000000..7f98aab --- /dev/null +++ b/.sqlx/query-11f7dd8da277aaf950e2a428f8e072cde8d806ca5b4007bbc882aada5c46ae63.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM user_profile_fields WHERE user_id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "11f7dd8da277aaf950e2a428f8e072cde8d806ca5b4007bbc882aada5c46ae63" +} diff --git a/.sqlx/query-1dd3efb043635e638f1c3d72923a4ccfb9c9810baee06cfac5ad4af5749e4c6e.json b/.sqlx/query-1dd3efb043635e638f1c3d72923a4ccfb9c9810baee06cfac5ad4af5749e4c6e.json new file mode 100644 index 0000000..148df15 --- /dev/null +++ b/.sqlx/query-1dd3efb043635e638f1c3d72923a4ccfb9c9810baee06cfac5ad4af5749e4c6e.json @@ -0,0 +1,68 @@ +{ + "db_name": "SQLite", + "query": "SELECT id, email, username, password_hash, role, bio, avatar_path, banner_path, also_known_as 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" + }, + { + "name": "role", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "bio", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "avatar_path", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "banner_path", + "ordinal": 7, + "type_info": "Text" + }, + { + "name": "also_known_as", + "ordinal": 8, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + true, + false, + false, + false, + false, + true, + true, + true, + true + ] + }, + "hash": "1dd3efb043635e638f1c3d72923a4ccfb9c9810baee06cfac5ad4af5749e4c6e" +} diff --git a/.sqlx/query-319b5d09824809a971f6c9546dde6389a0aca5a732531bb9ca9b99675d0a89f4.json b/.sqlx/query-319b5d09824809a971f6c9546dde6389a0aca5a732531bb9ca9b99675d0a89f4.json deleted file mode 100644 index a562a73..0000000 --- a/.sqlx/query-319b5d09824809a971f6c9546dde6389a0aca5a732531bb9ca9b99675d0a89f4.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "db_name": "SQLite", - "query": "SELECT id, email, username, password_hash, role 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" - }, - { - "name": "role", - "ordinal": 4, - "type_info": "Text" - } - ], - "parameters": { - "Right": 1 - }, - "nullable": [ - true, - false, - false, - false, - false - ] - }, - "hash": "319b5d09824809a971f6c9546dde6389a0aca5a732531bb9ca9b99675d0a89f4" -} diff --git a/.sqlx/query-5bde1c64a1dec54f348058c9d93842676aa3149bdfc4012f3f3318677a56336d.json b/.sqlx/query-5bde1c64a1dec54f348058c9d93842676aa3149bdfc4012f3f3318677a56336d.json new file mode 100644 index 0000000..6a1e141 --- /dev/null +++ b/.sqlx/query-5bde1c64a1dec54f348058c9d93842676aa3149bdfc4012f3f3318677a56336d.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO user_profile_fields (id, user_id, name, value, position) VALUES (?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 5 + }, + "nullable": [] + }, + "hash": "5bde1c64a1dec54f348058c9d93842676aa3149bdfc4012f3f3318677a56336d" +} diff --git a/.sqlx/query-5e447e9558515934d8f0c08e91342c0df0b29101223f370a126fb0ee76e3b9bd.json b/.sqlx/query-5e447e9558515934d8f0c08e91342c0df0b29101223f370a126fb0ee76e3b9bd.json new file mode 100644 index 0000000..b852564 --- /dev/null +++ b/.sqlx/query-5e447e9558515934d8f0c08e91342c0df0b29101223f370a126fb0ee76e3b9bd.json @@ -0,0 +1,26 @@ +{ + "db_name": "SQLite", + "query": "SELECT name, value FROM user_profile_fields WHERE user_id = ? ORDER BY position ASC", + "describe": { + "columns": [ + { + "name": "name", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "value", + "ordinal": 1, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false + ] + }, + "hash": "5e447e9558515934d8f0c08e91342c0df0b29101223f370a126fb0ee76e3b9bd" +} diff --git a/.sqlx/query-79af0324db4d0b8e4bd66e12816583598865630241deffbb8b4175fa7a755099.json b/.sqlx/query-79af0324db4d0b8e4bd66e12816583598865630241deffbb8b4175fa7a755099.json deleted file mode 100644 index a0c31a5..0000000 --- a/.sqlx/query-79af0324db4d0b8e4bd66e12816583598865630241deffbb8b4175fa7a755099.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "db_name": "SQLite", - "query": "SELECT id, email, username, password_hash, role FROM users WHERE id = ?", - "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" - }, - { - "name": "role", - "ordinal": 4, - "type_info": "Text" - } - ], - "parameters": { - "Right": 1 - }, - "nullable": [ - true, - false, - false, - false, - false - ] - }, - "hash": "79af0324db4d0b8e4bd66e12816583598865630241deffbb8b4175fa7a755099" -} diff --git a/crates/adapters/sqlite/.sqlx/query-1417a8a295bc966637eb7e68e088148a7bef09fb1a3c3ea44d25da32c3908472.json b/.sqlx/query-7cb37c7e3df2a945859e12a186a479b9f9f431691d5f0e4ee460cd559f5412b4.json similarity index 71% rename from crates/adapters/sqlite/.sqlx/query-1417a8a295bc966637eb7e68e088148a7bef09fb1a3c3ea44d25da32c3908472.json rename to .sqlx/query-7cb37c7e3df2a945859e12a186a479b9f9f431691d5f0e4ee460cd559f5412b4.json index 1edfaad..5da35a0 100644 --- a/crates/adapters/sqlite/.sqlx/query-1417a8a295bc966637eb7e68e088148a7bef09fb1a3c3ea44d25da32c3908472.json +++ b/.sqlx/query-7cb37c7e3df2a945859e12a186a479b9f9f431691d5f0e4ee460cd559f5412b4.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT id, email, username, password_hash, role, bio, avatar_path FROM users WHERE username = ?", + "query": "SELECT id, email, username, password_hash, role, bio, avatar_path, banner_path, also_known_as FROM users WHERE email = ?", "describe": { "columns": [ { @@ -37,6 +37,16 @@ "name": "avatar_path", "ordinal": 6, "type_info": "Text" + }, + { + "name": "banner_path", + "ordinal": 7, + "type_info": "Text" + }, + { + "name": "also_known_as", + "ordinal": 8, + "type_info": "Text" } ], "parameters": { @@ -49,8 +59,10 @@ false, false, true, + true, + true, true ] }, - "hash": "1417a8a295bc966637eb7e68e088148a7bef09fb1a3c3ea44d25da32c3908472" + "hash": "7cb37c7e3df2a945859e12a186a479b9f9f431691d5f0e4ee460cd559f5412b4" } diff --git a/.sqlx/query-c43249558d535343e387586fa00499fe813b18b9e7d8d241462f0689969dc64f.json b/.sqlx/query-c43249558d535343e387586fa00499fe813b18b9e7d8d241462f0689969dc64f.json deleted file mode 100644 index eadda08..0000000 --- a/.sqlx/query-c43249558d535343e387586fa00499fe813b18b9e7d8d241462f0689969dc64f.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "db_name": "SQLite", - "query": "SELECT id, email, username, password_hash, role FROM users WHERE email = ?", - "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" - }, - { - "name": "role", - "ordinal": 4, - "type_info": "Text" - } - ], - "parameters": { - "Right": 1 - }, - "nullable": [ - true, - false, - false, - false, - false - ] - }, - "hash": "c43249558d535343e387586fa00499fe813b18b9e7d8d241462f0689969dc64f" -} diff --git a/crates/adapters/sqlite/.sqlx/query-1edf77b936b825139735e4f92bc472031e3231235ca5fe40732d7bdfddc4cbba.json b/.sqlx/query-e6413dcabae4a72628a2abf33a8b65da6f95b7c3c015f2633fcf00c045b9f08b.json similarity index 72% rename from crates/adapters/sqlite/.sqlx/query-1edf77b936b825139735e4f92bc472031e3231235ca5fe40732d7bdfddc4cbba.json rename to .sqlx/query-e6413dcabae4a72628a2abf33a8b65da6f95b7c3c015f2633fcf00c045b9f08b.json index 4855c0d..a69615c 100644 --- a/crates/adapters/sqlite/.sqlx/query-1edf77b936b825139735e4f92bc472031e3231235ca5fe40732d7bdfddc4cbba.json +++ b/.sqlx/query-e6413dcabae4a72628a2abf33a8b65da6f95b7c3c015f2633fcf00c045b9f08b.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT id, email, username, password_hash, role, bio, avatar_path FROM users WHERE email = ?", + "query": "SELECT id, email, username, password_hash, role, bio, avatar_path, banner_path, also_known_as FROM users WHERE id = ?", "describe": { "columns": [ { @@ -37,6 +37,16 @@ "name": "avatar_path", "ordinal": 6, "type_info": "Text" + }, + { + "name": "banner_path", + "ordinal": 7, + "type_info": "Text" + }, + { + "name": "also_known_as", + "ordinal": 8, + "type_info": "Text" } ], "parameters": { @@ -49,8 +59,10 @@ false, false, true, + true, + true, true ] }, - "hash": "1edf77b936b825139735e4f92bc472031e3231235ca5fe40732d7bdfddc4cbba" + "hash": "e6413dcabae4a72628a2abf33a8b65da6f95b7c3c015f2633fcf00c045b9f08b" } diff --git a/crates/adapters/activitypub-base/src/activities.rs b/crates/adapters/activitypub-base/src/activities.rs index 56951ad..4ee061b 100644 --- a/crates/adapters/activitypub-base/src/activities.rs +++ b/crates/adapters/activitypub-base/src/activities.rs @@ -301,6 +301,10 @@ pub struct CreateActivity { pub(crate) kind: CreateType, pub(crate) actor: ObjectId, pub(crate) object: serde_json::Value, + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub(crate) to: Vec, + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub(crate) cc: Vec, } #[async_trait::async_trait] @@ -347,6 +351,10 @@ pub struct DeleteActivity { pub(crate) kind: DeleteType, pub(crate) actor: ObjectId, pub(crate) object: Url, + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub(crate) to: Vec, + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub(crate) cc: Vec, } #[async_trait::async_trait] @@ -392,6 +400,10 @@ pub struct UpdateActivity { pub(crate) kind: UpdateType, pub(crate) actor: ObjectId, pub(crate) object: serde_json::Value, + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub(crate) to: Vec, + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub(crate) cc: Vec, } #[async_trait::async_trait] @@ -495,6 +507,10 @@ pub struct AddActivity { pub(crate) kind: AddType, pub(crate) actor: ObjectId, pub(crate) object: serde_json::Value, + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub(crate) to: Vec, + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub(crate) cc: Vec, } #[async_trait::async_trait] diff --git a/crates/adapters/activitypub-base/src/actors.rs b/crates/adapters/activitypub-base/src/actors.rs index b003206..08cdda6 100644 --- a/crates/adapters/activitypub-base/src/actors.rs +++ b/crates/adapters/activitypub-base/src/actors.rs @@ -28,7 +28,10 @@ pub struct DbActor { pub last_refreshed_at: DateTime, pub bio: Option, pub avatar_url: Option, + pub banner_url: Option, + pub also_known_as: Option, pub profile_url: Option, + pub attachment: Vec, } #[derive(Debug, Clone, Deserialize, Serialize)] @@ -38,6 +41,20 @@ pub struct ApImageObject { pub url: Url, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Endpoints { + pub shared_inbox: Url, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProfileFieldObject { + #[serde(rename = "type")] + pub kind: String, + pub name: String, + pub value: String, +} + #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct Person { @@ -60,6 +77,16 @@ pub struct Person { #[serde(skip_serializing_if = "Option::is_none")] discoverable: Option, manually_approves_followers: bool, + #[serde(skip_serializing_if = "Option::is_none", default)] + updated: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + endpoints: Option, + #[serde(skip_serializing_if = "Option::is_none")] + image: Option, + #[serde(rename = "alsoKnownAs", skip_serializing_if = "Vec::is_empty", default)] + also_known_as: Vec, + #[serde(skip_serializing_if = "Vec::is_empty", default)] + attachment: Vec, } pub async fn get_local_actor( @@ -107,7 +134,10 @@ pub async fn get_local_actor( last_refreshed_at: Utc::now(), bio: user.bio, avatar_url: user.avatar_url, + banner_url: user.banner_url, + also_known_as: user.also_known_as, profile_url: user.profile_url, + attachment: user.attachment, }) } @@ -167,11 +197,14 @@ impl Object for DbActor { last_refreshed_at: Utc::now(), bio: None, avatar_url: None, + banner_url: None, + also_known_as: None, profile_url: None, + attachment: vec![], })) } - async fn into_json(self, _data: &Data) -> Result { + async fn into_json(self, data: &Data) -> Result { let public_key = PublicKey { id: format!("{}#main-key", &self.ap_id), owner: self.ap_id.clone(), @@ -182,7 +215,20 @@ impl Object for DbActor { kind: "Image".to_string(), url, }); + let image = self.banner_url.map(|url| ApImageObject { + kind: "Image".to_string(), + url, + }); let profile_url = self.profile_url; + let also_known_as: Vec = self.also_known_as.into_iter().collect(); + let attachment: Vec = self.attachment.into_iter().map(|f| ProfileFieldObject { + kind: "PropertyValue".to_string(), + name: f.name, + value: f.value, + }).collect(); + + let shared_inbox = Url::parse(&format!("{}/inbox", data.base_url)) + .expect("base_url is always valid"); Ok(Person { kind: Default::default(), @@ -198,7 +244,12 @@ impl Object for DbActor { icon, url: profile_url, discoverable: Some(true), - manually_approves_followers: false, + manually_approves_followers: true, + updated: Some(self.last_refreshed_at), + endpoints: Some(Endpoints { shared_inbox }), + image, + also_known_as, + attachment, }) } @@ -244,7 +295,10 @@ impl Object for DbActor { last_refreshed_at: Utc::now(), bio: None, avatar_url: None, + banner_url: None, + also_known_as: None, profile_url: None, + attachment: vec![], }) } } diff --git a/crates/adapters/activitypub-base/src/followers_handler.rs b/crates/adapters/activitypub-base/src/followers_handler.rs index bfc6658..36b4800 100644 --- a/crates/adapters/activitypub-base/src/followers_handler.rs +++ b/crates/adapters/activitypub-base/src/followers_handler.rs @@ -1,23 +1,21 @@ use activitypub_federation::{axum::json::FederationJson, config::Data}; -use axum::extract::Path; +use axum::extract::{Path, Query}; +use serde::Deserialize; use serde_json::json; 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, - }) +const PAGE_SIZE: usize = 20; + +#[derive(Deserialize)] +pub struct PageQuery { + page: Option, } pub async fn followers_handler( Path(user_id_str): Path, + Query(query): Query, data: Data, ) -> Result, Error> { let user_id = uuid::Uuid::parse_str(&user_id_str) @@ -29,24 +27,53 @@ pub async fn followers_handler( .map_err(Error::from)? .ok_or_else(|| Error::not_found(anyhow::anyhow!("user not found")))?; - let followers = data + let collection_id = format!("{}/users/{}/followers", data.base_url, user_id_str); + let total = data .federation_repo - .get_followers(user_id) + .count_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(); + if let Some(page) = query.page { + let page = page.max(1); + let offset = (page.saturating_sub(1) as usize) * PAGE_SIZE; + let followers = data + .federation_repo + .get_followers_page(user_id, offset as u32, PAGE_SIZE) + .await + .map_err(Error::from)?; - let id = format!("{}/users/{}/followers", data.base_url, user_id_str); - Ok(FederationJson(ordered_collection(id, items.len(), items))) + let has_next = offset + followers.len() < total; + let items: Vec = followers.into_iter().map(|f| f.actor.url).collect(); + + let mut obj = json!({ + "@context": "https://www.w3.org/ns/activitystreams", + "type": "OrderedCollectionPage", + "id": format!("{}?page={}", collection_id, page), + "partOf": collection_id, + "totalItems": total, + "orderedItems": items, + }); + + if has_next { + obj["next"] = json!(format!("{}?page={}", collection_id, page + 1)); + } + + Ok(FederationJson(obj)) + } else { + Ok(FederationJson(json!({ + "@context": "https://www.w3.org/ns/activitystreams", + "type": "OrderedCollection", + "id": collection_id, + "totalItems": total, + "first": format!("{}?page=1", collection_id), + }))) + } } pub async fn following_handler( Path(user_id_str): Path, + Query(query): Query, data: Data, ) -> Result, Error> { let user_id = uuid::Uuid::parse_str(&user_id_str) @@ -58,14 +85,46 @@ pub async fn following_handler( .map_err(Error::from)? .ok_or_else(|| Error::not_found(anyhow::anyhow!("user not found")))?; - let following = data + let collection_id = format!("{}/users/{}/following", data.base_url, user_id_str); + let total = data .federation_repo - .get_following(user_id) + .count_following(user_id) .await .map_err(Error::from)?; - let items: Vec = following.into_iter().map(|a| a.url).collect(); + if let Some(page) = query.page { + let page = page.max(1); + let offset = (page.saturating_sub(1) as usize) * PAGE_SIZE; + let following = data + .federation_repo + .get_following_page(user_id, offset as u32, PAGE_SIZE) + .await + .map_err(Error::from)?; - let id = format!("{}/users/{}/following", data.base_url, user_id_str); - Ok(FederationJson(ordered_collection(id, items.len(), items))) + let has_next = offset + following.len() < total; + let items: Vec = following.into_iter().map(|a| a.url).collect(); + + let mut obj = json!({ + "@context": "https://www.w3.org/ns/activitystreams", + "type": "OrderedCollectionPage", + "id": format!("{}?page={}", collection_id, page), + "partOf": collection_id, + "totalItems": total, + "orderedItems": items, + }); + + if has_next { + obj["next"] = json!(format!("{}?page={}", collection_id, page + 1)); + } + + Ok(FederationJson(obj)) + } else { + Ok(FederationJson(json!({ + "@context": "https://www.w3.org/ns/activitystreams", + "type": "OrderedCollection", + "id": collection_id, + "totalItems": total, + "first": format!("{}?page=1", collection_id), + }))) + } } diff --git a/crates/adapters/activitypub-base/src/lib.rs b/crates/adapters/activitypub-base/src/lib.rs index 0d9f986..5b24dee 100644 --- a/crates/adapters/activitypub-base/src/lib.rs +++ b/crates/adapters/activitypub-base/src/lib.rs @@ -12,6 +12,7 @@ pub mod outbox; pub mod repository; pub mod service; pub(crate) mod urls; +pub use urls::AS_PUBLIC; pub mod user; pub mod webfinger; diff --git a/crates/adapters/activitypub-base/src/outbox.rs b/crates/adapters/activitypub-base/src/outbox.rs index b4162aa..983b9b1 100644 --- a/crates/adapters/activitypub-base/src/outbox.rs +++ b/crates/adapters/activitypub-base/src/outbox.rs @@ -4,7 +4,12 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use url::Url; -use activitypub_federation::{config::Data, fetch::object_id::ObjectId, kinds::activity::CreateType}; +use activitypub_federation::{ + config::Data, + fetch::object_id::ObjectId, + kinds::activity::CreateType, + protocol::context::WithContext, +}; use crate::{activities::CreateActivity, data::FederationData, error::Error}; @@ -74,17 +79,20 @@ pub async fn outbox_handler( let has_more = items.len() == PAGE_SIZE; let oldest_ts = items.last().map(|(_, _, ts)| *ts); + let followers_url = format!("{}/followers", actor_url); let ordered_items: Vec = items .into_iter() .map(|(ap_id, object, _)| { let create_id = Url::parse(&format!("{}/activity", ap_id)).expect("valid url"); - serde_json::to_value(CreateActivity { + serde_json::to_value(WithContext::new_default(CreateActivity { id: create_id, kind: CreateType::default(), actor: ObjectId::from(actor_url.clone()), object, - }) + to: vec![crate::urls::AS_PUBLIC.to_string()], + cc: vec![followers_url.clone()], + })) .expect("serializable") }) .collect(); diff --git a/crates/adapters/activitypub-base/src/repository.rs b/crates/adapters/activitypub-base/src/repository.rs index 2a7b26a..0aab3c2 100644 --- a/crates/adapters/activitypub-base/src/repository.rs +++ b/crates/adapters/activitypub-base/src/repository.rs @@ -58,6 +58,19 @@ pub trait FederationRepository: Send + Sync { remote_actor_url: &str, ) -> Result<()>; async fn get_followers(&self, local_user_id: uuid::Uuid) -> Result>; + async fn get_followers_page( + &self, + local_user_id: uuid::Uuid, + offset: u32, + limit: usize, + ) -> Result>; + async fn count_followers(&self, local_user_id: uuid::Uuid) -> Result; + async fn get_following_page( + &self, + local_user_id: uuid::Uuid, + offset: u32, + limit: usize, + ) -> Result>; async fn update_follower_status( &self, local_user_id: uuid::Uuid, diff --git a/crates/adapters/activitypub-base/src/service.rs b/crates/adapters/activitypub-base/src/service.rs index 4fbc841..f3e23b8 100644 --- a/crates/adapters/activitypub-base/src/service.rs +++ b/crates/adapters/activitypub-base/src/service.rs @@ -121,6 +121,7 @@ impl ActivityPubService { .route("/.well-known/nodeinfo", get(nodeinfo_well_known_handler)) .route("/nodeinfo/2.0", get(nodeinfo_handler)) .route("/.well-known/webfinger", get(webfinger_handler)) + .route("/inbox", post(inbox_handler)) .route("/users/{id}/inbox", post(inbox_handler)) .route("/users/{id}/outbox", get(outbox_handler)) .route("/users/{id}/followers", get(followers_handler)) @@ -487,6 +488,8 @@ impl ActivityPubService { kind: Default::default(), actor: ObjectId::from(local_actor.ap_id.clone()), object, + to: vec![crate::urls::AS_PUBLIC.to_string()], + cc: vec![local_actor.followers_url.to_string()], }; let create_with_ctx = WithContext::new_default(create); @@ -554,6 +557,8 @@ impl ActivityPubService { kind: Default::default(), actor: ObjectId::from(local_actor.ap_id.clone()), object: ap_id, + to: vec![crate::urls::AS_PUBLIC.to_string()], + cc: vec![local_actor.followers_url.to_string()], }; let delete_with_ctx = WithContext::new_default(delete); let inboxes = collect_inboxes(&accepted); @@ -617,6 +622,8 @@ impl ActivityPubService { kind: Default::default(), actor: ObjectId::from(local_actor.ap_id.clone()), object, + to: vec![crate::urls::AS_PUBLIC.to_string()], + cc: vec![local_actor.followers_url.to_string()], }; let add_with_ctx = WithContext::new_default(add); let inboxes = collect_inboxes(&accepted); @@ -746,6 +753,8 @@ impl ActivityPubService { kind: Default::default(), actor: ObjectId::from(local_actor.ap_id.clone()), object, + to: vec![crate::urls::AS_PUBLIC.to_string()], + cc: vec![local_actor.followers_url.to_string()], }; let update_with_ctx = WithContext::new_default(update); let inboxes = collect_inboxes(&accepted); @@ -771,7 +780,8 @@ impl ActivityPubService { let person = local_actor.clone().into_json(&data).await .map_err(|e| anyhow::anyhow!("{e}"))?; - let person_json = serde_json::to_value(&person)?; + // Wrap with @context so Mastodon's JSON-LD processor can resolve field names. + let person_json = serde_json::to_value(&WithContext::new_default(person))?; let update_id = Url::parse(&format!( "{}/activities/update/{}", @@ -784,6 +794,8 @@ impl ActivityPubService { kind: Default::default(), actor: ObjectId::from(local_actor.ap_id.clone()), object: person_json, + to: vec![crate::urls::AS_PUBLIC.to_string()], + cc: vec![local_actor.followers_url.to_string()], }; let followers = data.federation_repo.get_followers(user_id).await?; @@ -793,10 +805,19 @@ impl ActivityPubService { .collect(); if accepted.is_empty() { + tracing::info!(user_id = %user_id, "no accepted followers, skipping actor update broadcast"); return Ok(()); } let inboxes = collect_inboxes(&accepted); + tracing::info!( + user_id = %user_id, + follower_count = accepted.len(), + inbox_count = inboxes.len(), + inboxes = ?inboxes, + "broadcasting actor update" + ); + let sends = SendActivityTask::prepare( &WithContext::new_default(update), &local_actor, @@ -807,8 +828,13 @@ impl ActivityPubService { let failures = send_with_retry(sends, &data).await; if !failures.is_empty() { - tracing::warn!(count = failures.len(), "actor update delivery failures"); + return Err(anyhow::anyhow!( + "actor update delivery failed for {} inbox(es): {}", + failures.len(), + failures.iter().map(|e| e.to_string()).collect::>().join("; ") + )); } + tracing::info!(user_id = %user_id, "actor update broadcast complete"); Ok(()) } @@ -1115,6 +1141,8 @@ impl ActivityPubService { kind: Default::default(), actor: ObjectId::from(local_actor.ap_id.clone()), object: object_json.clone(), + to: vec![], + cc: vec![], }; let sends = SendActivityTask::prepare( diff --git a/crates/adapters/activitypub-base/src/tests/actors.rs b/crates/adapters/activitypub-base/src/tests/actors.rs index 2876ace..b5ceca4 100644 --- a/crates/adapters/activitypub-base/src/tests/actors.rs +++ b/crates/adapters/activitypub-base/src/tests/actors.rs @@ -23,11 +23,21 @@ fn person_serializes_with_enriched_fields() { }), url: Some("https://example.com/u/alice".parse().unwrap()), discoverable: Some(true), - manually_approves_followers: false, + manually_approves_followers: true, + updated: Some(Utc::now()), + endpoints: Some(Endpoints { + shared_inbox: "https://example.com/inbox".parse().unwrap(), + }), + image: None, + also_known_as: vec![], + attachment: vec![], }; let json = serde_json::to_value(&person).unwrap(); assert_eq!(json["discoverable"], true); assert_eq!(json["summary"], "Bio text"); assert_eq!(json["icon"]["type"], "Image"); - assert!(json.get("manuallyApprovesFollowers").is_some()); + assert_eq!(json["manuallyApprovesFollowers"], true); + assert!(json.get("updated").is_some()); + assert!(json.get("endpoints").is_some()); + assert_eq!(json["endpoints"]["sharedInbox"], "https://example.com/inbox"); } diff --git a/crates/adapters/activitypub-base/src/urls.rs b/crates/adapters/activitypub-base/src/urls.rs index 209fff0..e945e28 100644 --- a/crates/adapters/activitypub-base/src/urls.rs +++ b/crates/adapters/activitypub-base/src/urls.rs @@ -2,6 +2,8 @@ use url::Url; use crate::error::Error; +pub const AS_PUBLIC: &str = "https://www.w3.org/ns/activitystreams#Public"; + pub fn extract_user_id_from_url(url: &Url) -> Option { let path = url.path(); path.strip_prefix("/users/") diff --git a/crates/adapters/activitypub-base/src/user.rs b/crates/adapters/activitypub-base/src/user.rs index 1c00361..75cedd1 100644 --- a/crates/adapters/activitypub-base/src/user.rs +++ b/crates/adapters/activitypub-base/src/user.rs @@ -7,7 +7,10 @@ pub struct ApUser { pub username: String, pub bio: Option, pub avatar_url: Option, + pub banner_url: Option, + pub also_known_as: Option, pub profile_url: Option, + pub attachment: Vec, } #[async_trait] diff --git a/crates/adapters/activitypub/src/lib.rs b/crates/adapters/activitypub/src/lib.rs index 1e0babc..2566008 100644 --- a/crates/adapters/activitypub/src/lib.rs +++ b/crates/adapters/activitypub/src/lib.rs @@ -31,6 +31,7 @@ pub async fn wire( review_store: std::sync::Arc, remote_watchlist_repo: std::sync::Arc, user_repo: std::sync::Arc, + profile_fields_repo: std::sync::Arc, movie_repo: std::sync::Arc, review_repo: std::sync::Arc, diary_repo: std::sync::Arc, @@ -52,15 +53,26 @@ pub async fn wire( watchlist: watchlist_handler, }); + let federation_debug = std::env::var("FEDERATION_DEBUG") + .map(|v| v == "true" || v == "1") + .unwrap_or(false); + + if federation_debug { + tracing::warn!( + "federation running in DEBUG mode — PermissiveVerifier active, \ + no URL/signature validation. Do NOT use in production." + ); + } + let concrete = std::sync::Arc::new( ActivityPubService::new( federation_repo, - std::sync::Arc::new(DomainUserRepoAdapter::new(user_repo, base_url.clone())), + std::sync::Arc::new(DomainUserRepoAdapter::new(user_repo, profile_fields_repo, base_url.clone())), composite, base_url.clone(), allow_registration, "movies-diary".to_string(), - cfg!(debug_assertions), + federation_debug, Some(event_publisher), ) .await?, diff --git a/crates/adapters/activitypub/src/objects.rs b/crates/adapters/activitypub/src/objects.rs index 1a18b23..54c00bd 100644 --- a/crates/adapters/activitypub/src/objects.rs +++ b/crates/adapters/activitypub/src/objects.rs @@ -1,3 +1,4 @@ +use activitypub_base::AS_PUBLIC; use activitypub_federation::kinds::object::NoteType; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; @@ -36,6 +37,10 @@ pub struct ReviewObject { pub(crate) watched_at: DateTime, #[serde(default)] pub(crate) tag: Vec, + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub(crate) to: Vec, + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub(crate) cc: Vec, } /// Serialize a local Review into a ReviewObject for AP delivery. @@ -84,7 +89,7 @@ pub fn review_to_ap_object( ReviewObject { kind: NoteType::default(), id: ap_id, - attributed_to: actor_url, + attributed_to: actor_url.clone(), content, published: DateTime::from_naive_utc_and_offset(*review.created_at(), Utc), movie_title, @@ -94,6 +99,8 @@ pub fn review_to_ap_object( comment: comment_text, watched_at: DateTime::from_naive_utc_and_offset(*review.watched_at(), Utc), tag, + to: vec![AS_PUBLIC.to_string()], + cc: vec![format!("{}/followers", actor_url)], } } @@ -119,6 +126,10 @@ pub struct WatchlistObject { /// Non-Movies-Diary apps ignore unknown fields. #[serde(default)] pub(crate) watchlist_entry: bool, + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub(crate) to: Vec, + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub(crate) cc: Vec, } pub fn watchlist_to_ap_object( @@ -156,7 +167,7 @@ pub fn watchlist_to_ap_object( WatchlistObject { kind: NoteType::default(), id: ap_id, - attributed_to: actor_url, + attributed_to: actor_url.clone(), content, published: added_at, movie_title, @@ -165,6 +176,8 @@ pub fn watchlist_to_ap_object( poster_url, tag, watchlist_entry: true, + to: vec![AS_PUBLIC.to_string()], + cc: vec![format!("{}/followers", actor_url)], } } diff --git a/crates/adapters/activitypub/src/tests/objects.rs b/crates/adapters/activitypub/src/tests/objects.rs index 9fda18a..4950f06 100644 --- a/crates/adapters/activitypub/src/tests/objects.rs +++ b/crates/adapters/activitypub/src/tests/objects.rs @@ -39,3 +39,52 @@ fn review_to_ap_object_includes_two_hashtags() { assert!(names.contains(&"#MoviesDiary")); assert!(names.contains(&"#Dune")); } + +#[test] +fn review_to_ap_object_has_public_addressing() { + use chrono::NaiveDateTime; + use domain::{ + models::{Review, ReviewSource}, + value_objects::{MovieId, Rating, ReviewId, UserId}, + }; + + let review = Review::from_persistence( + ReviewId::generate(), + MovieId::from_uuid(uuid::Uuid::new_v4()), + UserId::from_uuid(uuid::Uuid::new_v4()), + Rating::new(3).unwrap(), + None, + NaiveDateTime::parse_from_str("2024-06-01 00:00:00", "%Y-%m-%d %H:%M:%S").unwrap(), + NaiveDateTime::parse_from_str("2024-06-01 00:00:00", "%Y-%m-%d %H:%M:%S").unwrap(), + ReviewSource::Local, + ); + let actor_url: url::Url = "https://example.com/users/abc".parse().unwrap(); + let obj = review_to_ap_object( + &review, + "https://example.com/reviews/1".parse().unwrap(), + actor_url.clone(), + "Dune".to_string(), + 2021, + None, + "https://example.com", + ); + assert_eq!(obj.to, vec!["https://www.w3.org/ns/activitystreams#Public"]); + assert_eq!(obj.cc, vec!["https://example.com/users/abc/followers"]); +} + +#[test] +fn watchlist_to_ap_object_has_public_addressing() { + let actor_url: url::Url = "https://example.com/users/abc".parse().unwrap(); + let obj = watchlist_to_ap_object( + "https://example.com/watchlist/1".parse().unwrap(), + actor_url.clone(), + "Alien".to_string(), + 1979, + None, + None, + chrono::Utc::now(), + "https://example.com", + ); + assert_eq!(obj.to, vec!["https://www.w3.org/ns/activitystreams#Public"]); + assert_eq!(obj.cc, vec!["https://example.com/users/abc/followers"]); +} diff --git a/crates/adapters/activitypub/src/user_adapter.rs b/crates/adapters/activitypub/src/user_adapter.rs index 747a5e4..e3cad15 100644 --- a/crates/adapters/activitypub/src/user_adapter.rs +++ b/crates/adapters/activitypub/src/user_adapter.rs @@ -2,30 +2,45 @@ use std::sync::Arc; use activitypub_base::{ApUser, ApUserRepository}; use async_trait::async_trait; -use domain::{ports::UserRepository, value_objects::UserId}; +use domain::{ + models::ProfileField, + ports::{UserProfileFieldsRepository, UserRepository}, + value_objects::UserId, +}; use url::Url; pub struct DomainUserRepoAdapter { pub repo: Arc, + pub fields_repo: Arc, pub base_url: String, } impl DomainUserRepoAdapter { - pub fn new(repo: Arc, base_url: String) -> Self { - Self { repo, base_url } + pub fn new( + repo: Arc, + fields_repo: Arc, + base_url: String, + ) -> Self { + Self { repo, fields_repo, base_url } } - fn build_user(&self, u: &domain::models::User) -> ApUser { + fn build_user(&self, u: &domain::models::User, fields: Vec) -> ApUser { let avatar_url = u.avatar_path().and_then(|p| { Url::parse(&format!("{}/images/{}", self.base_url, p)).ok() }); + let banner_url = u.banner_path().and_then(|p| { + Url::parse(&format!("{}/images/{}", self.base_url, p)).ok() + }); let profile_url = Url::parse(&format!("{}/u/{}", self.base_url, u.username().value())).ok(); ApUser { id: u.id().value(), username: u.username().value().to_string(), bio: u.bio().map(|s| s.to_string()), avatar_url, + banner_url, + also_known_as: u.also_known_as().map(|s| s.to_string()), profile_url, + attachment: fields, } } } @@ -34,13 +49,23 @@ impl DomainUserRepoAdapter { impl ApUserRepository for DomainUserRepoAdapter { async fn find_by_id(&self, id: uuid::Uuid) -> anyhow::Result> { let user_id = UserId::from_uuid(id); - Ok(self.repo.find_by_id(&user_id).await?.as_ref().map(|u| self.build_user(u))) + let user = match self.repo.find_by_id(&user_id).await? { + Some(u) => u, + None => return Ok(None), + }; + let fields = self.fields_repo.get_fields(&user_id).await.unwrap_or_default(); + Ok(Some(self.build_user(&user, fields))) } async fn find_by_username(&self, username: &str) -> anyhow::Result> { use domain::value_objects::Username; let uname = Username::new(username.to_string()).map_err(|e| anyhow::anyhow!(e.to_string()))?; - Ok(self.repo.find_by_username(&uname).await?.as_ref().map(|u| self.build_user(u))) + let user = match self.repo.find_by_username(&uname).await? { + Some(u) => u, + None => return Ok(None), + }; + let fields = self.fields_repo.get_fields(user.id()).await.unwrap_or_default(); + Ok(Some(self.build_user(&user, fields))) } async fn count_users(&self) -> anyhow::Result { diff --git a/crates/adapters/postgres-federation/src/lib.rs b/crates/adapters/postgres-federation/src/lib.rs index 824734e..cb527da 100644 --- a/crates/adapters/postgres-federation/src/lib.rs +++ b/crates/adapters/postgres-federation/src/lib.rs @@ -127,6 +127,60 @@ impl FederationRepository for PostgresFederationRepository { }).collect()) } + async fn get_followers_page( + &self, + local_user_id: uuid::Uuid, + offset: u32, + limit: usize, + ) -> Result> { + let uid = local_user_id.to_string(); + let limit_i64 = limit as i64; + let offset_i64 = offset as i64; + + let rows = sqlx::query( + "SELECT f.remote_actor_url, f.status, + a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.avatar_url + FROM ap_followers f + LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url + WHERE f.local_user_id = $1 AND f.status = 'accepted' + ORDER BY f.created_at ASC + LIMIT $2 OFFSET $3", + ) + .bind(&uid) + .bind(limit_i64) + .bind(offset_i64) + .fetch_all(&self.pool) + .await?; + + Ok(rows.into_iter().map(|row| { + let url: String = row.get("remote_actor_url"); + let status_str: String = row.get("status"); + let handle: String = row.try_get("handle").unwrap_or_default(); + let inbox_url: String = row.try_get("inbox_url").unwrap_or_default(); + let shared_inbox_url: Option = row.try_get("shared_inbox_url").ok().flatten(); + let display_name: Option = row.try_get("display_name").ok().flatten(); + let avatar_url: Option = row.try_get("avatar_url").ok().flatten(); + Follower { + actor: RemoteActor { + url, handle, inbox_url, shared_inbox_url, display_name, avatar_url, + outbox_url: row.try_get("outbox_url").ok().flatten(), + }, + status: str_to_status(&status_str), + } + }).collect()) + } + + async fn count_followers(&self, local_user_id: uuid::Uuid) -> Result { + let uid = local_user_id.to_string(); + let count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM ap_followers WHERE local_user_id = $1 AND status = 'accepted'", + ) + .bind(&uid) + .fetch_one(&self.pool) + .await?; + Ok(count as usize) + } + async fn update_follower_status( &self, local_user_id: uuid::Uuid, @@ -232,6 +286,41 @@ impl FederationRepository for PostgresFederationRepository { Ok(count as usize) } + async fn get_following_page( + &self, + local_user_id: uuid::Uuid, + offset: u32, + limit: usize, + ) -> Result> { + let uid = local_user_id.to_string(); + let limit_i64 = limit as i64; + let offset_i64 = offset as i64; + + let rows = sqlx::query( + "SELECT a.url, a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.avatar_url + FROM ap_following f + INNER JOIN ap_remote_actors a ON a.url = f.remote_actor_url + WHERE f.local_user_id = $1 AND f.status = 'accepted' + ORDER BY f.created_at ASC + LIMIT $2 OFFSET $3", + ) + .bind(&uid) + .bind(limit_i64) + .bind(offset_i64) + .fetch_all(&self.pool) + .await?; + + Ok(rows.into_iter().map(|row| RemoteActor { + url: row.get("url"), + handle: row.get("handle"), + inbox_url: row.get("inbox_url"), + shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(), + display_name: row.try_get("display_name").ok().flatten(), + avatar_url: row.try_get("avatar_url").ok().flatten(), + outbox_url: row.try_get("outbox_url").ok().flatten(), + }).collect()) + } + async fn upsert_remote_actor(&self, actor: RemoteActor) -> Result<()> { let now = Utc::now().naive_utc(); let fetched_at = datetime_to_str(&now); diff --git a/crates/adapters/postgres/migrations/0019_profile_extended.sql b/crates/adapters/postgres/migrations/0019_profile_extended.sql new file mode 100644 index 0000000..b2fa2b7 --- /dev/null +++ b/crates/adapters/postgres/migrations/0019_profile_extended.sql @@ -0,0 +1,13 @@ +ALTER TABLE users ADD COLUMN IF NOT EXISTS banner_path TEXT; +ALTER TABLE users ADD COLUMN IF NOT EXISTS also_known_as TEXT; + +CREATE TABLE IF NOT EXISTS user_profile_fields ( + id TEXT NOT NULL PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + value TEXT NOT NULL, + position INTEGER NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_user_profile_fields_user_id + ON user_profile_fields(user_id); diff --git a/crates/adapters/postgres/src/lib.rs b/crates/adapters/postgres/src/lib.rs index d7369cb..e313381 100644 --- a/crates/adapters/postgres/src/lib.rs +++ b/crates/adapters/postgres/src/lib.rs @@ -18,6 +18,7 @@ mod import_session; mod models; mod persons; mod profile; +mod profile_fields; mod users; mod watchlist; @@ -31,6 +32,7 @@ pub use import_profile::PostgresImportProfileRepository; pub use import_session::PostgresImportSessionRepository; pub use persons::{PostgresPersonAdapter, create_person_adapter}; pub use profile::PostgresMovieProfileRepository; +pub use profile_fields::PostgresProfileFieldsRepository; pub use users::PostgresUserRepository; pub use watchlist::PostgresWatchlistRepository; @@ -931,6 +933,12 @@ impl StatsRepository for PostgresRepository { } } +pub fn create_profile_fields_repo( + pool: sqlx::PgPool, +) -> std::sync::Arc { + std::sync::Arc::new(profile_fields::PostgresProfileFieldsRepository::new(pool)) +} + pub async fn wire(database_url: &str) -> anyhow::Result<( sqlx::PgPool, std::sync::Arc, diff --git a/crates/adapters/postgres/src/profile_fields.rs b/crates/adapters/postgres/src/profile_fields.rs new file mode 100644 index 0000000..2994edc --- /dev/null +++ b/crates/adapters/postgres/src/profile_fields.rs @@ -0,0 +1,76 @@ +use async_trait::async_trait; +use sqlx::PgPool; + +use domain::{ + errors::DomainError, + models::ProfileField, + ports::UserProfileFieldsRepository, + value_objects::UserId, +}; + +pub struct PostgresProfileFieldsRepository { + pool: PgPool, +} + +impl PostgresProfileFieldsRepository { + pub fn new(pool: PgPool) -> Self { + Self { pool } + } +} + +#[async_trait] +impl UserProfileFieldsRepository for PostgresProfileFieldsRepository { + async fn get_fields(&self, user_id: &UserId) -> Result, DomainError> { + let id_str = user_id.value().to_string(); + #[derive(sqlx::FromRow)] + struct Row { + name: String, + value: String, + } + let rows = sqlx::query_as::<_, Row>( + "SELECT name, value FROM user_profile_fields WHERE user_id = $1 ORDER BY position ASC", + ) + .bind(&id_str) + .fetch_all(&self.pool) + .await + .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; + Ok(rows + .into_iter() + .map(|r| ProfileField { + name: r.name, + value: r.value, + }) + .collect()) + } + + async fn set_fields( + &self, + user_id: &UserId, + fields: Vec, + ) -> Result<(), DomainError> { + let id_str = user_id.value().to_string(); + + sqlx::query("DELETE FROM user_profile_fields WHERE user_id = $1") + .bind(&id_str) + .execute(&self.pool) + .await + .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; + + for (i, field) in fields.into_iter().enumerate() { + let id = uuid::Uuid::new_v4().to_string(); + let position = i as i64; + sqlx::query( + "INSERT INTO user_profile_fields (id, user_id, name, value, position) VALUES ($1, $2, $3, $4, $5)", + ) + .bind(&id) + .bind(&id_str) + .bind(&field.name) + .bind(&field.value) + .bind(position) + .execute(&self.pool) + .await + .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; + } + Ok(()) + } +} diff --git a/crates/adapters/postgres/src/users.rs b/crates/adapters/postgres/src/users.rs index 722deb1..1500b39 100644 --- a/crates/adapters/postgres/src/users.rs +++ b/crates/adapters/postgres/src/users.rs @@ -40,6 +40,8 @@ impl PostgresUserRepository { role: UserRole, bio: Option, avatar_path: Option, + banner_path: Option, + also_known_as: Option, ) -> Result { let id = uuid::Uuid::parse_str(&id_str) .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; @@ -57,6 +59,8 @@ impl PostgresUserRepository { role, bio, avatar_path, + banner_path, + also_known_as, )) } } @@ -74,9 +78,11 @@ impl UserRepository for PostgresUserRepository { role: String, bio: Option, avatar_path: Option, + banner_path: Option, + also_known_as: Option, } let row = sqlx::query_as::<_, Row>( - "SELECT id, email, username, password_hash, role, bio, avatar_path FROM users WHERE email = $1", + "SELECT id, email, username, password_hash, role, bio, avatar_path, banner_path, also_known_as FROM users WHERE email = $1", ) .bind(email_str) .fetch_optional(&self.pool) @@ -91,6 +97,8 @@ impl UserRepository for PostgresUserRepository { Self::parse_role(&r.role), r.bio, r.avatar_path, + r.banner_path, + r.also_known_as, ) }) .transpose() @@ -107,9 +115,11 @@ impl UserRepository for PostgresUserRepository { role: String, bio: Option, avatar_path: Option, + banner_path: Option, + also_known_as: Option, } let row = sqlx::query_as::<_, Row>( - "SELECT id, email, username, password_hash, role, bio, avatar_path FROM users WHERE username = $1", + "SELECT id, email, username, password_hash, role, bio, avatar_path, banner_path, also_known_as FROM users WHERE username = $1", ) .bind(username_str) .fetch_optional(&self.pool) @@ -124,6 +134,8 @@ impl UserRepository for PostgresUserRepository { Self::parse_role(&r.role), r.bio, r.avatar_path, + r.banner_path, + r.also_known_as, ) }) .transpose() @@ -178,9 +190,11 @@ impl UserRepository for PostgresUserRepository { role: String, bio: Option, avatar_path: Option, + banner_path: Option, + also_known_as: Option, } let row = sqlx::query_as::<_, Row>( - "SELECT id, email, username, password_hash, role, bio, avatar_path FROM users WHERE id = $1", + "SELECT id, email, username, password_hash, role, bio, avatar_path, banner_path, also_known_as FROM users WHERE id = $1", ) .bind(&id_str) .fetch_optional(&self.pool) @@ -195,6 +209,8 @@ impl UserRepository for PostgresUserRepository { Self::parse_role(&r.role), r.bio, r.avatar_path, + r.banner_path, + r.also_known_as, ) }) .transpose() @@ -205,15 +221,21 @@ impl UserRepository for PostgresUserRepository { user_id: &UserId, bio: Option, avatar_path: Option, + banner_path: Option, + also_known_as: Option, ) -> Result<(), DomainError> { let id_str = user_id.value().to_string(); - sqlx::query("UPDATE users SET bio = $1, avatar_path = $2 WHERE id = $3") - .bind(&bio) - .bind(&avatar_path) - .bind(&id_str) - .execute(&self.pool) - .await - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; + sqlx::query( + "UPDATE users SET bio = $1, avatar_path = $2, banner_path = $3, also_known_as = $4 WHERE id = $5", + ) + .bind(&bio) + .bind(&avatar_path) + .bind(&banner_path) + .bind(&also_known_as) + .bind(&id_str) + .execute(&self.pool) + .await + .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; Ok(()) } diff --git a/crates/adapters/sqlite-federation/src/lib.rs b/crates/adapters/sqlite-federation/src/lib.rs index 17ff876..a5a2291 100644 --- a/crates/adapters/sqlite-federation/src/lib.rs +++ b/crates/adapters/sqlite-federation/src/lib.rs @@ -146,6 +146,68 @@ impl FederationRepository for SqliteFederationRepository { Ok(followers) } + async fn get_followers_page( + &self, + local_user_id: uuid::Uuid, + offset: u32, + limit: usize, + ) -> Result> { + let uid = local_user_id.to_string(); + let limit_i64 = limit as i64; + let offset_i64 = offset as i64; + + let rows = sqlx::query( + "SELECT f.remote_actor_url, f.status, + a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.avatar_url + 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 = 'accepted' + ORDER BY f.created_at ASC + LIMIT ? OFFSET ?", + ) + .bind(&uid) + .bind(limit_i64) + .bind(offset_i64) + .fetch_all(&self.pool) + .await?; + + Ok(rows + .into_iter() + .map(|row| { + let url: String = row.get("remote_actor_url"); + let status_str: String = row.get("status"); + let handle: String = row.try_get("handle").unwrap_or_default(); + let inbox_url: String = row.try_get("inbox_url").unwrap_or_default(); + let shared_inbox_url: Option = row.try_get("shared_inbox_url").ok().flatten(); + let display_name: Option = row.try_get("display_name").ok().flatten(); + let avatar_url: Option = row.try_get("avatar_url").ok().flatten(); + Follower { + actor: RemoteActor { + url, + handle, + inbox_url, + shared_inbox_url, + display_name, + avatar_url, + outbox_url: row.try_get("outbox_url").ok().flatten(), + }, + status: str_to_status(&status_str), + } + }) + .collect()) + } + + async fn count_followers(&self, local_user_id: uuid::Uuid) -> Result { + let uid = local_user_id.to_string(); + let count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM ap_followers WHERE local_user_id = ? AND status = 'accepted'", + ) + .bind(&uid) + .fetch_one(&self.pool) + .await?; + Ok(count as usize) + } + async fn update_follower_status( &self, local_user_id: uuid::Uuid, @@ -261,6 +323,44 @@ impl FederationRepository for SqliteFederationRepository { Ok(count as usize) } + async fn get_following_page( + &self, + local_user_id: uuid::Uuid, + offset: u32, + limit: usize, + ) -> Result> { + let uid = local_user_id.to_string(); + let limit_i64 = limit as i64; + let offset_i64 = offset as i64; + + let rows = sqlx::query( + "SELECT a.url, a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.avatar_url + FROM ap_following f + INNER JOIN ap_remote_actors a ON a.url = f.remote_actor_url + WHERE f.local_user_id = ? AND f.status = 'accepted' + ORDER BY f.created_at ASC + LIMIT ? OFFSET ?", + ) + .bind(&uid) + .bind(limit_i64) + .bind(offset_i64) + .fetch_all(&self.pool) + .await?; + + Ok(rows + .into_iter() + .map(|row| RemoteActor { + url: row.get("url"), + handle: row.get("handle"), + inbox_url: row.get("inbox_url"), + shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(), + display_name: row.try_get("display_name").ok().flatten(), + avatar_url: row.try_get("avatar_url").ok().flatten(), + outbox_url: row.try_get("outbox_url").ok().flatten(), + }) + .collect()) + } + async fn upsert_remote_actor(&self, actor: RemoteActor) -> Result<()> { let now = Utc::now().naive_utc(); let fetched_at = datetime_to_str(&now); diff --git a/crates/adapters/sqlite/.sqlx/query-11f7dd8da277aaf950e2a428f8e072cde8d806ca5b4007bbc882aada5c46ae63.json b/crates/adapters/sqlite/.sqlx/query-11f7dd8da277aaf950e2a428f8e072cde8d806ca5b4007bbc882aada5c46ae63.json new file mode 100644 index 0000000..7f98aab --- /dev/null +++ b/crates/adapters/sqlite/.sqlx/query-11f7dd8da277aaf950e2a428f8e072cde8d806ca5b4007bbc882aada5c46ae63.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM user_profile_fields WHERE user_id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "11f7dd8da277aaf950e2a428f8e072cde8d806ca5b4007bbc882aada5c46ae63" +} diff --git a/crates/adapters/sqlite/.sqlx/query-1dd3efb043635e638f1c3d72923a4ccfb9c9810baee06cfac5ad4af5749e4c6e.json b/crates/adapters/sqlite/.sqlx/query-1dd3efb043635e638f1c3d72923a4ccfb9c9810baee06cfac5ad4af5749e4c6e.json new file mode 100644 index 0000000..148df15 --- /dev/null +++ b/crates/adapters/sqlite/.sqlx/query-1dd3efb043635e638f1c3d72923a4ccfb9c9810baee06cfac5ad4af5749e4c6e.json @@ -0,0 +1,68 @@ +{ + "db_name": "SQLite", + "query": "SELECT id, email, username, password_hash, role, bio, avatar_path, banner_path, also_known_as 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" + }, + { + "name": "role", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "bio", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "avatar_path", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "banner_path", + "ordinal": 7, + "type_info": "Text" + }, + { + "name": "also_known_as", + "ordinal": 8, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + true, + false, + false, + false, + false, + true, + true, + true, + true + ] + }, + "hash": "1dd3efb043635e638f1c3d72923a4ccfb9c9810baee06cfac5ad4af5749e4c6e" +} diff --git a/crates/adapters/sqlite/.sqlx/query-5bde1c64a1dec54f348058c9d93842676aa3149bdfc4012f3f3318677a56336d.json b/crates/adapters/sqlite/.sqlx/query-5bde1c64a1dec54f348058c9d93842676aa3149bdfc4012f3f3318677a56336d.json new file mode 100644 index 0000000..6a1e141 --- /dev/null +++ b/crates/adapters/sqlite/.sqlx/query-5bde1c64a1dec54f348058c9d93842676aa3149bdfc4012f3f3318677a56336d.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO user_profile_fields (id, user_id, name, value, position) VALUES (?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 5 + }, + "nullable": [] + }, + "hash": "5bde1c64a1dec54f348058c9d93842676aa3149bdfc4012f3f3318677a56336d" +} diff --git a/crates/adapters/sqlite/.sqlx/query-5e447e9558515934d8f0c08e91342c0df0b29101223f370a126fb0ee76e3b9bd.json b/crates/adapters/sqlite/.sqlx/query-5e447e9558515934d8f0c08e91342c0df0b29101223f370a126fb0ee76e3b9bd.json new file mode 100644 index 0000000..b852564 --- /dev/null +++ b/crates/adapters/sqlite/.sqlx/query-5e447e9558515934d8f0c08e91342c0df0b29101223f370a126fb0ee76e3b9bd.json @@ -0,0 +1,26 @@ +{ + "db_name": "SQLite", + "query": "SELECT name, value FROM user_profile_fields WHERE user_id = ? ORDER BY position ASC", + "describe": { + "columns": [ + { + "name": "name", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "value", + "ordinal": 1, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false + ] + }, + "hash": "5e447e9558515934d8f0c08e91342c0df0b29101223f370a126fb0ee76e3b9bd" +} diff --git a/crates/adapters/sqlite/.sqlx/query-7cb37c7e3df2a945859e12a186a479b9f9f431691d5f0e4ee460cd559f5412b4.json b/crates/adapters/sqlite/.sqlx/query-7cb37c7e3df2a945859e12a186a479b9f9f431691d5f0e4ee460cd559f5412b4.json new file mode 100644 index 0000000..5da35a0 --- /dev/null +++ b/crates/adapters/sqlite/.sqlx/query-7cb37c7e3df2a945859e12a186a479b9f9f431691d5f0e4ee460cd559f5412b4.json @@ -0,0 +1,68 @@ +{ + "db_name": "SQLite", + "query": "SELECT id, email, username, password_hash, role, bio, avatar_path, banner_path, also_known_as FROM users WHERE email = ?", + "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" + }, + { + "name": "role", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "bio", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "avatar_path", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "banner_path", + "ordinal": 7, + "type_info": "Text" + }, + { + "name": "also_known_as", + "ordinal": 8, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + true, + false, + false, + false, + false, + true, + true, + true, + true + ] + }, + "hash": "7cb37c7e3df2a945859e12a186a479b9f9f431691d5f0e4ee460cd559f5412b4" +} diff --git a/crates/adapters/sqlite/.sqlx/query-d6c6b579a18fb106e62148f5f85b8071fceefea51909ace939ae1d09c4597c43.json b/crates/adapters/sqlite/.sqlx/query-e6413dcabae4a72628a2abf33a8b65da6f95b7c3c015f2633fcf00c045b9f08b.json similarity index 72% rename from crates/adapters/sqlite/.sqlx/query-d6c6b579a18fb106e62148f5f85b8071fceefea51909ace939ae1d09c4597c43.json rename to crates/adapters/sqlite/.sqlx/query-e6413dcabae4a72628a2abf33a8b65da6f95b7c3c015f2633fcf00c045b9f08b.json index bb1a126..a69615c 100644 --- a/crates/adapters/sqlite/.sqlx/query-d6c6b579a18fb106e62148f5f85b8071fceefea51909ace939ae1d09c4597c43.json +++ b/crates/adapters/sqlite/.sqlx/query-e6413dcabae4a72628a2abf33a8b65da6f95b7c3c015f2633fcf00c045b9f08b.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT id, email, username, password_hash, role, bio, avatar_path FROM users WHERE id = ?", + "query": "SELECT id, email, username, password_hash, role, bio, avatar_path, banner_path, also_known_as FROM users WHERE id = ?", "describe": { "columns": [ { @@ -37,6 +37,16 @@ "name": "avatar_path", "ordinal": 6, "type_info": "Text" + }, + { + "name": "banner_path", + "ordinal": 7, + "type_info": "Text" + }, + { + "name": "also_known_as", + "ordinal": 8, + "type_info": "Text" } ], "parameters": { @@ -49,8 +59,10 @@ false, false, true, + true, + true, true ] }, - "hash": "d6c6b579a18fb106e62148f5f85b8071fceefea51909ace939ae1d09c4597c43" + "hash": "e6413dcabae4a72628a2abf33a8b65da6f95b7c3c015f2633fcf00c045b9f08b" } diff --git a/crates/adapters/sqlite/migrations/0019_profile_extended.sql b/crates/adapters/sqlite/migrations/0019_profile_extended.sql new file mode 100644 index 0000000..df05bee --- /dev/null +++ b/crates/adapters/sqlite/migrations/0019_profile_extended.sql @@ -0,0 +1,13 @@ +ALTER TABLE users ADD COLUMN banner_path TEXT; +ALTER TABLE users ADD COLUMN also_known_as TEXT; + +CREATE TABLE IF NOT EXISTS user_profile_fields ( + id TEXT NOT NULL PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + value TEXT NOT NULL, + position INTEGER NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_user_profile_fields_user_id + ON user_profile_fields(user_id); diff --git a/crates/adapters/sqlite/src/lib.rs b/crates/adapters/sqlite/src/lib.rs index c470588..6240c16 100644 --- a/crates/adapters/sqlite/src/lib.rs +++ b/crates/adapters/sqlite/src/lib.rs @@ -19,6 +19,7 @@ mod migrations; mod models; mod persons; mod profile; +mod profile_fields; mod users; mod watchlist; @@ -32,9 +33,16 @@ pub use import_profile::SqliteImportProfileRepository; pub use import_session::SqliteImportSessionRepository; pub use persons::{SqlitePersonAdapter, create_person_adapter}; pub use profile::SqliteMovieProfileRepository; +pub use profile_fields::SqliteProfileFieldsRepository; pub use users::SqliteUserRepository; pub use watchlist::SqliteWatchlistRepository; +pub fn create_profile_fields_repo( + pool: sqlx::SqlitePool, +) -> std::sync::Arc { + std::sync::Arc::new(SqliteProfileFieldsRepository::new(pool)) +} + fn format_year_month(ym: &str) -> String { let parts: Vec<&str> = ym.splitn(2, '-').collect(); if parts.len() != 2 { diff --git a/crates/adapters/sqlite/src/profile_fields.rs b/crates/adapters/sqlite/src/profile_fields.rs new file mode 100644 index 0000000..18ac230 --- /dev/null +++ b/crates/adapters/sqlite/src/profile_fields.rs @@ -0,0 +1,58 @@ +use async_trait::async_trait; +use sqlx::SqlitePool; + +use domain::{ + errors::DomainError, + models::ProfileField, + ports::UserProfileFieldsRepository, + value_objects::UserId, +}; + +pub struct SqliteProfileFieldsRepository { + pool: SqlitePool, +} + +impl SqliteProfileFieldsRepository { + pub fn new(pool: SqlitePool) -> Self { + Self { pool } + } +} + +#[async_trait] +impl UserProfileFieldsRepository for SqliteProfileFieldsRepository { + async fn get_fields(&self, user_id: &UserId) -> Result, DomainError> { + let id_str = user_id.value().to_string(); + let rows = sqlx::query!( + "SELECT name, value FROM user_profile_fields WHERE user_id = ? ORDER BY position ASC", + id_str + ) + .fetch_all(&self.pool) + .await + .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; + + Ok(rows.into_iter().map(|r| ProfileField { name: r.name, value: r.value }).collect()) + } + + async fn set_fields(&self, user_id: &UserId, fields: Vec) -> Result<(), DomainError> { + let id_str = user_id.value().to_string(); + + sqlx::query!("DELETE FROM user_profile_fields WHERE user_id = ?", id_str) + .execute(&self.pool) + .await + .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; + + for (i, field) in fields.into_iter().enumerate() { + let id = uuid::Uuid::new_v4().to_string(); + let position = i as i64; + sqlx::query!( + "INSERT INTO user_profile_fields (id, user_id, name, value, position) VALUES (?, ?, ?, ?, ?)", + id, id_str, field.name, field.value, position + ) + .execute(&self.pool) + .await + .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; + } + + Ok(()) + } +} diff --git a/crates/adapters/sqlite/src/tests/users.rs b/crates/adapters/sqlite/src/tests/users.rs index 80c2efe..bbbf32f 100644 --- a/crates/adapters/sqlite/src/tests/users.rs +++ b/crates/adapters/sqlite/src/tests/users.rs @@ -6,7 +6,7 @@ use sqlx::SqlitePool; 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, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, created_at TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'standard', bio TEXT, avatar_path TEXT)" + "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, role TEXT NOT NULL DEFAULT 'standard', bio TEXT, avatar_path TEXT, banner_path TEXT, also_known_as TEXT)" ) .execute(&pool) .await @@ -61,6 +61,8 @@ async fn update_profile_persists_bio_and_avatar() { user.id(), Some("My biography".to_string()), Some("avatars/user1".to_string()), + None, + None, ) .await .unwrap(); @@ -80,10 +82,10 @@ async fn update_profile_clears_fields_with_none() { UserRole::Standard, ); repo.save(&user).await.unwrap(); - repo.update_profile(user.id(), Some("bio".to_string()), Some("path".to_string())) + repo.update_profile(user.id(), Some("bio".to_string()), Some("path".to_string()), None, None) .await .unwrap(); - repo.update_profile(user.id(), None, None).await.unwrap(); + repo.update_profile(user.id(), None, None, None, None).await.unwrap(); let found = repo.find_by_id(user.id()).await.unwrap().unwrap(); assert_eq!(found.bio(), None); diff --git a/crates/adapters/sqlite/src/users.rs b/crates/adapters/sqlite/src/users.rs index fd6abfc..97ff8df 100644 --- a/crates/adapters/sqlite/src/users.rs +++ b/crates/adapters/sqlite/src/users.rs @@ -39,6 +39,8 @@ impl SqliteUserRepository { role: UserRole, bio: Option, avatar_path: Option, + banner_path: Option, + also_known_as: Option, ) -> Result { let id = uuid::Uuid::parse_str(&id_str) .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; @@ -56,6 +58,8 @@ impl SqliteUserRepository { role, bio, avatar_path, + banner_path, + also_known_as, )) } } @@ -65,7 +69,7 @@ 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, username, password_hash, role, bio, avatar_path FROM users WHERE email = ?", + "SELECT id, email, username, password_hash, role, bio, avatar_path, banner_path, also_known_as FROM users WHERE email = ?", email_str ) .fetch_optional(&self.pool) @@ -81,6 +85,8 @@ impl UserRepository for SqliteUserRepository { Self::parse_role(&r.role), r.bio, r.avatar_path, + r.banner_path, + r.also_known_as, ) }) .transpose() @@ -89,7 +95,7 @@ impl UserRepository for SqliteUserRepository { 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, role, bio, avatar_path FROM users WHERE username = ?", + "SELECT id, email, username, password_hash, role, bio, avatar_path, banner_path, also_known_as FROM users WHERE username = ?", username_str ) .fetch_optional(&self.pool) @@ -105,6 +111,8 @@ impl UserRepository for SqliteUserRepository { Self::parse_role(&r.role), r.bio, r.avatar_path, + r.banner_path, + r.also_known_as, ) }) .transpose() @@ -148,7 +156,7 @@ impl UserRepository for SqliteUserRepository { async fn find_by_id(&self, id: &UserId) -> Result, DomainError> { let id_str = id.value().to_string(); let row = sqlx::query!( - "SELECT id, email, username, password_hash, role, bio, avatar_path FROM users WHERE id = ?", + "SELECT id, email, username, password_hash, role, bio, avatar_path, banner_path, also_known_as FROM users WHERE id = ?", id_str ) .fetch_optional(&self.pool) @@ -164,6 +172,8 @@ impl UserRepository for SqliteUserRepository { Self::parse_role(&r.role), r.bio, r.avatar_path, + r.banner_path, + r.also_known_as, ) }) .transpose() @@ -174,15 +184,21 @@ impl UserRepository for SqliteUserRepository { user_id: &UserId, bio: Option, avatar_path: Option, + banner_path: Option, + also_known_as: Option, ) -> Result<(), DomainError> { let id_str = user_id.value().to_string(); - sqlx::query("UPDATE users SET bio = ?, avatar_path = ? WHERE id = ?") - .bind(&bio) - .bind(&avatar_path) - .bind(&id_str) - .execute(&self.pool) - .await - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; + sqlx::query( + "UPDATE users SET bio = ?, avatar_path = ?, banner_path = ?, also_known_as = ? WHERE id = ?", + ) + .bind(&bio) + .bind(&avatar_path) + .bind(&banner_path) + .bind(&also_known_as) + .bind(&id_str) + .execute(&self.pool) + .await + .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; Ok(()) } diff --git a/crates/adapters/template-askama/src/lib.rs b/crates/adapters/template-askama/src/lib.rs index 22894d9..f51e4bd 100644 --- a/crates/adapters/template-askama/src/lib.rs +++ b/crates/adapters/template-askama/src/lib.rs @@ -342,6 +342,9 @@ struct ProfileSettingsTemplate<'a> { ctx: &'a HtmlPageContext, bio: Option<&'a str>, avatar_url: Option<&'a str>, + banner_url: Option<&'a str>, + also_known_as: Option<&'a str>, + profile_fields: &'a [(String, String)], saved: bool, } @@ -703,6 +706,9 @@ impl HtmlRenderer for AskamaHtmlRenderer { ctx: &data.ctx, bio: data.bio.as_deref(), avatar_url: data.avatar_url.as_deref(), + banner_url: data.banner_url.as_deref(), + also_known_as: data.also_known_as.as_deref(), + profile_fields: &data.profile_fields, saved: data.saved, } .render() diff --git a/crates/adapters/template-askama/templates/profile_settings.html b/crates/adapters/template-askama/templates/profile_settings.html index 9ecebec..807531b 100644 --- a/crates/adapters/template-askama/templates/profile_settings.html +++ b/crates/adapters/template-askama/templates/profile_settings.html @@ -6,10 +6,17 @@ {% endif %}
+ + + + {% if let Some(url) = avatar_url %}

Current avatar:

@@ -20,6 +27,30 @@ Avatar image
+ + {% if let Some(url) = banner_url %} +
+

Current banner:

+ Current banner +
+ {% endif %} + + +
+ Profile fields (max 4) + {% for i in 0..4usize %} +
+ + +
+ {% endfor %} +
+ {% endblock %} diff --git a/crates/application/src/commands.rs b/crates/application/src/commands.rs index 2539d7d..9d5649e 100644 --- a/crates/application/src/commands.rs +++ b/crates/application/src/commands.rs @@ -78,6 +78,14 @@ pub struct UpdateProfileCommand { pub bio: Option, pub avatar_bytes: Option>, pub avatar_content_type: Option, + pub banner_bytes: Option>, + pub banner_content_type: Option, + pub also_known_as: Option, +} + +pub struct UpdateProfileFieldsCommand { + pub user_id: Uuid, + pub fields: Vec, } pub struct EnrichMovieCommand { diff --git a/crates/application/src/context.rs b/crates/application/src/context.rs index 5767b36..76585ba 100644 --- a/crates/application/src/context.rs +++ b/crates/application/src/context.rs @@ -6,7 +6,7 @@ use domain::ports::{ ImportProfileRepository, ImportSessionRepository, MetadataClient, MovieProfileRepository, MovieRepository, PasswordHasher, PosterFetcherClient, PersonCommand, PersonQuery, SearchCommand, SearchPort, - ReviewRepository, StatsRepository, UserRepository, + ReviewRepository, StatsRepository, UserProfileFieldsRepository, UserRepository, WatchlistRepository, }; #[cfg(feature = "federation")] @@ -37,6 +37,7 @@ pub struct AppContext { pub search_port: Arc, pub search_command: Arc, pub watchlist_repository: Arc, + pub profile_fields_repository: Arc, #[cfg(feature = "federation")] pub remote_watchlist_repository: Arc, pub config: AppConfig, diff --git a/crates/application/src/ports.rs b/crates/application/src/ports.rs index c65ff6b..118a39f 100644 --- a/crates/application/src/ports.rs +++ b/crates/application/src/ports.rs @@ -177,6 +177,9 @@ pub struct ProfileSettingsPageData { pub ctx: HtmlPageContext, pub bio: Option, pub avatar_url: Option, + pub banner_url: Option, + pub also_known_as: Option, + pub profile_fields: Vec<(String, String)>, pub saved: bool, } diff --git a/crates/application/src/use_cases/mod.rs b/crates/application/src/use_cases/mod.rs index 1d9ae0e..8d9a9f7 100644 --- a/crates/application/src/use_cases/mod.rs +++ b/crates/application/src/use_cases/mod.rs @@ -24,6 +24,7 @@ pub mod register; pub mod search; pub mod sync_poster; pub mod update_profile; +pub mod update_profile_fields; pub mod add_to_watchlist; pub mod remove_from_watchlist; pub mod get_watchlist; diff --git a/crates/application/src/use_cases/update_profile.rs b/crates/application/src/use_cases/update_profile.rs index b4aafd6..500ca3c 100644 --- a/crates/application/src/use_cases/update_profile.rs +++ b/crates/application/src/use_cases/update_profile.rs @@ -15,33 +15,46 @@ pub async fn execute(ctx: &AppContext, cmd: UpdateProfileCommand) -> Result<(), .await? .ok_or_else(|| DomainError::NotFound("User not found".into()))?; + // Handle avatar let new_avatar_path = if let Some(bytes) = cmd.avatar_bytes { let content_type = cmd.avatar_content_type.as_deref().unwrap_or(""); if !["image/jpeg", "image/png", "image/webp"].contains(&content_type) { - return Err(DomainError::ValidationError( - "Avatar must be jpeg, png, or webp".into(), - )); + return Err(DomainError::ValidationError("Avatar must be jpeg, png, or webp".into())); } if let Some(old_path) = user.avatar_path() { let _ = ctx.image_storage.delete(old_path).await; } let key = format!("avatars/{}", user_id.value()); let stored = ctx.image_storage.store(&key, &bytes).await?; - - if let Err(e) = ctx.event_publisher - .publish(&DomainEvent::ImageStored { key: stored.clone() }) - .await - { - tracing::warn!("failed to emit ImageStored for {stored}: {e}"); + if let Err(e) = ctx.event_publisher.publish(&DomainEvent::ImageStored { key: stored.clone() }).await { + tracing::warn!("failed to emit ImageStored for avatar {stored}: {e}"); } - Some(stored) } else { user.avatar_path().map(|s| s.to_string()) }; + // Handle banner + let new_banner_path = if let Some(bytes) = cmd.banner_bytes { + let content_type = cmd.banner_content_type.as_deref().unwrap_or(""); + if !["image/jpeg", "image/png", "image/webp"].contains(&content_type) { + return Err(DomainError::ValidationError("Banner must be jpeg, png, or webp".into())); + } + if let Some(old_path) = user.banner_path() { + let _ = ctx.image_storage.delete(old_path).await; + } + let key = format!("banners/{}", user_id.value()); + let stored = ctx.image_storage.store(&key, &bytes).await?; + if let Err(e) = ctx.event_publisher.publish(&DomainEvent::ImageStored { key: stored.clone() }).await { + tracing::warn!("failed to emit ImageStored for banner {stored}: {e}"); + } + Some(stored) + } else { + user.banner_path().map(|s| s.to_string()) + }; + ctx.user_repository - .update_profile(&user_id, cmd.bio, new_avatar_path) + .update_profile(&user_id, cmd.bio, new_avatar_path, new_banner_path, cmd.also_known_as) .await?; ctx.event_publisher diff --git a/crates/application/src/use_cases/update_profile_fields.rs b/crates/application/src/use_cases/update_profile_fields.rs new file mode 100644 index 0000000..c11c59a --- /dev/null +++ b/crates/application/src/use_cases/update_profile_fields.rs @@ -0,0 +1,17 @@ +use domain::{ + errors::DomainError, + events::DomainEvent, + value_objects::UserId, +}; + +use crate::{commands::UpdateProfileFieldsCommand, context::AppContext}; + +pub async fn execute(ctx: &AppContext, cmd: UpdateProfileFieldsCommand) -> Result<(), DomainError> { + if cmd.fields.len() > 4 { + return Err(DomainError::ValidationError("Maximum 4 profile fields allowed".into())); + } + let user_id = UserId::from_uuid(cmd.user_id); + ctx.profile_fields_repository.set_fields(&user_id, cmd.fields).await?; + ctx.event_publisher.publish(&DomainEvent::UserUpdated { user_id }).await?; + Ok(()) +} diff --git a/crates/domain/src/models/mod.rs b/crates/domain/src/models/mod.rs index ced77b5..2527e95 100644 --- a/crates/domain/src/models/mod.rs +++ b/crates/domain/src/models/mod.rs @@ -306,6 +306,12 @@ pub enum UserRole { Admin, } +#[derive(Debug, Clone)] +pub struct ProfileField { + pub name: String, + pub value: String, +} + #[derive(Clone, Debug)] pub struct User { id: UserId, @@ -315,6 +321,8 @@ pub struct User { role: UserRole, bio: Option, avatar_path: Option, + banner_path: Option, + also_known_as: Option, } impl User { @@ -332,6 +340,8 @@ impl User { role, bio: None, avatar_path: None, + banner_path: None, + also_known_as: None, } } @@ -343,6 +353,8 @@ impl User { role: UserRole, bio: Option, avatar_path: Option, + banner_path: Option, + also_known_as: Option, ) -> Self { Self { id, @@ -352,6 +364,8 @@ impl User { role, bio, avatar_path, + banner_path, + also_known_as, } } @@ -359,9 +373,11 @@ impl User { self.password_hash = new_hash; } - pub fn update_profile(&mut self, bio: Option, avatar_path: Option) { + pub fn update_profile(&mut self, bio: Option, avatar_path: Option, banner_path: Option, also_known_as: Option) { self.bio = bio; self.avatar_path = avatar_path; + self.banner_path = banner_path; + self.also_known_as = also_known_as; } pub fn email(&self) -> &Email { @@ -386,6 +402,14 @@ impl User { pub fn avatar_path(&self) -> Option<&str> { self.avatar_path.as_deref() } + + pub fn banner_path(&self) -> Option<&str> { + self.banner_path.as_deref() + } + + pub fn also_known_as(&self) -> Option<&str> { + self.also_known_as.as_deref() + } } #[derive(Clone, Debug)] diff --git a/crates/domain/src/ports.rs b/crates/domain/src/ports.rs index e907260..51d4b23 100644 --- a/crates/domain/src/ports.rs +++ b/crates/domain/src/ports.rs @@ -188,9 +188,17 @@ pub trait UserRepository: Send + Sync { user_id: &UserId, bio: Option, avatar_path: Option, + banner_path: Option, + also_known_as: Option, ) -> Result<(), DomainError>; } +#[async_trait] +pub trait UserProfileFieldsRepository: Send + Sync { + async fn get_fields(&self, user_id: &UserId) -> Result, DomainError>; + async fn set_fields(&self, user_id: &UserId, fields: Vec) -> Result<(), DomainError>; +} + #[async_trait] pub trait EventPublisher: Send + Sync { async fn publish(&self, event: &DomainEvent) -> Result<(), DomainError>; diff --git a/crates/presentation/src/handlers/api.rs b/crates/presentation/src/handlers/api.rs index 788ae57..7b4f665 100644 --- a/crates/presentation/src/handlers/api.rs +++ b/crates/presentation/src/handlers/api.rs @@ -22,7 +22,7 @@ use application::{ delete_review, export_diary as export_diary_uc, get_activity_feed as get_feed_uc, get_diary, get_movie_social_page, get_movies, get_review_history, get_user_profile as get_user_profile_uc, get_users, log_review, login as login_uc, - register as register_uc, sync_poster, update_profile, + register as register_uc, sync_poster, update_profile, update_profile_fields, search as search_uc, get_person, get_person_credits, add_to_watchlist, remove_from_watchlist, get_watchlist, is_on_watchlist, }, @@ -433,22 +433,30 @@ pub async fn update_profile_handler( let mut bio: Option = None; let mut avatar_bytes: Option> = None; let mut avatar_content_type: Option = None; + let mut banner_bytes: Option> = None; + let mut banner_content_type: Option = None; + let mut also_known_as: Option = None; while let Ok(Some(field)) = multipart.next_field().await { let name = field.name().unwrap_or("").to_string(); match name.as_str() { - "bio" => { + "bio" => { if let Ok(text) = field.text().await { bio = Some(text); } } + "also_known_as" => { if let Ok(text) = field.text().await { - bio = Some(text); + also_known_as = Some(text).filter(|s| !s.is_empty()); } } "avatar" => { - let content_type = field.content_type().map(|s| s.to_string()); - if let Ok(bytes) = field.bytes().await - && !bytes.is_empty() { - avatar_bytes = Some(bytes.to_vec()); - avatar_content_type = content_type; - } + let ct = field.content_type().map(|s| s.to_string()); + if let Ok(bytes) = field.bytes().await { + if !bytes.is_empty() { avatar_bytes = Some(bytes.to_vec()); avatar_content_type = ct; } + } + } + "banner" => { + let ct = field.content_type().map(|s| s.to_string()); + if let Ok(bytes) = field.bytes().await { + if !bytes.is_empty() { banner_bytes = Some(bytes.to_vec()); banner_content_type = ct; } + } } _ => {} } @@ -459,6 +467,9 @@ pub async fn update_profile_handler( bio, avatar_bytes, avatar_content_type, + banner_bytes, + banner_content_type, + also_known_as, }; match update_profile::execute(&state.app_ctx, cmd).await { @@ -474,6 +485,42 @@ pub async fn update_profile_handler( } } +pub async fn update_profile_fields_handler( + State(state): State, + AuthenticatedUser(user_id): AuthenticatedUser, + axum::Json(body): axum::Json, +) -> impl IntoResponse { + let raw_fields = match body.get("fields").and_then(|f| f.as_array()) { + Some(arr) => arr.clone(), + None => return StatusCode::BAD_REQUEST.into_response(), + }; + + let fields: Vec = raw_fields + .iter() + .filter_map(|f| { + let name = f.get("name").and_then(|n| n.as_str())?.to_string(); + let value = f.get("value").and_then(|v| v.as_str())?.to_string(); + Some(domain::models::ProfileField { name, value }) + }) + .collect(); + + let cmd = application::commands::UpdateProfileFieldsCommand { + user_id: user_id.value(), + fields, + }; + + match update_profile_fields::execute(&state.app_ctx, cmd).await { + Ok(()) => StatusCode::NO_CONTENT.into_response(), + Err(domain::errors::DomainError::ValidationError(msg)) => { + (StatusCode::BAD_REQUEST, msg).into_response() + } + Err(e) => { + tracing::error!("update_profile_fields error: {:?}", e); + StatusCode::INTERNAL_SERVER_ERROR.into_response() + } + } +} + fn movie_to_dto(movie: &Movie) -> MovieDto { MovieDto { id: movie.id().value(), diff --git a/crates/presentation/src/handlers/html.rs b/crates/presentation/src/handlers/html.rs index a9a3acf..7e64366 100644 --- a/crates/presentation/src/handlers/html.rs +++ b/crates/presentation/src/handlers/html.rs @@ -28,7 +28,7 @@ use application::{ use_cases::{ add_to_watchlist, delete_review, export_diary as export_diary_uc, get_movie_social_page, get_watchlist, is_on_watchlist, log_review, login as login_uc, register as register_uc, - remove_from_watchlist, update_profile, + remove_from_watchlist, update_profile, update_profile_fields, }, }; use domain::models::ExportFormat; @@ -1249,6 +1249,17 @@ pub async fn get_profile_settings( let avatar_url = user .avatar_path() .map(|path| format!("{}/images/{}", base_url, path)); + let banner_url = user + .banner_path() + .map(|path| format!("{}/images/{}", base_url, path)); + + let profile_fields = state.app_ctx.profile_fields_repository + .get_fields(&user_id) + .await + .unwrap_or_default() + .into_iter() + .map(|f| (f.name, f.value)) + .collect(); let saved = params.saved.as_deref() == Some("1"); @@ -1256,6 +1267,9 @@ pub async fn get_profile_settings( ctx, bio: user.bio().map(|s| s.to_string()), avatar_url, + banner_url, + also_known_as: user.also_known_as().map(|s| s.to_string()), + profile_fields, saved, }; @@ -1430,22 +1444,46 @@ pub async fn post_profile_settings( let mut bio: Option = None; let mut avatar_bytes: Option> = None; let mut avatar_content_type: Option = None; + let mut banner_bytes: Option> = None; + let mut banner_content_type: Option = None; + let mut also_known_as: Option = None; + let mut field_names: std::collections::HashMap = std::collections::HashMap::new(); + let mut field_values: std::collections::HashMap = std::collections::HashMap::new(); while let Ok(Some(field)) = multipart.next_field().await { let name = field.name().unwrap_or("").to_string(); match name.as_str() { - "bio" => { + "bio" => { if let Ok(text) = field.text().await { bio = Some(text); } } + "also_known_as" => { if let Ok(text) = field.text().await { - bio = Some(text); + also_known_as = Some(text).filter(|s| !s.is_empty()); } } "avatar" => { - let content_type = field.content_type().map(|s| s.to_string()); - if let Ok(bytes) = field.bytes().await - && !bytes.is_empty() { - avatar_bytes = Some(bytes.to_vec()); - avatar_content_type = content_type; + let ct = field.content_type().map(|s| s.to_string()); + if let Ok(bytes) = field.bytes().await { + if !bytes.is_empty() { avatar_bytes = Some(bytes.to_vec()); avatar_content_type = ct; } + } + } + "banner" => { + let ct = field.content_type().map(|s| s.to_string()); + if let Ok(bytes) = field.bytes().await { + if !bytes.is_empty() { banner_bytes = Some(bytes.to_vec()); banner_content_type = ct; } + } + } + n if n.starts_with("field_name_") => { + if let Ok(idx) = n["field_name_".len()..].parse::() { + if let Ok(text) = field.text().await { + if !text.is_empty() { field_names.insert(idx, text); } } + } + } + n if n.starts_with("field_value_") => { + if let Ok(idx) = n["field_value_".len()..].parse::() { + if let Ok(text) = field.text().await { + if !text.is_empty() { field_values.insert(idx, text); } + } + } } _ => {} } @@ -1456,9 +1494,26 @@ pub async fn post_profile_settings( bio, avatar_bytes, avatar_content_type, + banner_bytes, + banner_content_type, + also_known_as, }; - let _ = update_profile::execute(&state.app_ctx, cmd).await; + let fields: Vec = (0..4) + .filter_map(|i| { + field_names.get(&i).map(|name| domain::models::ProfileField { + name: name.clone(), + value: field_values.get(&i).cloned().unwrap_or_default(), + }) + }) + .collect(); + + let fields_cmd = application::commands::UpdateProfileFieldsCommand { + user_id: user_id.value(), + fields, + }; + let _ = update_profile_fields::execute(&state.app_ctx, fields_cmd).await; + Redirect::to("/settings/profile?saved=1").into_response() } diff --git a/crates/presentation/src/main.rs b/crates/presentation/src/main.rs index c397643..01b1349 100644 --- a/crates/presentation/src/main.rs +++ b/crates/presentation/src/main.rs @@ -72,6 +72,15 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> { _ => anyhow::bail!("DATABASE_BACKEND={backend} is not supported by this build (sqlite feature is not enabled)"), }; + let profile_fields_repo = match &db_pool { + #[cfg(feature = "postgres")] + DbPool::Postgres(pool) => postgres::create_profile_fields_repo(pool.clone()), + #[cfg(feature = "sqlite")] + DbPool::Sqlite(pool) => sqlite::create_profile_fields_repo(pool.clone()), + #[cfg(not(feature = "sqlite"))] + _ => anyhow::bail!("no profile fields repo for this backend"), + }; + // Wire up event channel, federation service, and ap_router let event_bus = EventBusBackend::from_env()?; @@ -114,6 +123,7 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> { review_store, remote_watchlist_repo.clone(), Arc::clone(&user_repository), + Arc::clone(&profile_fields_repo), Arc::clone(&movie_repository), Arc::clone(&review_repository), Arc::clone(&diary_repository), @@ -173,6 +183,7 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> { import_profile_repository: import_profile_repository as Arc, movie_profile_repository, watchlist_repository, + profile_fields_repository: profile_fields_repo, #[cfg(feature = "federation")] remote_watchlist_repository: remote_watchlist_repo, person_command, diff --git a/crates/presentation/src/routes.rs b/crates/presentation/src/routes.rs index 3ae634d..594d082 100644 --- a/crates/presentation/src/routes.rs +++ b/crates/presentation/src/routes.rs @@ -223,6 +223,7 @@ fn api_routes(rate_limit: u64) -> Router { .route("/import/profiles", routing::get(handlers::import::api_get_profiles).post(handlers::import::api_post_profile)) .route("/import/profiles/{id}", routing::delete(handlers::import::api_delete_profile)) .route("/profile", routing::get(handlers::api::get_profile).put(handlers::api::update_profile_handler)) + .route("/profile/fields", routing::put(handlers::api::update_profile_fields_handler)) .route("/search", routing::get(handlers::api::get_search)) .route("/people/{id}", routing::get(handlers::api::get_person_handler)) .route("/people/{id}/credits", routing::get(handlers::api::get_person_credits_handler)) diff --git a/crates/worker/src/db.rs b/crates/worker/src/db.rs index dd72047..a165791 100644 --- a/crates/worker/src/db.rs +++ b/crates/worker/src/db.rs @@ -4,7 +4,8 @@ use anyhow::Context; use domain::ports::{ DiaryRepository, ImageRefCommand, ImageRefQuery, ImportProfileRepository, ImportSessionRepository, MovieProfileRepository, MovieRepository, PersonCommand, PersonQuery, - ReviewRepository, SearchCommand, SearchPort, StatsRepository, UserRepository, WatchlistRepository, + ReviewRepository, SearchCommand, SearchPort, StatsRepository, UserProfileFieldsRepository, + UserRepository, WatchlistRepository, }; pub enum DbPool { @@ -30,6 +31,7 @@ pub struct Repos { pub person_query: Arc, pub search_command: Arc, pub search_port: Arc, + pub profile_fields: Arc, } pub async fn connect(database_url: &str, backend: &str) -> anyhow::Result<(Repos, DbPool)> { @@ -41,10 +43,12 @@ pub async fn connect(database_url: &str, backend: &str) -> anyhow::Result<(Repos let (image_ref_command, image_ref_query) = postgres::create_image_ref(pool.clone()); let (person_command, person_query) = postgres::create_person_adapter(pool.clone()); let (search_command, search_port) = postgres_search::create_search_adapter(pool.clone()); + let pf = postgres::create_profile_fields_repo(pool.clone()); Ok((Repos { movie: m, review: r, diary: d, stats: s, user: u, import_session: is, import_profile: ip, movie_profile: mp, watchlist: wl, image_ref_command, image_ref_query, - person_command, person_query, search_command, search_port }, + person_command, person_query, search_command, search_port, + profile_fields: pf }, DbPool::Postgres(pool))) } #[cfg(feature = "sqlite")] @@ -54,10 +58,12 @@ pub async fn connect(database_url: &str, backend: &str) -> anyhow::Result<(Repos let (image_ref_command, image_ref_query) = sqlite::create_image_ref(pool.clone()); let (person_command, person_query) = sqlite::create_person_adapter(pool.clone()); let (search_command, search_port) = sqlite_search::create_search_adapter(pool.clone()); + let pf = sqlite::create_profile_fields_repo(pool.clone()); Ok((Repos { movie: m, review: r, diary: d, stats: s, user: u, import_session: is, import_profile: ip, movie_profile: mp, watchlist: wl, image_ref_command, image_ref_query, - person_command, person_query, search_command, search_port }, + person_command, person_query, search_command, search_port, + profile_fields: pf }, DbPool::Sqlite(pool))) } #[cfg(not(feature = "sqlite"))] diff --git a/crates/worker/src/main.rs b/crates/worker/src/main.rs index e25ddc5..de08fe4 100644 --- a/crates/worker/src/main.rs +++ b/crates/worker/src/main.rs @@ -32,20 +32,22 @@ async fn main() -> anyhow::Result<()> { let (repos, db_pool) = db::connect(&database_url, &backend).await?; let (event_publisher_arc, consumer_arc) = event_bus::create(&db_pool).await?; - let image_ref_command = Arc::clone(&repos.image_ref_command); - let image_ref_query = Arc::clone(&repos.image_ref_query); - let person_command = Arc::clone(&repos.person_command); - let person_query = Arc::clone(&repos.person_query); - let search_command = Arc::clone(&repos.search_command); - let search_port = Arc::clone(&repos.search_port); + let image_ref_command = Arc::clone(&repos.image_ref_command); + let image_ref_query = Arc::clone(&repos.image_ref_query); + let person_command = Arc::clone(&repos.person_command); + let person_query = Arc::clone(&repos.person_query); + let search_command = Arc::clone(&repos.search_command); + let search_port = Arc::clone(&repos.search_port); + let profile_fields_repo = Arc::clone(&repos.profile_fields); // Clone refs federation handler needs before ctx consumes them. #[cfg(feature = "federation")] - let (fed_movie_repo, fed_review_repo, fed_diary_repo, fed_user_repo, base_url, allow_registration) = ( + let (fed_movie_repo, fed_review_repo, fed_diary_repo, fed_user_repo, fed_profile_fields_repo, base_url, allow_registration) = ( Arc::clone(&repos.movie), Arc::clone(&repos.review), Arc::clone(&repos.diary), Arc::clone(&repos.user), + Arc::clone(&repos.profile_fields), app_config.base_url.clone(), app_config.allow_registration, ); @@ -76,6 +78,7 @@ async fn main() -> anyhow::Result<()> { import_profile_repository: repos.import_profile, movie_profile_repository: repos.movie_profile, watchlist_repository: repos.watchlist, + profile_fields_repository: Arc::clone(&profile_fields_repo), #[cfg(feature = "federation")] remote_watchlist_repository: fed_remote_watchlist_repo.clone(), person_command: Arc::clone(&person_command), @@ -172,6 +175,7 @@ async fn main() -> anyhow::Result<()> { fed_review_store, fed_remote_watchlist_repo, fed_user_repo, + fed_profile_fields_repo, fed_movie_repo, fed_review_repo, fed_diary_repo, @@ -207,7 +211,7 @@ async fn main() -> anyhow::Result<()> { fn init_tracing() { let filter = std::env::var("RUST_LOG") - .unwrap_or_else(|_| "worker=info,application=info".to_string()); + .unwrap_or_else(|_| "worker=info,application=info,activitypub_base=info".to_string()); tracing_subscriber::registry() .with(tracing_subscriber::EnvFilter::new(filter)) .with(tracing_subscriber::fmt::layer())