From 80f620c8400bd09cb082e219ce827f1baab0d794 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Mon, 11 May 2026 22:59:52 +0200 Subject: [PATCH] feat: image storage generalization, user profile, and federation polish - Replace PosterStorage with generic ImageStorage port (IMAGE_STORAGE_BACKEND/PATH env vars) - Rename poster-storage crate to image-storage; serve at /images/{*key} - Add bio and avatar_path to User model (migration 0009_user_profile) - update_profile use case with avatar upload, mime validation, old avatar cleanup - GET/PUT /api/v1/profile and GET/POST /settings/profile HTML page - Enrich AP Person actor with summary, icon, url, discoverable fields - Store remote actor avatar_url (migration 0010_ap_remote_actor_avatar) - Shared inbox delivery via collect_inboxes deduplication - Broadcast Update(Person) to followers on UserUpdated event - Paginated outbox: OrderedCollection + OrderedCollectionPage with cursor - Announce/boost tracking in ap_announces table (migration 0011_ap_announces) --- Cargo.lock | 32 +-- Cargo.toml | 4 +- README.md | 8 +- .../activitypub-base/src/activities.rs | 54 +++++ .../adapters/activitypub-base/src/actors.rs | 78 ++++++- .../adapters/activitypub-base/src/content.rs | 10 + .../adapters/activitypub-base/src/outbox.rs | 123 +++++++++-- .../activitypub-base/src/repository.rs | 9 + .../adapters/activitypub-base/src/service.rs | 128 ++++++++++- crates/adapters/activitypub-base/src/user.rs | 2 + .../adapters/activitypub/src/event_handler.rs | 7 +- .../activitypub/src/review_handler.rs | 67 ++++++ .../adapters/activitypub/src/user_adapter.rs | 4 + crates/adapters/event-payload/src/lib.rs | 12 ++ .../Cargo.toml | 2 +- .../src/config.rs | 16 +- crates/adapters/image-storage/src/lib.rs | 157 ++++++++++++++ crates/adapters/nats/src/subject.rs | 1 + crates/adapters/poster-storage/src/lib.rs | 204 ------------------ crates/adapters/poster-sync/src/lib.rs | 15 +- .../adapters/postgres-federation/src/lib.rs | 48 ++++- .../postgres/migrations/0009_user_profile.sql | 2 + .../0010_ap_remote_actor_avatar.sql | 1 + .../postgres/migrations/0011_ap_announces.sql | 8 + crates/adapters/postgres/src/users.rs | 39 +++- crates/adapters/sqlite-federation/src/lib.rs | 74 ++++++- ...fc85702d8c39c6301839de7b4d27f4a4d41b.json} | 12 +- ...1ba3b11c9f252311579f7daba335f70d78f07.json | 12 ++ ...9194094e10122ea596e8d9323968e600635a9.json | 98 +++++++++ ...8148a7bef09fb1a3c3ea44d25da32c3908472.json | 56 +++++ ...425e43d09a6df97c335ac347f7cfd61acd171.json | 32 --- ...32d9ea7eabc99d9f1a44694e5d10762606f82.json | 12 -- ...a65ac0b8a001798a2476ea86143c5565df399.json | 32 --- ...55c7e0e86b9f6549ae8ae09247806f1321086.json | 12 ++ ...472031e3231235ca5fe40732d7bdfddc4cbba.json | 56 +++++ ...3adaa4ce73fc037e2bf2a87d60187aeb7361.json} | 12 +- ...3fa7ea30eeb985c4bfd12b0fc3997d1ba03bb.json | 38 ++++ ...4b41a7128a465c0957915c7ed7031c6b83fb0.json | 12 ++ ...85a0002c2b600c34ba4d99f1e1c5e99f35e75.json | 20 ++ ...8225e6e18e6079644b949258a39bf4b0fe3e5.json | 12 -- ...8665b15c14fc0b4f6018e548b76474254073e.json | 12 ++ ...d52957f2f52d8a021cc6f117cb6f241689a07.json | 12 ++ ...5fabd813a2b39652dd7e18f03f5b10a4beaca.json | 44 ++++ ...eb8fec11fe868b940faee5a351795caaa2357.json | 62 ++++++ ...54745b3331b84ce9a2178f7812f1ed7262cc.json} | 12 +- ...0a148d8dc29bbf0df715f895fa4248d83785f.json | 12 ++ ...fc53fef1299fc7c7b52be44f99c2302490a22.json | 26 +++ ...f28e1d55234a242a7ddba50db13cf73b488d.json} | 12 +- ...340e978d31a36be9121da3c59378f2fc1ed8e.json | 26 +++ ...c31fddcf182850782e9bfbc4ff3ee8b7d4bb.json} | 12 +- ...bbeafaf39e2f53611dab435d485648eb7e598.json | 12 ++ ...ef662b169377c5bafdd03583645a39368c910.json | 56 +++++ ...897074c8ff4cdd1d3e9a13ef4b9dd5346d12a.json | 12 ++ ...14d76d76ecc7d2190ffb73d12bec2874111d2.json | 20 ++ ...b8071fceefea51909ace939ae1d09c4597c43.json | 56 +++++ ...ef33ded742ef03df885686cd5198f9f8e1c01.json | 12 ++ ...e3f4c19cb52dcfff66772145cd80c53c16883.json | 12 ++ ...de4a42c12891025236abc48ea4f175292a6cc.json | 12 ++ ...1bd97708b401a6a2290abe6df49fcb7f28a8d.json | 44 ++++ ...bb606fd9ee9884f4457831f693a0df3609317.json | 32 +++ .../sqlite/migrations/0009_user_profile.sql | 2 + .../0010_ap_remote_actor_avatar.sql | 1 + .../sqlite/migrations/0011_ap_announces.sql | 8 + crates/adapters/sqlite/src/federation.rs | 49 ++++- crates/adapters/sqlite/src/users.rs | 81 ++++++- crates/adapters/template-askama/src/lib.rs | 25 ++- .../templates/profile_settings.html | 25 +++ crates/application/src/context.rs | 5 +- crates/application/src/ports.rs | 11 + crates/application/src/use_cases/mod.rs | 1 + .../application/src/use_cases/sync_poster.rs | 9 +- .../src/use_cases/update_profile.rs | 51 +++++ crates/application/src/worker.rs | 1 + crates/domain/src/events.rs | 3 + crates/domain/src/models/mod.rs | 55 +++++ crates/domain/src/ports.rs | 23 +- crates/presentation/Cargo.toml | 2 +- crates/presentation/src/dtos.rs | 7 + crates/presentation/src/extractors.rs | 26 ++- crates/presentation/src/handlers/api.rs | 103 ++++++++- crates/presentation/src/handlers/html.rs | 102 ++++++++- crates/presentation/src/handlers/images.rs | 25 +++ crates/presentation/src/handlers/mod.rs | 2 +- crates/presentation/src/handlers/posters.rs | 33 --- crates/presentation/src/main.rs | 4 +- crates/presentation/src/routes.rs | 18 +- crates/presentation/tests/api_test.rs | 27 ++- crates/worker/Cargo.toml | 2 +- crates/worker/src/main.rs | 10 +- 89 files changed, 2231 insertions(+), 499 deletions(-) rename crates/adapters/{poster-storage => image-storage}/Cargo.toml (92%) rename crates/adapters/{poster-storage => image-storage}/src/config.rs (77%) create mode 100644 crates/adapters/image-storage/src/lib.rs delete mode 100644 crates/adapters/poster-storage/src/lib.rs create mode 100644 crates/adapters/postgres/migrations/0009_user_profile.sql create mode 100644 crates/adapters/postgres/migrations/0010_ap_remote_actor_avatar.sql create mode 100644 crates/adapters/postgres/migrations/0011_ap_announces.sql rename crates/adapters/sqlite/.sqlx/{query-01a08873b7fa815ad98a56a0902b60414cfcdc2c7a8570351320c4bc425347c6.json => query-05d958c1fa38095ae2b5b81ede48fc85702d8c39c6301839de7b4d27f4a4d41b.json} (79%) create mode 100644 crates/adapters/sqlite/.sqlx/query-0acf3515a4d1bef7a2458d878481ba3b11c9f252311579f7daba335f70d78f07.json create mode 100644 crates/adapters/sqlite/.sqlx/query-106b5b65162314c47217c26b7e89194094e10122ea596e8d9323968e600635a9.json create mode 100644 crates/adapters/sqlite/.sqlx/query-1417a8a295bc966637eb7e68e088148a7bef09fb1a3c3ea44d25da32c3908472.json delete mode 100644 crates/adapters/sqlite/.sqlx/query-167481bb1692cc81531d9a5cd85425e43d09a6df97c335ac347f7cfd61acd171.json delete mode 100644 crates/adapters/sqlite/.sqlx/query-18de90feb13b9f467f06d0ce25332d9ea7eabc99d9f1a44694e5d10762606f82.json delete mode 100644 crates/adapters/sqlite/.sqlx/query-1bc5a51762717e45292626052f0a65ac0b8a001798a2476ea86143c5565df399.json create mode 100644 crates/adapters/sqlite/.sqlx/query-1c70d5d455e5ab3dfb68d35ce5255c7e0e86b9f6549ae8ae09247806f1321086.json create mode 100644 crates/adapters/sqlite/.sqlx/query-1edf77b936b825139735e4f92bc472031e3231235ca5fe40732d7bdfddc4cbba.json rename crates/adapters/sqlite/.sqlx/{query-026e2afeb573707cb360fcdab8f6137aabfaf603b5ed57b98ac2888b4a0389ff.json => query-25fd01355c929a83daf2c802b8ae3adaa4ce73fc037e2bf2a87d60187aeb7361.json} (80%) create mode 100644 crates/adapters/sqlite/.sqlx/query-2c7353f34c4748d4d4be6abbf343fa7ea30eeb985c4bfd12b0fc3997d1ba03bb.json create mode 100644 crates/adapters/sqlite/.sqlx/query-34a0a7b5e6a7f2b97b59e9de6a34b41a7128a465c0957915c7ed7031c6b83fb0.json create mode 100644 crates/adapters/sqlite/.sqlx/query-4d85f0ff9732576bba77dc84d3885a0002c2b600c34ba4d99f1e1c5e99f35e75.json delete mode 100644 crates/adapters/sqlite/.sqlx/query-630e092fcd33bc312befef352a98225e6e18e6079644b949258a39bf4b0fe3e5.json create mode 100644 crates/adapters/sqlite/.sqlx/query-6638569b8759b965f8919f4998a8665b15c14fc0b4f6018e548b76474254073e.json create mode 100644 crates/adapters/sqlite/.sqlx/query-759b45ac8426d93d4668e208003d52957f2f52d8a021cc6f117cb6f241689a07.json create mode 100644 crates/adapters/sqlite/.sqlx/query-7fa9ce795286905f4b6c3d5a8975fabd813a2b39652dd7e18f03f5b10a4beaca.json create mode 100644 crates/adapters/sqlite/.sqlx/query-7ff439f22f880f999a72aad1359eb8fec11fe868b940faee5a351795caaa2357.json rename crates/adapters/sqlite/.sqlx/{query-47f7cf95ce3450635b643ab710cadba96f40319140834d510bc5207b2552e055.json => query-8a70f21c39d203867c06dc0bf74a54745b3331b84ce9a2178f7812f1ed7262cc.json} (79%) create mode 100644 crates/adapters/sqlite/.sqlx/query-9024c2b533d0100f18d5a5550220a148d8dc29bbf0df715f895fa4248d83785f.json create mode 100644 crates/adapters/sqlite/.sqlx/query-a01336632a54099e31686a9cbe6fc53fef1299fc7c7b52be44f99c2302490a22.json rename crates/adapters/sqlite/.sqlx/{query-affe1eb261283c09d4b1ce6e684681755f079a044ffec8ff2bd79cfd8efe16b8.json => query-a7c424c26663e4e51b1c563fa977f28e1d55234a242a7ddba50db13cf73b488d.json} (80%) create mode 100644 crates/adapters/sqlite/.sqlx/query-aca9e7aaa32c23b4de3f5048d60340e978d31a36be9121da3c59378f2fc1ed8e.json rename crates/adapters/sqlite/.sqlx/{query-af883f8b78f185077e2d3dcfaa0a6e62fbdfbf00c97c9b33b699dc631476181d.json => query-ae983138ad90fda3794b784fbf62c31fddcf182850782e9bfbc4ff3ee8b7d4bb.json} (76%) create mode 100644 crates/adapters/sqlite/.sqlx/query-bf4aaf630ebefde88d87a95c257bbeafaf39e2f53611dab435d485648eb7e598.json create mode 100644 crates/adapters/sqlite/.sqlx/query-ca7c29f4c80cee4877625a259edef662b169377c5bafdd03583645a39368c910.json create mode 100644 crates/adapters/sqlite/.sqlx/query-cca022ac6275f2b1aaf63a14420897074c8ff4cdd1d3e9a13ef4b9dd5346d12a.json create mode 100644 crates/adapters/sqlite/.sqlx/query-d5d2a81306488a8cee5654cea7e14d76d76ecc7d2190ffb73d12bec2874111d2.json create mode 100644 crates/adapters/sqlite/.sqlx/query-d6c6b579a18fb106e62148f5f85b8071fceefea51909ace939ae1d09c4597c43.json create mode 100644 crates/adapters/sqlite/.sqlx/query-e3a3b56d63df433339bd8183f20ef33ded742ef03df885686cd5198f9f8e1c01.json create mode 100644 crates/adapters/sqlite/.sqlx/query-e431381ad41c1c2f7b9c89509d5e3f4c19cb52dcfff66772145cd80c53c16883.json create mode 100644 crates/adapters/sqlite/.sqlx/query-f84e5483ca4210aec67b38cc1a9de4a42c12891025236abc48ea4f175292a6cc.json create mode 100644 crates/adapters/sqlite/.sqlx/query-f8559923b7a885fb0d4938479f11bd97708b401a6a2290abe6df49fcb7f28a8d.json create mode 100644 crates/adapters/sqlite/.sqlx/query-fdd5b522f26b5e0ce62f76c774fbb606fd9ee9884f4457831f693a0df3609317.json create mode 100644 crates/adapters/sqlite/migrations/0009_user_profile.sql create mode 100644 crates/adapters/sqlite/migrations/0010_ap_remote_actor_avatar.sql create mode 100644 crates/adapters/sqlite/migrations/0011_ap_announces.sql create mode 100644 crates/adapters/template-askama/templates/profile_settings.html create mode 100644 crates/application/src/use_cases/update_profile.rs create mode 100644 crates/presentation/src/handlers/images.rs delete mode 100644 crates/presentation/src/handlers/posters.rs diff --git a/Cargo.lock b/Cargo.lock index 256a7e5..44e40a7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2399,6 +2399,20 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "image-storage" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "domain", + "infer", + "object_store", + "tokio", + "tracing", + "uuid", +] + [[package]] name = "impl-more" version = "0.1.9" @@ -3424,20 +3438,6 @@ dependencies = [ "reqwest 0.13.3", ] -[[package]] -name = "poster-storage" -version = "0.1.0" -dependencies = [ - "anyhow", - "async-trait", - "domain", - "infer", - "object_store", - "tokio", - "tracing", - "uuid", -] - [[package]] name = "poster-sync" version = "0.1.0" @@ -3539,13 +3539,13 @@ dependencies = [ "dotenvy", "export", "http-body-util", + "image-storage", "importer", "infer", "metadata", "nats", "percent-encoding", "poster-fetcher", - "poster-storage", "postgres", "postgres-event-queue", "postgres-federation", @@ -6340,11 +6340,11 @@ dependencies = [ "dotenvy", "export", "futures", + "image-storage", "importer", "metadata", "nats", "poster-fetcher", - "poster-storage", "poster-sync", "postgres", "postgres-event-queue", diff --git a/Cargo.toml b/Cargo.toml index d188865..20d9ab5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = [ "crates/adapters/event-publisher", "crates/adapters/metadata", "crates/adapters/poster-fetcher", - "crates/adapters/poster-storage", + "crates/adapters/image-storage", "crates/adapters/poster-sync", "crates/adapters/rss", "crates/adapters/sqlite", @@ -59,7 +59,7 @@ presentation = { path = "crates/presentation" } auth = { path = "crates/adapters/auth" } metadata = { path = "crates/adapters/metadata" } poster-fetcher = { path = "crates/adapters/poster-fetcher" } -poster-storage = { path = "crates/adapters/poster-storage" } +image-storage = { path = "crates/adapters/image-storage" } poster-sync = { path = "crates/adapters/poster-sync" } event-publisher = { path = "crates/adapters/event-publisher" } rss = { path = "crates/adapters/rss" } diff --git a/README.md b/README.md index 52ce4a0..4e32aab 100644 --- a/README.md +++ b/README.md @@ -76,14 +76,14 @@ OMDB_API_KEY=your-key # Public base URL (used for ActivityPub actor URLs and canonical links) BASE_URL=https://yourdomain.example.com -# Poster storage — pick one backend: +# Image storage — pick one backend: # Option A: local filesystem (zero deps) -POSTER_STORAGE_BACKEND=local -POSTER_STORAGE_PATH=./posters +IMAGE_STORAGE_BACKEND=local +IMAGE_STORAGE_PATH=./images # Option B: S3-compatible (MinIO, AWS S3, etc.) -# POSTER_STORAGE_BACKEND=s3 +# IMAGE_STORAGE_BACKEND=s3 # MINIO_ENDPOINT=http://localhost:9000 # MINIO_BUCKET=posters # MINIO_REGION=minio diff --git a/crates/adapters/activitypub-base/src/activities.rs b/crates/adapters/activitypub-base/src/activities.rs index ed56f3d..a2d50cd 100644 --- a/crates/adapters/activitypub-base/src/activities.rs +++ b/crates/adapters/activitypub-base/src/activities.rs @@ -9,6 +9,10 @@ use activitypub_federation::{ use serde::{Deserialize, Serialize}; use url::Url; +#[derive(Clone, Default, Debug, Serialize, Deserialize)] +#[serde(rename = "Announce")] +pub struct AnnounceType; + use crate::actors::DbActor; use crate::data::FederationData; use crate::error::Error; @@ -330,6 +334,54 @@ impl Activity for UpdateActivity { } } +// --- Announce --- + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AnnounceActivity { + pub(crate) id: Url, + #[serde(rename = "type", default)] + pub(crate) kind: AnnounceType, + pub(crate) actor: ObjectId, + pub(crate) object: Url, + pub(crate) published: Option>, +} + +#[async_trait::async_trait] +impl Activity for AnnounceActivity { + type DataType = FederationData; + type Error = Error; + + fn id(&self) -> &Url { + &self.id + } + + fn actor(&self) -> &Url { + self.actor.inner() + } + + async fn verify(&self, _data: &Data) -> Result<(), Self::Error> { + Ok(()) + } + + async fn receive(self, data: &Data) -> Result<(), Self::Error> { + let object_domain = self.object.host_str().unwrap_or(""); + if object_domain != data.domain { + return Ok(()); + } + data.federation_repo + .add_announce( + self.id.as_str(), + self.object.as_str(), + self.actor.inner().as_str(), + self.published.unwrap_or_else(chrono::Utc::now), + ) + .await?; + tracing::info!(actor = %self.actor.inner(), object = %self.object, "received announce"); + Ok(()) + } +} + // --- Inbox dispatch enum --- #[derive(Debug, Deserialize, Serialize)] @@ -350,4 +402,6 @@ pub enum InboxActivities { Delete(DeleteActivity), #[serde(rename = "Update")] Update(UpdateActivity), + #[serde(rename = "Announce")] + Announce(AnnounceActivity), } diff --git a/crates/adapters/activitypub-base/src/actors.rs b/crates/adapters/activitypub-base/src/actors.rs index 917ac5a..3d5e107 100644 --- a/crates/adapters/activitypub-base/src/actors.rs +++ b/crates/adapters/activitypub-base/src/actors.rs @@ -26,6 +26,15 @@ pub struct DbActor { pub following_url: Url, pub ap_id: Url, pub last_refreshed_at: DateTime, + pub bio: Option, + pub avatar_path: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ApImageObject { + #[serde(rename = "type")] + pub kind: String, + pub url: Url, } #[derive(Debug, Clone, Deserialize, Serialize)] @@ -41,6 +50,15 @@ pub struct Person { following: Url, public_key: PublicKey, name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + summary: Option, + #[serde(skip_serializing_if = "Option::is_none")] + icon: Option, + #[serde(skip_serializing_if = "Option::is_none")] + url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + discoverable: Option, + manually_approves_followers: bool, } pub async fn get_local_actor( @@ -86,6 +104,8 @@ pub async fn get_local_actor( following_url, ap_id, last_refreshed_at: Utc::now(), + bio: user.bio, + avatar_path: user.avatar_path, }) } @@ -143,16 +163,27 @@ impl Object for DbActor { following_url, ap_id, last_refreshed_at: Utc::now(), + bio: None, + avatar_path: None, })) } - 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(), public_key_pem: self.public_key_pem.clone(), }; + let icon = self.avatar_path.as_ref().map(|p| ApImageObject { + kind: "Image".to_string(), + url: Url::parse(&format!("{}/images/{}", data.base_url, p)) + .expect("valid avatar url"), + }); + let profile_url = + Url::parse(&format!("{}/u/{}", data.base_url, self.username)) + .expect("valid profile url"); + Ok(Person { kind: Default::default(), id: self.ap_id.clone().into(), @@ -163,6 +194,11 @@ impl Object for DbActor { following: self.following_url.clone(), public_key, name: Some(self.username.clone()), + summary: self.bio.clone(), + icon, + url: Some(profile_url), + discoverable: Some(true), + manually_approves_followers: false, }) } @@ -182,6 +218,7 @@ impl Object for DbActor { inbox_url: json.inbox.to_string(), shared_inbox_url: None, display_name: json.name.clone(), + avatar_url: json.icon.as_ref().map(|i| i.url.to_string()), }; data.federation_repo.upsert_remote_actor(actor).await?; @@ -204,6 +241,8 @@ impl Object for DbActor { following_url, ap_id, last_refreshed_at: Utc::now(), + bio: None, + avatar_path: None, }) } } @@ -221,3 +260,40 @@ impl Actor for DbActor { self.inbox_url.clone() } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn person_serializes_with_enriched_fields() { + let person = Person { + kind: Default::default(), + id: "https://example.com/users/1".parse::().unwrap().into(), + preferred_username: "alice".to_string(), + inbox: "https://example.com/users/1/inbox".parse().unwrap(), + outbox: "https://example.com/users/1/outbox".parse().unwrap(), + followers: "https://example.com/users/1/followers".parse().unwrap(), + following: "https://example.com/users/1/following".parse().unwrap(), + public_key: PublicKey { + id: "https://example.com/users/1#main-key".to_string(), + owner: "https://example.com/users/1".parse().unwrap(), + public_key_pem: "pem".to_string(), + }, + name: Some("Alice".to_string()), + summary: Some("Bio text".to_string()), + icon: Some(ApImageObject { + kind: "Image".to_string(), + url: "https://example.com/images/avatars/1".parse().unwrap(), + }), + url: Some("https://example.com/u/alice".parse().unwrap()), + discoverable: Some(true), + manually_approves_followers: false, + }; + 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()); + } +} diff --git a/crates/adapters/activitypub-base/src/content.rs b/crates/adapters/activitypub-base/src/content.rs index 69b54b6..4aa93dc 100644 --- a/crates/adapters/activitypub-base/src/content.rs +++ b/crates/adapters/activitypub-base/src/content.rs @@ -1,4 +1,5 @@ use async_trait::async_trait; +use chrono::{DateTime, Utc}; use url::Url; #[async_trait] @@ -10,6 +11,15 @@ pub trait ApObjectHandler: Send + Sync { user_id: uuid::Uuid, ) -> anyhow::Result>; + /// Returns up to `limit` objects ordered newest-first, published before `before`. + /// Returns (ap_id, object_json, published_at). + async fn get_local_objects_page( + &self, + user_id: uuid::Uuid, + before: Option>, + limit: usize, + ) -> anyhow::Result)>>; + /// Incoming Create activity — persist remote content. async fn on_create( &self, diff --git a/crates/adapters/activitypub-base/src/outbox.rs b/crates/adapters/activitypub-base/src/outbox.rs index 1c1b385..b4162aa 100644 --- a/crates/adapters/activitypub-base/src/outbox.rs +++ b/crates/adapters/activitypub-base/src/outbox.rs @@ -1,9 +1,20 @@ -use activitypub_federation::{axum::json::FederationJson, config::Data}; -use axum::extract::Path; +use axum::extract::{Path, Query}; +use axum::response::IntoResponse; +use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; +use url::Url; -use crate::data::FederationData; -use crate::error::Error; +use activitypub_federation::{config::Data, fetch::object_id::ObjectId, kinds::activity::CreateType}; + +use crate::{activities::CreateActivity, data::FederationData, error::Error}; + +const PAGE_SIZE: usize = 20; + +#[derive(Deserialize)] +pub struct OutboxQuery { + page: Option, + before: Option, +} #[derive(Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -14,13 +25,28 @@ pub struct OrderedCollection { kind: String, id: String, total_items: u64, + first: String, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OrderedCollectionPage { + #[serde(rename = "@context")] + context: String, + #[serde(rename = "type")] + kind: String, + id: String, + part_of: String, ordered_items: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + next: Option, } pub async fn outbox_handler( Path(user_id_str): Path, + Query(query): Query, data: Data, -) -> Result, Error> { +) -> Result { let uuid = uuid::Uuid::parse_str(&user_id_str) .map_err(|_| Error::bad_request(anyhow::anyhow!("invalid user id")))?; @@ -30,19 +56,80 @@ pub async fn outbox_handler( .map_err(Error::from)? .ok_or_else(|| Error::not_found(anyhow::anyhow!("user not found")))?; - let objects = data - .object_handler - .get_local_objects_for_user(uuid) - .await - .map_err(|e| Error::from(anyhow::anyhow!(e)))?; - let outbox_url = format!("{}/users/{}/outbox", data.base_url, user_id_str); - Ok(FederationJson(OrderedCollection { - context: "https://www.w3.org/ns/activitystreams".to_string(), - kind: "OrderedCollection".to_string(), - id: outbox_url, - total_items: objects.len() as u64, - ordered_items: vec![], - })) + if query.page.unwrap_or(false) { + let before: Option> = query.before.as_deref().and_then(|s| s.parse().ok()); + + let items = data + .object_handler + .get_local_objects_page(uuid, before, PAGE_SIZE) + .await + .map_err(|e| Error::from(anyhow::anyhow!("{}", e)))?; + + let actor_url: Url = format!("{}/users/{}", data.base_url, user_id_str) + .parse() + .expect("valid url"); + + let has_more = items.len() == PAGE_SIZE; + let oldest_ts = items.last().map(|(_, _, ts)| *ts); + + 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 { + id: create_id, + kind: CreateType::default(), + actor: ObjectId::from(actor_url.clone()), + object, + }) + .expect("serializable") + }) + .collect(); + + let page_id = match &query.before { + Some(b) => format!("{}?page=true&before={}", outbox_url, b), + None => format!("{}?page=true", outbox_url), + }; + + let next = if has_more { + oldest_ts.map(|ts| { + // Use RFC 3339 with Z suffix (no + sign) to avoid percent-encoding + let ts_str = ts + .format("%Y-%m-%dT%H:%M:%S%.3fZ") + .to_string(); + format!("{}?page=true&before={}", outbox_url, ts_str) + }) + } else { + None + }; + + Ok(axum::Json(OrderedCollectionPage { + context: "https://www.w3.org/ns/activitystreams".to_string(), + kind: "OrderedCollectionPage".to_string(), + id: page_id, + part_of: outbox_url, + ordered_items, + next, + }) + .into_response()) + } else { + let total = data + .object_handler + .get_local_objects_for_user(uuid) + .await + .map_err(|e| Error::from(anyhow::anyhow!("{}", e)))? + .len() as u64; + + Ok(axum::Json(OrderedCollection { + context: "https://www.w3.org/ns/activitystreams".to_string(), + kind: "OrderedCollection".to_string(), + id: outbox_url.clone(), + total_items: total, + first: format!("{}?page=true", outbox_url), + }) + .into_response()) + } } diff --git a/crates/adapters/activitypub-base/src/repository.rs b/crates/adapters/activitypub-base/src/repository.rs index f0cabfc..90c9f74 100644 --- a/crates/adapters/activitypub-base/src/repository.rs +++ b/crates/adapters/activitypub-base/src/repository.rs @@ -21,6 +21,7 @@ pub struct RemoteActor { pub inbox_url: String, pub shared_inbox_url: Option, pub display_name: Option, + pub avatar_url: Option, } #[derive(Debug, Clone)] @@ -88,4 +89,12 @@ pub trait FederationRepository: Send + Sync { remote_actor_url: &str, status: FollowingStatus, ) -> Result<()>; + async fn add_announce( + &self, + activity_id: &str, + object_url: &str, + actor_url: &str, + announced_at: chrono::DateTime, + ) -> Result<()>; + async fn count_announces(&self, object_url: &str) -> Result; } diff --git a/crates/adapters/activitypub-base/src/service.rs b/crates/adapters/activitypub-base/src/service.rs index bd4a18a..fb1a919 100644 --- a/crates/adapters/activitypub-base/src/service.rs +++ b/crates/adapters/activitypub-base/src/service.rs @@ -10,7 +10,7 @@ use axum::{Router, routing::get, routing::post}; use url::Url; use crate::{ - activities::{AcceptActivity, CreateActivity, FollowActivity, RejectActivity, UndoActivity}, + activities::{AcceptActivity, CreateActivity, FollowActivity, RejectActivity, UndoActivity, UpdateActivity}, actors::{DbActor, get_local_actor}, content::ApObjectHandler, data::FederationData, @@ -24,6 +24,24 @@ use crate::{ webfinger::webfinger_handler, }; +fn collect_inboxes(followers: &[crate::repository::Follower]) -> Vec { + let mut seen = std::collections::HashSet::new(); + let mut inboxes = Vec::new(); + for f in followers { + let inbox_str = f + .actor + .shared_inbox_url + .as_deref() + .unwrap_or(&f.actor.inbox_url); + if seen.insert(inbox_str.to_string()) { + if let Ok(url) = Url::parse(inbox_str) { + inboxes.push(url); + } + } + } + inboxes +} + pub(crate) async fn send_with_retry( sends: Vec, data: &activitypub_federation::config::Data, @@ -150,6 +168,7 @@ impl ActivityPubService { inbox_url: remote_actor.inbox_url.to_string(), shared_inbox_url: None, display_name: Some(remote_actor.username.clone()), + avatar_url: None, }; data.federation_repo .add_following(local_user_id, remote, &follow_id_str) @@ -289,7 +308,11 @@ impl ActivityPubService { ); } - self.spawn_backfill(local_user_id, remote_actor.inbox_url.clone()); + let target_inbox = remote_actor + .shared_inbox_url + .clone() + .unwrap_or_else(|| remote_actor.inbox_url.clone()); + self.spawn_backfill(local_user_id, target_inbox); Ok(()) } @@ -437,10 +460,7 @@ impl ActivityPubService { }; let create_with_ctx = WithContext::new_default(create); - let inboxes: Vec = accepted - .iter() - .filter_map(|f| Url::parse(&f.actor.inbox_url).ok()) - .collect(); + let inboxes = collect_inboxes(&accepted); let sends = SendActivityTask::prepare(&create_with_ctx, &local_actor, inboxes, &data).await?; @@ -455,6 +475,57 @@ impl ActivityPubService { Ok(()) } + pub async fn broadcast_actor_update(&self, user_id: uuid::Uuid) -> anyhow::Result<()> { + use activitypub_federation::traits::Object; + + let data = self.federation_config.to_request_data(); + let local_actor = get_local_actor(user_id, &data) + .await + .map_err(|e| anyhow::anyhow!("{e}"))?; + + let person = local_actor.clone().into_json(&data).await + .map_err(|e| anyhow::anyhow!("{e}"))?; + let person_json = serde_json::to_value(&person)?; + + let update_id = Url::parse(&format!( + "{}/activities/update/{}", + self.base_url, + uuid::Uuid::new_v4() + ))?; + + let update = UpdateActivity { + id: update_id, + kind: Default::default(), + actor: ObjectId::from(local_actor.ap_id.clone()), + object: person_json, + }; + + let followers = data.federation_repo.get_followers(user_id).await?; + let accepted: Vec<_> = followers + .into_iter() + .filter(|f| f.status == FollowerStatus::Accepted) + .collect(); + + if accepted.is_empty() { + return Ok(()); + } + + let inboxes = collect_inboxes(&accepted); + let sends = SendActivityTask::prepare( + &WithContext::new_default(update), + &local_actor, + inboxes, + &data, + ) + .await?; + + let failures = send_with_retry(sends, &data).await; + if !failures.is_empty() { + tracing::warn!(count = failures.len(), "actor update delivery failures"); + } + Ok(()) + } + async fn follow_local( &self, local_user_id: uuid::Uuid, @@ -493,6 +564,7 @@ impl ActivityPubService { inbox_url: target_inbox_url, shared_inbox_url: None, display_name: Some(target.username), + avatar_url: None, }; data.federation_repo .add_following(local_user_id, target_as_remote, &follow_id) @@ -618,3 +690,47 @@ impl ActivityPubService { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::repository::{Follower, FollowerStatus, RemoteActor}; + + fn make_follower(inbox: &str, shared: Option<&str>) -> Follower { + Follower { + actor: RemoteActor { + url: format!("https://remote/{}", inbox), + handle: "user".to_string(), + inbox_url: inbox.to_string(), + shared_inbox_url: shared.map(|s| s.to_string()), + display_name: None, + avatar_url: None, + }, + status: FollowerStatus::Accepted, + } + } + + #[test] + fn collect_inboxes_deduplicates_shared() { + let followers = vec![ + make_follower("https://mastodon.social/users/a/inbox", Some("https://mastodon.social/inbox")), + make_follower("https://mastodon.social/users/b/inbox", Some("https://mastodon.social/inbox")), + make_follower("https://other.instance/users/c/inbox", None), + ]; + let inboxes = collect_inboxes(&followers); + assert_eq!(inboxes.len(), 2); + let strs: Vec<_> = inboxes.iter().map(|u| u.as_str()).collect(); + assert!(strs.contains(&"https://mastodon.social/inbox")); + assert!(strs.contains(&"https://other.instance/users/c/inbox")); + } + + #[test] + fn collect_inboxes_falls_back_to_individual_inbox() { + let followers = vec![ + make_follower("https://example.com/users/x/inbox", None), + ]; + let inboxes = collect_inboxes(&followers); + assert_eq!(inboxes.len(), 1); + assert_eq!(inboxes[0].as_str(), "https://example.com/users/x/inbox"); + } +} diff --git a/crates/adapters/activitypub-base/src/user.rs b/crates/adapters/activitypub-base/src/user.rs index 3120dfe..2a72147 100644 --- a/crates/adapters/activitypub-base/src/user.rs +++ b/crates/adapters/activitypub-base/src/user.rs @@ -4,6 +4,8 @@ use async_trait::async_trait; pub struct ApUser { pub id: uuid::Uuid, pub username: String, + pub bio: Option, + pub avatar_path: Option, } #[async_trait] diff --git a/crates/adapters/activitypub/src/event_handler.rs b/crates/adapters/activitypub/src/event_handler.rs index 72b053e..5e8a8ae 100644 --- a/crates/adapters/activitypub/src/event_handler.rs +++ b/crates/adapters/activitypub/src/event_handler.rs @@ -46,6 +46,11 @@ impl EventHandler for ActivityPubEventHandler { .on_review_logged(user_id, review_id) .await .map_err(|e| DomainError::InfrastructureError(e.to_string())), + DomainEvent::UserUpdated { user_id } => self + .ap_service + .broadcast_actor_update(user_id.value()) + .await + .map_err(|e| DomainError::InfrastructureError(e.to_string())), _ => Ok(()), } } @@ -78,7 +83,7 @@ impl ActivityPubEventHandler { let poster_url = movie .as_ref() .and_then(|m| m.poster_path()) - .map(|p| format!("{}/posters/{}", self.base_url, p.value())); + .map(|p| format!("{}/images/{}", self.base_url, p.value())); let obj = review_to_ap_object( &review, diff --git a/crates/adapters/activitypub/src/review_handler.rs b/crates/adapters/activitypub/src/review_handler.rs index 629327b..ac81691 100644 --- a/crates/adapters/activitypub/src/review_handler.rs +++ b/crates/adapters/activitypub/src/review_handler.rs @@ -75,6 +75,73 @@ impl ApObjectHandler for ReviewObjectHandler { Ok(results) } + async fn get_local_objects_page( + &self, + user_id: uuid::Uuid, + before: Option>, + limit: usize, + ) -> anyhow::Result)>> { + use domain::value_objects::UserId; + + let domain_user_id = UserId::from_uuid(user_id); + let history = self + .diary_repository + .get_user_history(&domain_user_id) + .await?; + + let mut results = Vec::new(); + for entry in history { + let review = entry.review(); + if !matches!(review.source(), ReviewSource::Local) { + continue; + } + + let published = + chrono::DateTime::from_naive_utc_and_offset(*review.watched_at(), chrono::Utc); + + if let Some(cutoff) = before { + if published >= cutoff { + continue; + } + } + + let ap_id = review_url(&self.base_url, review.id()); + let actor_url = actor_url(&self.base_url, user_id); + + let movie = self + .movie_repository + .get_movie_by_id(review.movie_id()) + .await + .ok() + .flatten(); + let movie_title = movie + .as_ref() + .map(|m| m.title().value().to_string()) + .unwrap_or_else(|| "Unknown".to_string()); + let release_year = movie.as_ref().map(|m| m.release_year().value()).unwrap_or(0); + let poster_url = movie + .as_ref() + .and_then(|m| m.poster_path()) + .map(|p| format!("{}/posters/{}", self.base_url, p.value())); + + let obj = review_to_ap_object( + review, + ap_id.clone(), + actor_url, + movie_title, + release_year, + poster_url, + ); + let json = serde_json::to_value(obj)?; + results.push((ap_id, json, published)); + + if results.len() >= limit { + break; + } + } + Ok(results) + } + async fn on_create( &self, _ap_id: &Url, diff --git a/crates/adapters/activitypub/src/user_adapter.rs b/crates/adapters/activitypub/src/user_adapter.rs index a4e9431..1dd72e4 100644 --- a/crates/adapters/activitypub/src/user_adapter.rs +++ b/crates/adapters/activitypub/src/user_adapter.rs @@ -13,6 +13,8 @@ impl ApUserRepository for DomainUserRepoAdapter { Ok(self.0.find_by_id(&user_id).await?.map(|u| ApUser { id: u.id().value(), username: u.username().value().to_string(), + bio: u.bio().map(|s| s.to_string()), + avatar_path: u.avatar_path().map(|s| s.to_string()), })) } @@ -23,6 +25,8 @@ impl ApUserRepository for DomainUserRepoAdapter { Ok(self.0.find_by_username(&uname).await?.map(|u| ApUser { id: u.id().value(), username: u.username().value().to_string(), + bio: u.bio().map(|s| s.to_string()), + avatar_path: u.avatar_path().map(|s| s.to_string()), })) } } diff --git a/crates/adapters/event-payload/src/lib.rs b/crates/adapters/event-payload/src/lib.rs index 43b0180..edb597f 100644 --- a/crates/adapters/event-payload/src/lib.rs +++ b/crates/adapters/event-payload/src/lib.rs @@ -32,6 +32,9 @@ pub enum EventPayload { movie_id: String, poster_path: Option, }, + UserUpdated { + user_id: String, + }, } impl EventPayload { @@ -41,6 +44,7 @@ impl EventPayload { EventPayload::ReviewUpdated { .. } => "ReviewUpdated", EventPayload::MovieDiscovered { .. } => "MovieDiscovered", EventPayload::MovieDeleted { .. } => "MovieDeleted", + EventPayload::UserUpdated { .. } => "UserUpdated", } } } @@ -87,6 +91,9 @@ impl From<&DomainEvent> for EventPayload { movie_id: movie_id.value().to_string(), poster_path: poster_path.as_ref().map(|p| p.value().to_string()), }, + DomainEvent::UserUpdated { user_id } => EventPayload::UserUpdated { + user_id: user_id.value().to_string(), + }, } } } @@ -127,6 +134,11 @@ impl TryFrom for DomainEvent { .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; Ok(DomainEvent::MovieDeleted { movie_id, poster_path }) } + EventPayload::UserUpdated { user_id } => { + Ok(DomainEvent::UserUpdated { + user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?), + }) + } } } } diff --git a/crates/adapters/poster-storage/Cargo.toml b/crates/adapters/image-storage/Cargo.toml similarity index 92% rename from crates/adapters/poster-storage/Cargo.toml rename to crates/adapters/image-storage/Cargo.toml index 3489e74..a985bd1 100644 --- a/crates/adapters/poster-storage/Cargo.toml +++ b/crates/adapters/image-storage/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "poster-storage" +name = "image-storage" version = "0.1.0" edition = "2024" diff --git a/crates/adapters/poster-storage/src/config.rs b/crates/adapters/image-storage/src/config.rs similarity index 77% rename from crates/adapters/poster-storage/src/config.rs rename to crates/adapters/image-storage/src/config.rs index 5721eaf..9503cef 100644 --- a/crates/adapters/poster-storage/src/config.rs +++ b/crates/adapters/image-storage/src/config.rs @@ -6,8 +6,8 @@ pub struct StorageConfig(Arc); impl StorageConfig { pub fn from_env() -> anyhow::Result { - let backend = std::env::var("POSTER_STORAGE_BACKEND") - .context("POSTER_STORAGE_BACKEND required (valid values: s3, local)")?; + let backend = std::env::var("IMAGE_STORAGE_BACKEND") + .context("IMAGE_STORAGE_BACKEND required (valid values: s3, local)")?; let store: Arc = match backend.as_str() { "s3" => build_s3_store( @@ -19,11 +19,11 @@ impl StorageConfig { &std::env::var("MINIO_REGION").unwrap_or_else(|_| "minio".to_string()), )?, "local" => build_local_store( - &std::env::var("POSTER_STORAGE_PATH") - .context("POSTER_STORAGE_PATH required when POSTER_STORAGE_BACKEND=local")?, + &std::env::var("IMAGE_STORAGE_PATH") + .context("IMAGE_STORAGE_PATH required when IMAGE_STORAGE_BACKEND=local")?, )?, other => { - anyhow::bail!("Unknown POSTER_STORAGE_BACKEND: {other:?}. Valid values: s3, local") + anyhow::bail!("Unknown IMAGE_STORAGE_BACKEND: {other:?}. Valid values: s3, local") } }; @@ -55,7 +55,7 @@ fn build_s3_store( } fn build_local_store(path: &str) -> anyhow::Result> { - std::fs::create_dir_all(path).context("Failed to create poster storage directory")?; + std::fs::create_dir_all(path).context("Failed to create image storage directory")?; let store = LocalFileSystem::new_with_prefix(path) .context("Failed to initialise local file system store")?; Ok(Arc::new(store)) @@ -67,7 +67,7 @@ mod tests { #[test] fn local_store_creates_dir_and_succeeds() { - let dir = std::env::temp_dir().join(format!("poster_test_{}", uuid::Uuid::new_v4())); + let dir = std::env::temp_dir().join(format!("image_test_{}", uuid::Uuid::new_v4())); let result = build_local_store(dir.to_str().unwrap()); assert!(result.is_ok(), "expected Ok, got: {:?}", result.err()); assert!(dir.exists(), "directory should have been created"); @@ -75,7 +75,7 @@ mod tests { #[test] fn local_store_succeeds_if_dir_already_exists() { - let dir = std::env::temp_dir().join(format!("poster_test_{}", uuid::Uuid::new_v4())); + let dir = std::env::temp_dir().join(format!("image_test_{}", uuid::Uuid::new_v4())); std::fs::create_dir_all(&dir).unwrap(); let result = build_local_store(dir.to_str().unwrap()); assert!(result.is_ok()); diff --git a/crates/adapters/image-storage/src/lib.rs b/crates/adapters/image-storage/src/lib.rs new file mode 100644 index 0000000..a3e6a0a --- /dev/null +++ b/crates/adapters/image-storage/src/lib.rs @@ -0,0 +1,157 @@ +mod config; +pub use config::StorageConfig; + +use async_trait::async_trait; +use domain::{ + errors::DomainError, + events::DomainEvent, + ports::{EventHandler, ImageStorage}, +}; +use object_store::{Attribute, Attributes, ObjectStore, PutOptions, path::Path}; +use std::sync::Arc; + +fn detect_mime(bytes: &[u8]) -> &'static str { + infer::get(bytes) + .map(|t| t.mime_type()) + .unwrap_or("application/octet-stream") +} + +pub struct ImageStorageAdapter { + store: Arc, +} + +impl ImageStorageAdapter { + pub fn new(store: Arc) -> Self { + Self { store } + } + + pub fn from_config(config: StorageConfig) -> Self { + Self::new(config.build_store()) + } +} + +#[async_trait] +impl ImageStorage for ImageStorageAdapter { + async fn store(&self, key: &str, image_bytes: &[u8]) -> Result { + let path = Path::from(key); + let mime = detect_mime(image_bytes); + let mut attributes = Attributes::new(); + attributes.insert(Attribute::ContentType, mime.into()); + let opts = PutOptions { attributes, ..Default::default() }; + self.store + .put_opts(&path, image_bytes.to_vec().into(), opts) + .await + .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; + Ok(key.to_string()) + } + + async fn get(&self, key: &str) -> Result, DomainError> { + let path = Path::from(key); + let result = self.store.get(&path).await.map_err(|e| match e { + object_store::Error::NotFound { .. } => DomainError::NotFound("Image not found".into()), + _ => DomainError::InfrastructureError(e.to_string()), + })?; + result + .bytes() + .await + .map(|b| b.to_vec()) + .map_err(|e| DomainError::InfrastructureError(e.to_string())) + } + + async fn delete(&self, key: &str) -> Result<(), DomainError> { + let path = Path::from(key); + match self.store.delete(&path).await { + Ok(()) => Ok(()), + Err(object_store::Error::NotFound { .. }) => Ok(()), + Err(e) => Err(DomainError::InfrastructureError(e.to_string())), + } + } +} + +pub struct ImageCleanupHandler { + image_storage: Arc, +} + +impl ImageCleanupHandler { + pub fn new(image_storage: Arc) -> Self { + Self { image_storage } + } +} + +#[async_trait] +impl EventHandler for ImageCleanupHandler { + async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError> { + let poster_path = match event { + DomainEvent::MovieDeleted { poster_path, .. } => poster_path, + _ => return Ok(()), + }; + let Some(path) = poster_path else { return Ok(()) }; + if let Err(e) = self.image_storage.delete(path.value()).await { + tracing::warn!("image cleanup failed for {}: {e}", path.value()); + } + Ok(()) + } +} + +pub fn create() -> anyhow::Result> { + Ok(Arc::new(ImageStorageAdapter::from_config(StorageConfig::from_env()?))) +} + +#[cfg(test)] +mod tests { + use super::*; + use object_store::memory::InMemory; + + fn adapter() -> ImageStorageAdapter { + ImageStorageAdapter::new(Arc::new(InMemory::new())) + } + + #[tokio::test] + async fn store_and_retrieve_round_trip() { + let adapter = adapter(); + let bytes = b"fake-image-bytes"; + let path = adapter.store("posters/abc123", bytes).await.unwrap(); + assert_eq!(path, "posters/abc123"); + let retrieved = adapter.get("posters/abc123").await.unwrap(); + assert_eq!(retrieved, bytes); + } + + #[tokio::test] + async fn get_missing_returns_not_found() { + let adapter = adapter(); + let result = adapter.get("nonexistent").await; + assert!(matches!(result, Err(DomainError::NotFound(_)))); + } + + #[tokio::test] + async fn delete_removes_key() { + let adapter = adapter(); + adapter.store("avatars/user1", b"img").await.unwrap(); + adapter.delete("avatars/user1").await.unwrap(); + let result = adapter.get("avatars/user1").await; + assert!(matches!(result, Err(DomainError::NotFound(_)))); + } + + #[tokio::test] + async fn delete_missing_returns_ok() { + let adapter = adapter(); + assert!(adapter.delete("does-not-exist").await.is_ok()); + } + + #[tokio::test] + async fn cleanup_handler_deletes_on_movie_deleted() { + use domain::{events::DomainEvent, value_objects::{MovieId, PosterPath}}; + let inner = Arc::new(adapter()); + inner.store("some-uuid", b"img").await.unwrap(); + let path = PosterPath::new("some-uuid".to_string()).unwrap(); + let handler = ImageCleanupHandler::new(Arc::clone(&inner) as Arc); + handler + .handle(&DomainEvent::MovieDeleted { + movie_id: MovieId::from_uuid(uuid::Uuid::new_v4()), + poster_path: Some(path.clone()), + }) + .await + .unwrap(); + assert!(matches!(inner.get("some-uuid").await, Err(DomainError::NotFound(_)))); + } +} diff --git a/crates/adapters/nats/src/subject.rs b/crates/adapters/nats/src/subject.rs index 7887b8a..afaae3d 100644 --- a/crates/adapters/nats/src/subject.rs +++ b/crates/adapters/nats/src/subject.rs @@ -6,6 +6,7 @@ pub fn event_to_subject(prefix: &str, event: &DomainEvent) -> String { DomainEvent::ReviewUpdated { .. } => "review.updated", DomainEvent::MovieDiscovered { .. } => "movie.discovered", DomainEvent::MovieDeleted { .. } => "movie.deleted", + DomainEvent::UserUpdated { .. } => "user.updated", }; format!("{prefix}.{suffix}") } diff --git a/crates/adapters/poster-storage/src/lib.rs b/crates/adapters/poster-storage/src/lib.rs deleted file mode 100644 index c04e5f4..0000000 --- a/crates/adapters/poster-storage/src/lib.rs +++ /dev/null @@ -1,204 +0,0 @@ -mod config; -pub use config::StorageConfig; - -use async_trait::async_trait; -use domain::{ - errors::DomainError, - events::DomainEvent, - ports::{EventHandler, PosterStorage}, - value_objects::{MovieId, PosterPath}, -}; -use object_store::{Attribute, Attributes, ObjectStore, PutOptions, path::Path}; -use std::sync::Arc; - -fn detect_mime(bytes: &[u8]) -> &'static str { - infer::get(bytes) - .map(|t| t.mime_type()) - .unwrap_or("application/octet-stream") -} - -pub struct PosterStorageAdapter { - store: Arc, -} - -impl PosterStorageAdapter { - pub fn new(store: Arc) -> Self { - Self { store } - } - - pub fn from_config(config: StorageConfig) -> Self { - Self::new(config.build_store()) - } -} - -#[async_trait] -impl PosterStorage for PosterStorageAdapter { - async fn store_poster( - &self, - movie_id: &MovieId, - image_bytes: &[u8], - ) -> Result { - let path = Path::from(movie_id.value().to_string()); - let mime = detect_mime(image_bytes); - let mut attributes = Attributes::new(); - attributes.insert(Attribute::ContentType, mime.into()); - let opts = PutOptions { - attributes, - ..Default::default() - }; - self.store - .put_opts(&path, image_bytes.to_vec().into(), opts) - .await - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; - PosterPath::new(path.to_string()) - } - - async fn delete_poster(&self, path: &PosterPath) -> Result<(), DomainError> { - let p = Path::from(path.value().to_string()); - match self.store.delete(&p).await { - Ok(()) => Ok(()), - Err(object_store::Error::NotFound { .. }) => Ok(()), - Err(e) => Err(DomainError::InfrastructureError(e.to_string())), - } - } - - async fn get_poster(&self, poster_path: &PosterPath) -> Result, DomainError> { - let path = Path::from(poster_path.value().to_string()); - let result = self.store.get(&path).await.map_err(|e| match e { - object_store::Error::NotFound { .. } => { - DomainError::NotFound("Poster not found".into()) - } - _ => DomainError::InfrastructureError(e.to_string()), - })?; - result - .bytes() - .await - .map(|b| b.to_vec()) - .map_err(|e| DomainError::InfrastructureError(e.to_string())) - } -} - -pub struct PosterCleanupHandler { - poster_storage: Arc, -} - -impl PosterCleanupHandler { - pub fn new(poster_storage: Arc) -> Self { - Self { poster_storage } - } -} - -#[async_trait] -impl EventHandler for PosterCleanupHandler { - async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError> { - let poster_path = match event { - DomainEvent::MovieDeleted { poster_path, .. } => poster_path, - _ => return Ok(()), - }; - let Some(path) = poster_path else { return Ok(()) }; - if let Err(e) = self.poster_storage.delete_poster(path).await { - tracing::warn!("poster cleanup failed for {}: {e}", path.value()); - } - Ok(()) - } -} - -pub fn create() -> anyhow::Result> { - Ok(std::sync::Arc::new(PosterStorageAdapter::from_config(StorageConfig::from_env()?))) -} - -#[cfg(test)] -mod tests { - use super::*; - use object_store::memory::InMemory; - use uuid::Uuid; - - fn adapter() -> PosterStorageAdapter { - PosterStorageAdapter::new(Arc::new(InMemory::new())) - } - - #[tokio::test] - async fn store_and_retrieve_round_trip() { - let adapter = adapter(); - let movie_id = MovieId::from_uuid(Uuid::new_v4()); - let bytes = b"fake-image-bytes"; - - let path = adapter.store_poster(&movie_id, bytes).await.unwrap(); - let retrieved = adapter.get_poster(&path).await.unwrap(); - - assert_eq!(retrieved, bytes); - } - - #[tokio::test] - async fn get_missing_returns_not_found() { - let adapter = adapter(); - let path = PosterPath::new("nonexistent".into()).unwrap(); - let result = adapter.get_poster(&path).await; - assert!(matches!(result, Err(DomainError::NotFound(_)))); - } - - #[tokio::test] - async fn delete_poster_removes_file() { - let adapter = adapter(); - let movie_id = MovieId::from_uuid(Uuid::new_v4()); - let path = adapter.store_poster(&movie_id, b"img").await.unwrap(); - - adapter.delete_poster(&path).await.unwrap(); - - let result = adapter.get_poster(&path).await; - assert!(matches!(result, Err(DomainError::NotFound(_)))); - } - - #[tokio::test] - async fn delete_poster_missing_file_returns_ok() { - let adapter = adapter(); - let path = PosterPath::new("does-not-exist".into()).unwrap(); - assert!(adapter.delete_poster(&path).await.is_ok()); - } - - #[tokio::test] - async fn cleanup_handler_deletes_poster_on_movie_deleted() { - use domain::{events::DomainEvent, ports::EventHandler}; - - let inner = Arc::new(adapter()); - let path = inner - .store_poster(&MovieId::from_uuid(Uuid::new_v4()), b"img") - .await - .unwrap(); - let movie_id = MovieId::from_uuid(Uuid::new_v4()); - - let handler = PosterCleanupHandler::new(Arc::clone(&inner) as Arc); - handler - .handle(&DomainEvent::MovieDeleted { movie_id, poster_path: Some(path.clone()) }) - .await - .unwrap(); - - assert!(matches!(inner.get_poster(&path).await, Err(DomainError::NotFound(_)))); - } - - #[tokio::test] - async fn cleanup_handler_ignores_none_poster_path() { - use domain::{events::DomainEvent, ports::EventHandler}; - - let inner = Arc::new(adapter()); - let handler = PosterCleanupHandler::new(Arc::clone(&inner) as Arc); - let event = DomainEvent::MovieDeleted { - movie_id: MovieId::from_uuid(Uuid::new_v4()), - poster_path: None, - }; - handler.handle(&event).await.unwrap(); - } - - #[tokio::test] - async fn cleanup_handler_ignores_other_events() { - use domain::{events::DomainEvent, ports::EventHandler, value_objects::ExternalMetadataId}; - - let inner = Arc::new(adapter()); - let handler = PosterCleanupHandler::new(Arc::clone(&inner) as Arc); - let event = DomainEvent::MovieDiscovered { - movie_id: MovieId::from_uuid(Uuid::new_v4()), - external_metadata_id: ExternalMetadataId::new("tt1234567".to_string()).unwrap(), - }; - handler.handle(&event).await.unwrap(); - } -} diff --git a/crates/adapters/poster-sync/src/lib.rs b/crates/adapters/poster-sync/src/lib.rs index 99f3c8b..38e723c 100644 --- a/crates/adapters/poster-sync/src/lib.rs +++ b/crates/adapters/poster-sync/src/lib.rs @@ -4,15 +4,15 @@ use async_trait::async_trait; use domain::{ errors::DomainError, events::DomainEvent, - ports::{EventHandler, MetadataClient, MovieRepository, PosterFetcherClient, PosterStorage}, - value_objects::{ExternalMetadataId, MovieId}, + ports::{EventHandler, ImageStorage, MetadataClient, MovieRepository, PosterFetcherClient}, + value_objects::{ExternalMetadataId, MovieId, PosterPath}, }; pub struct PosterSyncHandler { movie_repository: Arc, metadata_client: Arc, poster_fetcher: Arc, - poster_storage: Arc, + image_storage: Arc, max_retries: u32, } @@ -21,10 +21,10 @@ impl PosterSyncHandler { movie_repository: Arc, metadata_client: Arc, poster_fetcher: Arc, - poster_storage: Arc, + image_storage: Arc, max_retries: u32, ) -> Self { - Self { movie_repository, metadata_client, poster_fetcher, poster_storage, max_retries } + Self { movie_repository, metadata_client, poster_fetcher, image_storage, max_retries } } async fn sync(&self, movie_id: MovieId, external_metadata_id: ExternalMetadataId) -> Result<(), DomainError> { @@ -46,9 +46,10 @@ impl PosterSyncHandler { }; let image_bytes = self.poster_fetcher.fetch_poster_bytes(&poster_url).await?; - let stored_path = self.poster_storage.store_poster(&movie_id, &image_bytes).await?; + let stored_path = self.image_storage.store(&movie_id.value().to_string(), &image_bytes).await?; + let poster_path = PosterPath::new(stored_path)?; - movie.update_poster(stored_path); + movie.update_poster(poster_path); self.movie_repository.upsert_movie(&movie).await } } diff --git a/crates/adapters/postgres-federation/src/lib.rs b/crates/adapters/postgres-federation/src/lib.rs index 2a7d104..8b8eef6 100644 --- a/crates/adapters/postgres-federation/src/lib.rs +++ b/crates/adapters/postgres-federation/src/lib.rs @@ -103,7 +103,7 @@ impl FederationRepository for PostgresFederationRepository { let uid = local_user_id.to_string(); let rows = sqlx::query( "SELECT f.remote_actor_url, f.status, - a.handle, a.inbox_url, a.shared_inbox_url, a.display_name + 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", @@ -118,8 +118,9 @@ impl FederationRepository for PostgresFederationRepository { 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 }, + actor: RemoteActor { url, handle, inbox_url, shared_inbox_url, display_name, avatar_url }, status: str_to_status(&status_str), } }).collect()) @@ -200,7 +201,7 @@ impl FederationRepository for PostgresFederationRepository { async fn get_following(&self, local_user_id: uuid::Uuid) -> Result> { let uid = local_user_id.to_string(); let rows = sqlx::query( - "SELECT a.url, a.handle, a.inbox_url, a.shared_inbox_url, a.display_name + "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'", @@ -214,6 +215,7 @@ impl FederationRepository for PostgresFederationRepository { 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(), }).collect()) } @@ -232,13 +234,14 @@ impl FederationRepository for PostgresFederationRepository { let now = Utc::now().naive_utc(); let fetched_at = datetime_to_str(&now); sqlx::query( - "INSERT INTO ap_remote_actors (url, handle, inbox_url, shared_inbox_url, display_name, fetched_at) - VALUES ($1, $2, $3, $4, $5, $6::timestamptz) + "INSERT INTO ap_remote_actors (url, handle, inbox_url, shared_inbox_url, display_name, avatar_url, fetched_at) + VALUES ($1, $2, $3, $4, $5, $6, $7::timestamptz) ON CONFLICT(url) DO UPDATE SET handle = EXCLUDED.handle, inbox_url = EXCLUDED.inbox_url, shared_inbox_url = EXCLUDED.shared_inbox_url, display_name = EXCLUDED.display_name, + avatar_url = EXCLUDED.avatar_url, fetched_at = EXCLUDED.fetched_at", ) .bind(&actor.url) @@ -246,6 +249,7 @@ impl FederationRepository for PostgresFederationRepository { .bind(&actor.inbox_url) .bind(&actor.shared_inbox_url) .bind(&actor.display_name) + .bind(&actor.avatar_url) .bind(&fetched_at) .execute(&self.pool) .await?; @@ -254,7 +258,7 @@ impl FederationRepository for PostgresFederationRepository { async fn get_remote_actor(&self, actor_url: &str) -> Result> { let row = sqlx::query( - "SELECT url, handle, inbox_url, shared_inbox_url, display_name + "SELECT url, handle, inbox_url, shared_inbox_url, display_name, avatar_url FROM ap_remote_actors WHERE url = $1", ) .bind(actor_url) @@ -266,6 +270,7 @@ impl FederationRepository for PostgresFederationRepository { 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(), })) } @@ -306,7 +311,7 @@ impl FederationRepository for PostgresFederationRepository { async fn get_pending_followers(&self, local_user_id: uuid::Uuid) -> Result> { let uid = local_user_id.to_string(); let rows = sqlx::query( - "SELECT f.remote_actor_url, a.handle, a.inbox_url, a.shared_inbox_url, a.display_name + "SELECT f.remote_actor_url, 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 = 'pending'", @@ -320,6 +325,7 @@ impl FederationRepository for PostgresFederationRepository { inbox_url: row.try_get("inbox_url").unwrap_or_default(), shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(), display_name: row.try_get("display_name").ok().flatten(), + avatar_url: row.try_get("avatar_url").ok().flatten(), }).collect()) } @@ -347,6 +353,34 @@ impl FederationRepository for PostgresFederationRepository { } Ok(()) } + + async fn add_announce( + &self, + activity_id: &str, + object_url: &str, + actor_url: &str, + announced_at: chrono::DateTime, + ) -> Result<()> { + let ts = announced_at.format("%Y-%m-%d %H:%M:%S").to_string(); + sqlx::query( + "INSERT INTO ap_announces (id, object_url, actor_url, announced_at) VALUES ($1, $2, $3, $4) ON CONFLICT (id) DO NOTHING", + ) + .bind(activity_id) + .bind(object_url) + .bind(actor_url) + .bind(&ts) + .execute(&self.pool) + .await?; + Ok(()) + } + + async fn count_announces(&self, object_url: &str) -> Result { + let row = sqlx::query("SELECT COUNT(*) as cnt FROM ap_announces WHERE object_url = $1") + .bind(object_url) + .fetch_one(&self.pool) + .await?; + Ok(row.get::("cnt") as usize) + } } #[async_trait] diff --git a/crates/adapters/postgres/migrations/0009_user_profile.sql b/crates/adapters/postgres/migrations/0009_user_profile.sql new file mode 100644 index 0000000..2d7dcfb --- /dev/null +++ b/crates/adapters/postgres/migrations/0009_user_profile.sql @@ -0,0 +1,2 @@ +ALTER TABLE users ADD COLUMN bio TEXT; +ALTER TABLE users ADD COLUMN avatar_path TEXT; diff --git a/crates/adapters/postgres/migrations/0010_ap_remote_actor_avatar.sql b/crates/adapters/postgres/migrations/0010_ap_remote_actor_avatar.sql new file mode 100644 index 0000000..3662443 --- /dev/null +++ b/crates/adapters/postgres/migrations/0010_ap_remote_actor_avatar.sql @@ -0,0 +1 @@ +ALTER TABLE ap_remote_actors ADD COLUMN avatar_url TEXT; diff --git a/crates/adapters/postgres/migrations/0011_ap_announces.sql b/crates/adapters/postgres/migrations/0011_ap_announces.sql new file mode 100644 index 0000000..2ab1467 --- /dev/null +++ b/crates/adapters/postgres/migrations/0011_ap_announces.sql @@ -0,0 +1,8 @@ +CREATE TABLE ap_announces ( + id TEXT PRIMARY KEY, + object_url TEXT NOT NULL, + actor_url TEXT NOT NULL, + announced_at TEXT NOT NULL +); + +CREATE INDEX idx_ap_announces_object ON ap_announces (object_url); diff --git a/crates/adapters/postgres/src/users.rs b/crates/adapters/postgres/src/users.rs index bb6b9f7..244b60f 100644 --- a/crates/adapters/postgres/src/users.rs +++ b/crates/adapters/postgres/src/users.rs @@ -38,6 +38,8 @@ impl PostgresUserRepository { username_str: String, hash_str: String, role: UserRole, + bio: Option, + avatar_path: Option, ) -> Result { let id = uuid::Uuid::parse_str(&id_str) .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; @@ -53,6 +55,8 @@ impl PostgresUserRepository { username, hash, role, + bio, + avatar_path, )) } } @@ -68,9 +72,11 @@ impl UserRepository for PostgresUserRepository { username: String, password_hash: String, role: String, + bio: Option, + avatar_path: Option, } let row = sqlx::query_as::<_, Row>( - "SELECT id, email, username, password_hash, role FROM users WHERE email = $1", + "SELECT id, email, username, password_hash, role, bio, avatar_path FROM users WHERE email = $1", ) .bind(email_str) .fetch_optional(&self.pool) @@ -83,6 +89,8 @@ impl UserRepository for PostgresUserRepository { r.username, r.password_hash, Self::parse_role(&r.role), + r.bio, + r.avatar_path, ) }) .transpose() @@ -97,9 +105,11 @@ impl UserRepository for PostgresUserRepository { username: String, password_hash: String, role: String, + bio: Option, + avatar_path: Option, } let row = sqlx::query_as::<_, Row>( - "SELECT id, email, username, password_hash, role FROM users WHERE username = $1", + "SELECT id, email, username, password_hash, role, bio, avatar_path FROM users WHERE username = $1", ) .bind(username_str) .fetch_optional(&self.pool) @@ -112,6 +122,8 @@ impl UserRepository for PostgresUserRepository { r.username, r.password_hash, Self::parse_role(&r.role), + r.bio, + r.avatar_path, ) }) .transpose() @@ -164,9 +176,11 @@ impl UserRepository for PostgresUserRepository { username: String, password_hash: String, role: String, + bio: Option, + avatar_path: Option, } let row = sqlx::query_as::<_, Row>( - "SELECT id, email, username, password_hash, role FROM users WHERE id = $1", + "SELECT id, email, username, password_hash, role, bio, avatar_path FROM users WHERE id = $1", ) .bind(&id_str) .fetch_optional(&self.pool) @@ -179,11 +193,30 @@ impl UserRepository for PostgresUserRepository { r.username, r.password_hash, Self::parse_role(&r.role), + r.bio, + r.avatar_path, ) }) .transpose() } + async fn update_profile( + &self, + user_id: &UserId, + bio: Option, + avatar_path: 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()))?; + Ok(()) + } + async fn list_with_stats(&self) -> Result, DomainError> { sqlx::query_as::<_, UserSummaryRow>( r#"SELECT u.id, u.email, diff --git a/crates/adapters/sqlite-federation/src/lib.rs b/crates/adapters/sqlite-federation/src/lib.rs index a23354d..3d77cad 100644 --- a/crates/adapters/sqlite-federation/src/lib.rs +++ b/crates/adapters/sqlite-federation/src/lib.rs @@ -106,7 +106,7 @@ impl FederationRepository for SqliteFederationRepository { let rows = sqlx::query( "SELECT f.remote_actor_url, f.status, - a.handle, a.inbox_url, a.shared_inbox_url, a.display_name + 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 = ?", @@ -125,6 +125,7 @@ impl FederationRepository for SqliteFederationRepository { 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 { @@ -133,6 +134,7 @@ impl FederationRepository for SqliteFederationRepository { inbox_url, shared_inbox_url, display_name, + avatar_url, }, status: str_to_status(&status_str), } @@ -223,7 +225,7 @@ impl FederationRepository for SqliteFederationRepository { let uid = local_user_id.to_string(); let rows = sqlx::query( - "SELECT a.url, a.handle, a.inbox_url, a.shared_inbox_url, a.display_name + "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'", @@ -240,6 +242,7 @@ impl FederationRepository for SqliteFederationRepository { 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(), }) .collect()) } @@ -260,13 +263,14 @@ impl FederationRepository for SqliteFederationRepository { let fetched_at = datetime_to_str(&now); sqlx::query( - "INSERT INTO ap_remote_actors (url, handle, inbox_url, shared_inbox_url, display_name, fetched_at) - VALUES (?, ?, ?, ?, ?, ?) + "INSERT INTO ap_remote_actors (url, handle, inbox_url, shared_inbox_url, display_name, avatar_url, fetched_at) + VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(url) DO UPDATE SET handle = excluded.handle, inbox_url = excluded.inbox_url, shared_inbox_url = excluded.shared_inbox_url, display_name = excluded.display_name, + avatar_url = excluded.avatar_url, fetched_at = excluded.fetched_at", ) .bind(&actor.url) @@ -274,6 +278,7 @@ impl FederationRepository for SqliteFederationRepository { .bind(&actor.inbox_url) .bind(&actor.shared_inbox_url) .bind(&actor.display_name) + .bind(&actor.avatar_url) .bind(&fetched_at) .execute(&self.pool) .await?; @@ -283,7 +288,7 @@ impl FederationRepository for SqliteFederationRepository { async fn get_remote_actor(&self, actor_url: &str) -> Result> { let row = sqlx::query( - "SELECT url, handle, inbox_url, shared_inbox_url, display_name + "SELECT url, handle, inbox_url, shared_inbox_url, display_name, avatar_url FROM ap_remote_actors WHERE url = ?", ) .bind(actor_url) @@ -296,6 +301,7 @@ impl FederationRepository for SqliteFederationRepository { 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(), })) } @@ -344,7 +350,7 @@ impl FederationRepository for SqliteFederationRepository { let rows = sqlx::query( "SELECT f.remote_actor_url, - a.handle, a.inbox_url, a.shared_inbox_url, a.display_name + 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 = 'pending'", @@ -361,6 +367,7 @@ impl FederationRepository for SqliteFederationRepository { inbox_url: row.try_get("inbox_url").unwrap_or_default(), shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(), display_name: row.try_get("display_name").ok().flatten(), + avatar_url: row.try_get("avatar_url").ok().flatten(), }) .collect()) } @@ -392,6 +399,35 @@ impl FederationRepository for SqliteFederationRepository { Ok(()) } + + async fn add_announce( + &self, + activity_id: &str, + object_url: &str, + actor_url: &str, + announced_at: chrono::DateTime, + ) -> Result<()> { + let ts = announced_at.format("%Y-%m-%d %H:%M:%S").to_string(); + sqlx::query( + "INSERT OR IGNORE INTO ap_announces (id, object_url, actor_url, announced_at) + VALUES (?1, ?2, ?3, ?4)", + ) + .bind(activity_id) + .bind(object_url) + .bind(actor_url) + .bind(&ts) + .execute(&self.pool) + .await?; + Ok(()) + } + + async fn count_announces(&self, object_url: &str) -> Result { + let row = sqlx::query("SELECT COUNT(*) as cnt FROM ap_announces WHERE object_url = ?1") + .bind(object_url) + .fetch_one(&self.pool) + .await?; + Ok(row.get::("cnt") as usize) + } } // --- Content-specific repository (movies-diary) --- @@ -553,9 +589,34 @@ pub fn wire(pool: sqlx::SqlitePool) -> ( #[cfg(test)] mod tests { use super::*; + use chrono::Utc; use domain::ports::SocialQueryPort; use sqlx::SqlitePool; + async fn test_pool() -> SqlitePool { + let pool = SqlitePool::connect("sqlite::memory:").await.unwrap(); + sqlx::query("CREATE TABLE ap_announces (id TEXT PRIMARY KEY, object_url TEXT NOT NULL, actor_url TEXT NOT NULL, announced_at TEXT NOT NULL)") + .execute(&pool).await.unwrap(); + pool + } + + #[tokio::test] + async fn add_announce_stores_and_counts() { + let pool = test_pool().await; + let repo = SqliteFederationRepository::new(pool); + repo.add_announce("https://remote/ann/1", "https://local/r/1", "https://remote/u/1", Utc::now()).await.unwrap(); + assert_eq!(repo.count_announces("https://local/r/1").await.unwrap(), 1); + } + + #[tokio::test] + async fn duplicate_announce_is_ignored() { + let pool = test_pool().await; + let repo = SqliteFederationRepository::new(pool); + repo.add_announce("https://remote/ann/1", "https://local/r/1", "https://remote/u/1", Utc::now()).await.unwrap(); + repo.add_announce("https://remote/ann/1", "https://local/r/1", "https://remote/u/1", Utc::now()).await.unwrap(); + assert_eq!(repo.count_announces("https://local/r/1").await.unwrap(), 1); + } + async fn setup_db(pool: &SqlitePool) { sqlx::query( "CREATE TABLE IF NOT EXISTS ap_remote_actors ( @@ -564,6 +625,7 @@ mod tests { inbox_url TEXT NOT NULL, shared_inbox_url TEXT, display_name TEXT, + avatar_url TEXT, fetched_at TEXT NOT NULL )", ) diff --git a/crates/adapters/sqlite/.sqlx/query-01a08873b7fa815ad98a56a0902b60414cfcdc2c7a8570351320c4bc425347c6.json b/crates/adapters/sqlite/.sqlx/query-05d958c1fa38095ae2b5b81ede48fc85702d8c39c6301839de7b4d27f4a4d41b.json similarity index 79% rename from crates/adapters/sqlite/.sqlx/query-01a08873b7fa815ad98a56a0902b60414cfcdc2c7a8570351320c4bc425347c6.json rename to crates/adapters/sqlite/.sqlx/query-05d958c1fa38095ae2b5b81ede48fc85702d8c39c6301839de7b4d27f4a4d41b.json index 27ed241..35fb040 100644 --- a/crates/adapters/sqlite/.sqlx/query-01a08873b7fa815ad98a56a0902b60414cfcdc2c7a8570351320c4bc425347c6.json +++ b/crates/adapters/sqlite/.sqlx/query-05d958c1fa38095ae2b5b81ede48fc85702d8c39c6301839de7b4d27f4a4d41b.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,\n r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at\n FROM reviews r\n INNER JOIN movies m ON m.id = r.movie_id\n WHERE r.movie_id = ?\n ORDER BY r.watched_at ASC\n LIMIT ? OFFSET ?", + "query": "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,\n r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url\n FROM reviews r\n INNER JOIN movies m ON m.id = r.movie_id\n WHERE r.movie_id = ?\n ORDER BY r.watched_at DESC\n LIMIT ? OFFSET ?", "describe": { "columns": [ { @@ -67,6 +67,11 @@ "name": "created_at", "ordinal": 12, "type_info": "Text" + }, + { + "name": "remote_actor_url", + "ordinal": 13, + "type_info": "Text" } ], "parameters": { @@ -85,8 +90,9 @@ false, true, false, - false + false, + true ] }, - "hash": "01a08873b7fa815ad98a56a0902b60414cfcdc2c7a8570351320c4bc425347c6" + "hash": "05d958c1fa38095ae2b5b81ede48fc85702d8c39c6301839de7b4d27f4a4d41b" } diff --git a/crates/adapters/sqlite/.sqlx/query-0acf3515a4d1bef7a2458d878481ba3b11c9f252311579f7daba335f70d78f07.json b/crates/adapters/sqlite/.sqlx/query-0acf3515a4d1bef7a2458d878481ba3b11c9f252311579f7daba335f70d78f07.json new file mode 100644 index 0000000..d60a26d --- /dev/null +++ b/crates/adapters/sqlite/.sqlx/query-0acf3515a4d1bef7a2458d878481ba3b11c9f252311579f7daba335f70d78f07.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO users (id, email, username, password_hash, created_at, role) VALUES (?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 6 + }, + "nullable": [] + }, + "hash": "0acf3515a4d1bef7a2458d878481ba3b11c9f252311579f7daba335f70d78f07" +} diff --git a/crates/adapters/sqlite/.sqlx/query-106b5b65162314c47217c26b7e89194094e10122ea596e8d9323968e600635a9.json b/crates/adapters/sqlite/.sqlx/query-106b5b65162314c47217c26b7e89194094e10122ea596e8d9323968e600635a9.json new file mode 100644 index 0000000..744f12a --- /dev/null +++ b/crates/adapters/sqlite/.sqlx/query-106b5b65162314c47217c26b7e89194094e10122ea596e8d9323968e600635a9.json @@ -0,0 +1,98 @@ +{ + "db_name": "SQLite", + "query": "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,\n r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url\n FROM reviews r\n INNER JOIN movies m ON m.id = r.movie_id\n WHERE r.user_id = ?\n ORDER BY r.watched_at DESC", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "external_metadata_id", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "title", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "release_year", + "ordinal": 3, + "type_info": "Integer" + }, + { + "name": "director", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "poster_path", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "review_id", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "movie_id", + "ordinal": 7, + "type_info": "Text" + }, + { + "name": "user_id", + "ordinal": 8, + "type_info": "Text" + }, + { + "name": "rating", + "ordinal": 9, + "type_info": "Integer" + }, + { + "name": "comment", + "ordinal": 10, + "type_info": "Text" + }, + { + "name": "watched_at", + "ordinal": 11, + "type_info": "Text" + }, + { + "name": "created_at", + "ordinal": 12, + "type_info": "Text" + }, + { + "name": "remote_actor_url", + "ordinal": 13, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + true, + false, + false, + true, + true, + false, + false, + false, + false, + true, + false, + false, + true + ] + }, + "hash": "106b5b65162314c47217c26b7e89194094e10122ea596e8d9323968e600635a9" +} diff --git a/crates/adapters/sqlite/.sqlx/query-1417a8a295bc966637eb7e68e088148a7bef09fb1a3c3ea44d25da32c3908472.json b/crates/adapters/sqlite/.sqlx/query-1417a8a295bc966637eb7e68e088148a7bef09fb1a3c3ea44d25da32c3908472.json new file mode 100644 index 0000000..1edfaad --- /dev/null +++ b/crates/adapters/sqlite/.sqlx/query-1417a8a295bc966637eb7e68e088148a7bef09fb1a3c3ea44d25da32c3908472.json @@ -0,0 +1,56 @@ +{ + "db_name": "SQLite", + "query": "SELECT id, email, username, password_hash, role, bio, avatar_path 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" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + true, + false, + false, + false, + false, + true, + true + ] + }, + "hash": "1417a8a295bc966637eb7e68e088148a7bef09fb1a3c3ea44d25da32c3908472" +} diff --git a/crates/adapters/sqlite/.sqlx/query-167481bb1692cc81531d9a5cd85425e43d09a6df97c335ac347f7cfd61acd171.json b/crates/adapters/sqlite/.sqlx/query-167481bb1692cc81531d9a5cd85425e43d09a6df97c335ac347f7cfd61acd171.json deleted file mode 100644 index 80aefc3..0000000 --- a/crates/adapters/sqlite/.sqlx/query-167481bb1692cc81531d9a5cd85425e43d09a6df97c335ac347f7cfd61acd171.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "db_name": "SQLite", - "query": "SELECT id, email, password_hash FROM users WHERE email = ?", - "describe": { - "columns": [ - { - "name": "id", - "ordinal": 0, - "type_info": "Text" - }, - { - "name": "email", - "ordinal": 1, - "type_info": "Text" - }, - { - "name": "password_hash", - "ordinal": 2, - "type_info": "Text" - } - ], - "parameters": { - "Right": 1 - }, - "nullable": [ - false, - false, - false - ] - }, - "hash": "167481bb1692cc81531d9a5cd85425e43d09a6df97c335ac347f7cfd61acd171" -} diff --git a/crates/adapters/sqlite/.sqlx/query-18de90feb13b9f467f06d0ce25332d9ea7eabc99d9f1a44694e5d10762606f82.json b/crates/adapters/sqlite/.sqlx/query-18de90feb13b9f467f06d0ce25332d9ea7eabc99d9f1a44694e5d10762606f82.json deleted file mode 100644 index 78a50ea..0000000 --- a/crates/adapters/sqlite/.sqlx/query-18de90feb13b9f467f06d0ce25332d9ea7eabc99d9f1a44694e5d10762606f82.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT OR IGNORE INTO users (id, email, password_hash, created_at) VALUES (?, ?, ?, ?)", - "describe": { - "columns": [], - "parameters": { - "Right": 4 - }, - "nullable": [] - }, - "hash": "18de90feb13b9f467f06d0ce25332d9ea7eabc99d9f1a44694e5d10762606f82" -} diff --git a/crates/adapters/sqlite/.sqlx/query-1bc5a51762717e45292626052f0a65ac0b8a001798a2476ea86143c5565df399.json b/crates/adapters/sqlite/.sqlx/query-1bc5a51762717e45292626052f0a65ac0b8a001798a2476ea86143c5565df399.json deleted file mode 100644 index 56618eb..0000000 --- a/crates/adapters/sqlite/.sqlx/query-1bc5a51762717e45292626052f0a65ac0b8a001798a2476ea86143c5565df399.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "db_name": "SQLite", - "query": "SELECT id, email, password_hash FROM users WHERE id = ?", - "describe": { - "columns": [ - { - "name": "id", - "ordinal": 0, - "type_info": "Text" - }, - { - "name": "email", - "ordinal": 1, - "type_info": "Text" - }, - { - "name": "password_hash", - "ordinal": 2, - "type_info": "Text" - } - ], - "parameters": { - "Right": 1 - }, - "nullable": [ - false, - false, - false - ] - }, - "hash": "1bc5a51762717e45292626052f0a65ac0b8a001798a2476ea86143c5565df399" -} diff --git a/crates/adapters/sqlite/.sqlx/query-1c70d5d455e5ab3dfb68d35ce5255c7e0e86b9f6549ae8ae09247806f1321086.json b/crates/adapters/sqlite/.sqlx/query-1c70d5d455e5ab3dfb68d35ce5255c7e0e86b9f6549ae8ae09247806f1321086.json new file mode 100644 index 0000000..ccad005 --- /dev/null +++ b/crates/adapters/sqlite/.sqlx/query-1c70d5d455e5ab3dfb68d35ce5255c7e0e86b9f6549ae8ae09247806f1321086.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM import_sessions WHERE expires_at < datetime('now')", + "describe": { + "columns": [], + "parameters": { + "Right": 0 + }, + "nullable": [] + }, + "hash": "1c70d5d455e5ab3dfb68d35ce5255c7e0e86b9f6549ae8ae09247806f1321086" +} diff --git a/crates/adapters/sqlite/.sqlx/query-1edf77b936b825139735e4f92bc472031e3231235ca5fe40732d7bdfddc4cbba.json b/crates/adapters/sqlite/.sqlx/query-1edf77b936b825139735e4f92bc472031e3231235ca5fe40732d7bdfddc4cbba.json new file mode 100644 index 0000000..4855c0d --- /dev/null +++ b/crates/adapters/sqlite/.sqlx/query-1edf77b936b825139735e4f92bc472031e3231235ca5fe40732d7bdfddc4cbba.json @@ -0,0 +1,56 @@ +{ + "db_name": "SQLite", + "query": "SELECT id, email, username, password_hash, role, bio, avatar_path 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" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + true, + false, + false, + false, + false, + true, + true + ] + }, + "hash": "1edf77b936b825139735e4f92bc472031e3231235ca5fe40732d7bdfddc4cbba" +} diff --git a/crates/adapters/sqlite/.sqlx/query-026e2afeb573707cb360fcdab8f6137aabfaf603b5ed57b98ac2888b4a0389ff.json b/crates/adapters/sqlite/.sqlx/query-25fd01355c929a83daf2c802b8ae3adaa4ce73fc037e2bf2a87d60187aeb7361.json similarity index 80% rename from crates/adapters/sqlite/.sqlx/query-026e2afeb573707cb360fcdab8f6137aabfaf603b5ed57b98ac2888b4a0389ff.json rename to crates/adapters/sqlite/.sqlx/query-25fd01355c929a83daf2c802b8ae3adaa4ce73fc037e2bf2a87d60187aeb7361.json index 58175b3..6e4083e 100644 --- a/crates/adapters/sqlite/.sqlx/query-026e2afeb573707cb360fcdab8f6137aabfaf603b5ed57b98ac2888b4a0389ff.json +++ b/crates/adapters/sqlite/.sqlx/query-25fd01355c929a83daf2c802b8ae3adaa4ce73fc037e2bf2a87d60187aeb7361.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,\n r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at\n FROM reviews r\n INNER JOIN movies m ON m.id = r.movie_id\n ORDER BY r.watched_at DESC\n LIMIT ? OFFSET ?", + "query": "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,\n r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url\n FROM reviews r\n INNER JOIN movies m ON m.id = r.movie_id\n ORDER BY r.watched_at ASC\n LIMIT ? OFFSET ?", "describe": { "columns": [ { @@ -67,6 +67,11 @@ "name": "created_at", "ordinal": 12, "type_info": "Text" + }, + { + "name": "remote_actor_url", + "ordinal": 13, + "type_info": "Text" } ], "parameters": { @@ -85,8 +90,9 @@ false, true, false, - false + false, + true ] }, - "hash": "026e2afeb573707cb360fcdab8f6137aabfaf603b5ed57b98ac2888b4a0389ff" + "hash": "25fd01355c929a83daf2c802b8ae3adaa4ce73fc037e2bf2a87d60187aeb7361" } diff --git a/crates/adapters/sqlite/.sqlx/query-2c7353f34c4748d4d4be6abbf343fa7ea30eeb985c4bfd12b0fc3997d1ba03bb.json b/crates/adapters/sqlite/.sqlx/query-2c7353f34c4748d4d4be6abbf343fa7ea30eeb985c4bfd12b0fc3997d1ba03bb.json new file mode 100644 index 0000000..71a8608 --- /dev/null +++ b/crates/adapters/sqlite/.sqlx/query-2c7353f34c4748d4d4be6abbf343fa7ea30eeb985c4bfd12b0fc3997d1ba03bb.json @@ -0,0 +1,38 @@ +{ + "db_name": "SQLite", + "query": "SELECT u.id AS \"id!: String\",\n u.email AS \"email!: String\",\n COUNT(DISTINCT r.movie_id) AS \"total_movies!: i64\",\n AVG(CAST(r.rating AS REAL)) AS avg_rating\n FROM users u\n LEFT JOIN reviews r ON r.user_id = u.id AND r.remote_actor_url IS NULL\n GROUP BY u.id, u.email\n ORDER BY u.email ASC", + "describe": { + "columns": [ + { + "name": "id!: String", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "email!: String", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "total_movies!: i64", + "ordinal": 2, + "type_info": "Integer" + }, + { + "name": "avg_rating", + "ordinal": 3, + "type_info": "Float" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + true, + false, + false, + true + ] + }, + "hash": "2c7353f34c4748d4d4be6abbf343fa7ea30eeb985c4bfd12b0fc3997d1ba03bb" +} diff --git a/crates/adapters/sqlite/.sqlx/query-34a0a7b5e6a7f2b97b59e9de6a34b41a7128a465c0957915c7ed7031c6b83fb0.json b/crates/adapters/sqlite/.sqlx/query-34a0a7b5e6a7f2b97b59e9de6a34b41a7128a465c0957915c7ed7031c6b83fb0.json new file mode 100644 index 0000000..e77c708 --- /dev/null +++ b/crates/adapters/sqlite/.sqlx/query-34a0a7b5e6a7f2b97b59e9de6a34b41a7128a465c0957915c7ed7031c6b83fb0.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM import_sessions WHERE user_id = ? AND expires_at < datetime('now')", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "34a0a7b5e6a7f2b97b59e9de6a34b41a7128a465c0957915c7ed7031c6b83fb0" +} diff --git a/crates/adapters/sqlite/.sqlx/query-4d85f0ff9732576bba77dc84d3885a0002c2b600c34ba4d99f1e1c5e99f35e75.json b/crates/adapters/sqlite/.sqlx/query-4d85f0ff9732576bba77dc84d3885a0002c2b600c34ba4d99f1e1c5e99f35e75.json new file mode 100644 index 0000000..f67cfd6 --- /dev/null +++ b/crates/adapters/sqlite/.sqlx/query-4d85f0ff9732576bba77dc84d3885a0002c2b600c34ba4d99f1e1c5e99f35e75.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "SELECT strftime('%Y-%m', watched_at) AS month\n FROM reviews\n WHERE user_id = ?\n GROUP BY month\n ORDER BY COUNT(*) DESC\n LIMIT 1", + "describe": { + "columns": [ + { + "name": "month", + "ordinal": 0, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + true + ] + }, + "hash": "4d85f0ff9732576bba77dc84d3885a0002c2b600c34ba4d99f1e1c5e99f35e75" +} diff --git a/crates/adapters/sqlite/.sqlx/query-630e092fcd33bc312befef352a98225e6e18e6079644b949258a39bf4b0fe3e5.json b/crates/adapters/sqlite/.sqlx/query-630e092fcd33bc312befef352a98225e6e18e6079644b949258a39bf4b0fe3e5.json deleted file mode 100644 index 8b41a06..0000000 --- a/crates/adapters/sqlite/.sqlx/query-630e092fcd33bc312befef352a98225e6e18e6079644b949258a39bf4b0fe3e5.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO reviews (id, movie_id, user_id, rating, comment, watched_at, created_at)\n VALUES (?, ?, ?, ?, ?, ?, ?)", - "describe": { - "columns": [], - "parameters": { - "Right": 7 - }, - "nullable": [] - }, - "hash": "630e092fcd33bc312befef352a98225e6e18e6079644b949258a39bf4b0fe3e5" -} diff --git a/crates/adapters/sqlite/.sqlx/query-6638569b8759b965f8919f4998a8665b15c14fc0b4f6018e548b76474254073e.json b/crates/adapters/sqlite/.sqlx/query-6638569b8759b965f8919f4998a8665b15c14fc0b4f6018e548b76474254073e.json new file mode 100644 index 0000000..7a604fe --- /dev/null +++ b/crates/adapters/sqlite/.sqlx/query-6638569b8759b965f8919f4998a8665b15c14fc0b4f6018e548b76474254073e.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT OR REPLACE INTO import_profiles (id, user_id, name, field_mappings, created_at)\n VALUES (?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 5 + }, + "nullable": [] + }, + "hash": "6638569b8759b965f8919f4998a8665b15c14fc0b4f6018e548b76474254073e" +} diff --git a/crates/adapters/sqlite/.sqlx/query-759b45ac8426d93d4668e208003d52957f2f52d8a021cc6f117cb6f241689a07.json b/crates/adapters/sqlite/.sqlx/query-759b45ac8426d93d4668e208003d52957f2f52d8a021cc6f117cb6f241689a07.json new file mode 100644 index 0000000..c743c41 --- /dev/null +++ b/crates/adapters/sqlite/.sqlx/query-759b45ac8426d93d4668e208003d52957f2f52d8a021cc6f117cb6f241689a07.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE import_sessions SET field_mappings = ?, row_results = ? WHERE id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 3 + }, + "nullable": [] + }, + "hash": "759b45ac8426d93d4668e208003d52957f2f52d8a021cc6f117cb6f241689a07" +} diff --git a/crates/adapters/sqlite/.sqlx/query-7fa9ce795286905f4b6c3d5a8975fabd813a2b39652dd7e18f03f5b10a4beaca.json b/crates/adapters/sqlite/.sqlx/query-7fa9ce795286905f4b6c3d5a8975fabd813a2b39652dd7e18f03f5b10a4beaca.json new file mode 100644 index 0000000..3f0c006 --- /dev/null +++ b/crates/adapters/sqlite/.sqlx/query-7fa9ce795286905f4b6c3d5a8975fabd813a2b39652dd7e18f03f5b10a4beaca.json @@ -0,0 +1,44 @@ +{ + "db_name": "SQLite", + "query": "SELECT id, user_id, name, field_mappings, created_at FROM import_profiles WHERE id = ? AND user_id = ?", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "user_id", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "name", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "field_mappings", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "created_at", + "ordinal": 4, + "type_info": "Text" + } + ], + "parameters": { + "Right": 2 + }, + "nullable": [ + false, + false, + false, + false, + false + ] + }, + "hash": "7fa9ce795286905f4b6c3d5a8975fabd813a2b39652dd7e18f03f5b10a4beaca" +} diff --git a/crates/adapters/sqlite/.sqlx/query-7ff439f22f880f999a72aad1359eb8fec11fe868b940faee5a351795caaa2357.json b/crates/adapters/sqlite/.sqlx/query-7ff439f22f880f999a72aad1359eb8fec11fe868b940faee5a351795caaa2357.json new file mode 100644 index 0000000..381b73f --- /dev/null +++ b/crates/adapters/sqlite/.sqlx/query-7ff439f22f880f999a72aad1359eb8fec11fe868b940faee5a351795caaa2357.json @@ -0,0 +1,62 @@ +{ + "db_name": "SQLite", + "query": "SELECT id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url\n FROM reviews WHERE movie_id = ? ORDER BY watched_at ASC", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "movie_id", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "user_id", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "rating", + "ordinal": 3, + "type_info": "Integer" + }, + { + "name": "comment", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "watched_at", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "created_at", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "remote_actor_url", + "ordinal": 7, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false, + true, + false, + false, + true + ] + }, + "hash": "7ff439f22f880f999a72aad1359eb8fec11fe868b940faee5a351795caaa2357" +} diff --git a/crates/adapters/sqlite/.sqlx/query-47f7cf95ce3450635b643ab710cadba96f40319140834d510bc5207b2552e055.json b/crates/adapters/sqlite/.sqlx/query-8a70f21c39d203867c06dc0bf74a54745b3331b84ce9a2178f7812f1ed7262cc.json similarity index 79% rename from crates/adapters/sqlite/.sqlx/query-47f7cf95ce3450635b643ab710cadba96f40319140834d510bc5207b2552e055.json rename to crates/adapters/sqlite/.sqlx/query-8a70f21c39d203867c06dc0bf74a54745b3331b84ce9a2178f7812f1ed7262cc.json index 8db3f5e..965ef29 100644 --- a/crates/adapters/sqlite/.sqlx/query-47f7cf95ce3450635b643ab710cadba96f40319140834d510bc5207b2552e055.json +++ b/crates/adapters/sqlite/.sqlx/query-8a70f21c39d203867c06dc0bf74a54745b3331b84ce9a2178f7812f1ed7262cc.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,\n r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at\n FROM reviews r\n INNER JOIN movies m ON m.id = r.movie_id\n WHERE r.movie_id = ?\n ORDER BY r.watched_at DESC\n LIMIT ? OFFSET ?", + "query": "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,\n r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url\n FROM reviews r\n INNER JOIN movies m ON m.id = r.movie_id\n WHERE r.movie_id = ?\n ORDER BY r.watched_at ASC\n LIMIT ? OFFSET ?", "describe": { "columns": [ { @@ -67,6 +67,11 @@ "name": "created_at", "ordinal": 12, "type_info": "Text" + }, + { + "name": "remote_actor_url", + "ordinal": 13, + "type_info": "Text" } ], "parameters": { @@ -85,8 +90,9 @@ false, true, false, - false + false, + true ] }, - "hash": "47f7cf95ce3450635b643ab710cadba96f40319140834d510bc5207b2552e055" + "hash": "8a70f21c39d203867c06dc0bf74a54745b3331b84ce9a2178f7812f1ed7262cc" } diff --git a/crates/adapters/sqlite/.sqlx/query-9024c2b533d0100f18d5a5550220a148d8dc29bbf0df715f895fa4248d83785f.json b/crates/adapters/sqlite/.sqlx/query-9024c2b533d0100f18d5a5550220a148d8dc29bbf0df715f895fa4248d83785f.json new file mode 100644 index 0000000..3d3132f --- /dev/null +++ b/crates/adapters/sqlite/.sqlx/query-9024c2b533d0100f18d5a5550220a148d8dc29bbf0df715f895fa4248d83785f.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM import_profiles WHERE id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "9024c2b533d0100f18d5a5550220a148d8dc29bbf0df715f895fa4248d83785f" +} diff --git a/crates/adapters/sqlite/.sqlx/query-a01336632a54099e31686a9cbe6fc53fef1299fc7c7b52be44f99c2302490a22.json b/crates/adapters/sqlite/.sqlx/query-a01336632a54099e31686a9cbe6fc53fef1299fc7c7b52be44f99c2302490a22.json new file mode 100644 index 0000000..114ff1f --- /dev/null +++ b/crates/adapters/sqlite/.sqlx/query-a01336632a54099e31686a9cbe6fc53fef1299fc7c7b52be44f99c2302490a22.json @@ -0,0 +1,26 @@ +{ + "db_name": "SQLite", + "query": "SELECT COUNT(DISTINCT movie_id) AS \"total!: i64\",\n AVG(CAST(rating AS REAL)) AS avg_rating\n FROM reviews WHERE user_id = ?", + "describe": { + "columns": [ + { + "name": "total!: i64", + "ordinal": 0, + "type_info": "Integer" + }, + { + "name": "avg_rating", + "ordinal": 1, + "type_info": "Float" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + true + ] + }, + "hash": "a01336632a54099e31686a9cbe6fc53fef1299fc7c7b52be44f99c2302490a22" +} diff --git a/crates/adapters/sqlite/.sqlx/query-affe1eb261283c09d4b1ce6e684681755f079a044ffec8ff2bd79cfd8efe16b8.json b/crates/adapters/sqlite/.sqlx/query-a7c424c26663e4e51b1c563fa977f28e1d55234a242a7ddba50db13cf73b488d.json similarity index 80% rename from crates/adapters/sqlite/.sqlx/query-affe1eb261283c09d4b1ce6e684681755f079a044ffec8ff2bd79cfd8efe16b8.json rename to crates/adapters/sqlite/.sqlx/query-a7c424c26663e4e51b1c563fa977f28e1d55234a242a7ddba50db13cf73b488d.json index 65a123c..b715b4e 100644 --- a/crates/adapters/sqlite/.sqlx/query-affe1eb261283c09d4b1ce6e684681755f079a044ffec8ff2bd79cfd8efe16b8.json +++ b/crates/adapters/sqlite/.sqlx/query-a7c424c26663e4e51b1c563fa977f28e1d55234a242a7ddba50db13cf73b488d.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,\n r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at\n FROM reviews r\n INNER JOIN movies m ON m.id = r.movie_id\n ORDER BY r.watched_at ASC\n LIMIT ? OFFSET ?", + "query": "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,\n r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url\n FROM reviews r\n INNER JOIN movies m ON m.id = r.movie_id\n ORDER BY r.watched_at DESC\n LIMIT ? OFFSET ?", "describe": { "columns": [ { @@ -67,6 +67,11 @@ "name": "created_at", "ordinal": 12, "type_info": "Text" + }, + { + "name": "remote_actor_url", + "ordinal": 13, + "type_info": "Text" } ], "parameters": { @@ -85,8 +90,9 @@ false, true, false, - false + false, + true ] }, - "hash": "affe1eb261283c09d4b1ce6e684681755f079a044ffec8ff2bd79cfd8efe16b8" + "hash": "a7c424c26663e4e51b1c563fa977f28e1d55234a242a7ddba50db13cf73b488d" } diff --git a/crates/adapters/sqlite/.sqlx/query-aca9e7aaa32c23b4de3f5048d60340e978d31a36be9121da3c59378f2fc1ed8e.json b/crates/adapters/sqlite/.sqlx/query-aca9e7aaa32c23b4de3f5048d60340e978d31a36be9121da3c59378f2fc1ed8e.json new file mode 100644 index 0000000..4392039 --- /dev/null +++ b/crates/adapters/sqlite/.sqlx/query-aca9e7aaa32c23b4de3f5048d60340e978d31a36be9121da3c59378f2fc1ed8e.json @@ -0,0 +1,26 @@ +{ + "db_name": "SQLite", + "query": "SELECT m.director AS \"director!\",\n COUNT(*) AS \"count!: i64\"\n FROM reviews r\n INNER JOIN movies m ON m.id = r.movie_id\n WHERE r.user_id = ? AND m.director IS NOT NULL\n GROUP BY m.director\n ORDER BY COUNT(*) DESC\n LIMIT 5", + "describe": { + "columns": [ + { + "name": "director!", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "count!: i64", + "ordinal": 1, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + true, + false + ] + }, + "hash": "aca9e7aaa32c23b4de3f5048d60340e978d31a36be9121da3c59378f2fc1ed8e" +} diff --git a/crates/adapters/sqlite/.sqlx/query-af883f8b78f185077e2d3dcfaa0a6e62fbdfbf00c97c9b33b699dc631476181d.json b/crates/adapters/sqlite/.sqlx/query-ae983138ad90fda3794b784fbf62c31fddcf182850782e9bfbc4ff3ee8b7d4bb.json similarity index 76% rename from crates/adapters/sqlite/.sqlx/query-af883f8b78f185077e2d3dcfaa0a6e62fbdfbf00c97c9b33b699dc631476181d.json rename to crates/adapters/sqlite/.sqlx/query-ae983138ad90fda3794b784fbf62c31fddcf182850782e9bfbc4ff3ee8b7d4bb.json index 4b94231..625de0c 100644 --- a/crates/adapters/sqlite/.sqlx/query-af883f8b78f185077e2d3dcfaa0a6e62fbdfbf00c97c9b33b699dc631476181d.json +++ b/crates/adapters/sqlite/.sqlx/query-ae983138ad90fda3794b784fbf62c31fddcf182850782e9bfbc4ff3ee8b7d4bb.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT id, movie_id, user_id, rating, comment, watched_at, created_at\n FROM reviews WHERE movie_id = ? ORDER BY watched_at ASC", + "query": "SELECT id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url\n FROM reviews WHERE id = ?", "describe": { "columns": [ { @@ -37,6 +37,11 @@ "name": "created_at", "ordinal": 6, "type_info": "Text" + }, + { + "name": "remote_actor_url", + "ordinal": 7, + "type_info": "Text" } ], "parameters": { @@ -49,8 +54,9 @@ false, true, false, - false + false, + true ] }, - "hash": "af883f8b78f185077e2d3dcfaa0a6e62fbdfbf00c97c9b33b699dc631476181d" + "hash": "ae983138ad90fda3794b784fbf62c31fddcf182850782e9bfbc4ff3ee8b7d4bb" } diff --git a/crates/adapters/sqlite/.sqlx/query-bf4aaf630ebefde88d87a95c257bbeafaf39e2f53611dab435d485648eb7e598.json b/crates/adapters/sqlite/.sqlx/query-bf4aaf630ebefde88d87a95c257bbeafaf39e2f53611dab435d485648eb7e598.json new file mode 100644 index 0000000..e1cbf34 --- /dev/null +++ b/crates/adapters/sqlite/.sqlx/query-bf4aaf630ebefde88d87a95c257bbeafaf39e2f53611dab435d485648eb7e598.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM import_sessions WHERE id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "bf4aaf630ebefde88d87a95c257bbeafaf39e2f53611dab435d485648eb7e598" +} diff --git a/crates/adapters/sqlite/.sqlx/query-ca7c29f4c80cee4877625a259edef662b169377c5bafdd03583645a39368c910.json b/crates/adapters/sqlite/.sqlx/query-ca7c29f4c80cee4877625a259edef662b169377c5bafdd03583645a39368c910.json new file mode 100644 index 0000000..320b404 --- /dev/null +++ b/crates/adapters/sqlite/.sqlx/query-ca7c29f4c80cee4877625a259edef662b169377c5bafdd03583645a39368c910.json @@ -0,0 +1,56 @@ +{ + "db_name": "SQLite", + "query": "SELECT id, user_id, parsed_data, field_mappings, row_results, created_at, expires_at\n FROM import_sessions WHERE id = ? AND user_id = ?", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "user_id", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "parsed_data", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "field_mappings", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "row_results", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "created_at", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "expires_at", + "ordinal": 6, + "type_info": "Text" + } + ], + "parameters": { + "Right": 2 + }, + "nullable": [ + false, + false, + false, + true, + true, + false, + false + ] + }, + "hash": "ca7c29f4c80cee4877625a259edef662b169377c5bafdd03583645a39368c910" +} diff --git a/crates/adapters/sqlite/.sqlx/query-cca022ac6275f2b1aaf63a14420897074c8ff4cdd1d3e9a13ef4b9dd5346d12a.json b/crates/adapters/sqlite/.sqlx/query-cca022ac6275f2b1aaf63a14420897074c8ff4cdd1d3e9a13ef4b9dd5346d12a.json new file mode 100644 index 0000000..a320185 --- /dev/null +++ b/crates/adapters/sqlite/.sqlx/query-cca022ac6275f2b1aaf63a14420897074c8ff4cdd1d3e9a13ef4b9dd5346d12a.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO reviews (id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url)\n VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 8 + }, + "nullable": [] + }, + "hash": "cca022ac6275f2b1aaf63a14420897074c8ff4cdd1d3e9a13ef4b9dd5346d12a" +} diff --git a/crates/adapters/sqlite/.sqlx/query-d5d2a81306488a8cee5654cea7e14d76d76ecc7d2190ffb73d12bec2874111d2.json b/crates/adapters/sqlite/.sqlx/query-d5d2a81306488a8cee5654cea7e14d76d76ecc7d2190ffb73d12bec2874111d2.json new file mode 100644 index 0000000..506d8af --- /dev/null +++ b/crates/adapters/sqlite/.sqlx/query-d5d2a81306488a8cee5654cea7e14d76d76ecc7d2190ffb73d12bec2874111d2.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "SELECT m.director\n FROM reviews r\n INNER JOIN movies m ON m.id = r.movie_id\n WHERE r.user_id = ? AND m.director IS NOT NULL\n GROUP BY m.director\n ORDER BY COUNT(*) DESC\n LIMIT 1", + "describe": { + "columns": [ + { + "name": "director", + "ordinal": 0, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + true + ] + }, + "hash": "d5d2a81306488a8cee5654cea7e14d76d76ecc7d2190ffb73d12bec2874111d2" +} diff --git a/crates/adapters/sqlite/.sqlx/query-d6c6b579a18fb106e62148f5f85b8071fceefea51909ace939ae1d09c4597c43.json b/crates/adapters/sqlite/.sqlx/query-d6c6b579a18fb106e62148f5f85b8071fceefea51909ace939ae1d09c4597c43.json new file mode 100644 index 0000000..bb1a126 --- /dev/null +++ b/crates/adapters/sqlite/.sqlx/query-d6c6b579a18fb106e62148f5f85b8071fceefea51909ace939ae1d09c4597c43.json @@ -0,0 +1,56 @@ +{ + "db_name": "SQLite", + "query": "SELECT id, email, username, password_hash, role, bio, avatar_path 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" + }, + { + "name": "bio", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "avatar_path", + "ordinal": 6, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + true, + false, + false, + false, + false, + true, + true + ] + }, + "hash": "d6c6b579a18fb106e62148f5f85b8071fceefea51909ace939ae1d09c4597c43" +} diff --git a/crates/adapters/sqlite/.sqlx/query-e3a3b56d63df433339bd8183f20ef33ded742ef03df885686cd5198f9f8e1c01.json b/crates/adapters/sqlite/.sqlx/query-e3a3b56d63df433339bd8183f20ef33ded742ef03df885686cd5198f9f8e1c01.json new file mode 100644 index 0000000..9f87c40 --- /dev/null +++ b/crates/adapters/sqlite/.sqlx/query-e3a3b56d63df433339bd8183f20ef33ded742ef03df885686cd5198f9f8e1c01.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO import_sessions (id, user_id, parsed_data, field_mappings, row_results, created_at, expires_at)\n VALUES (?, ?, ?, ?, ?, ?, ?)", + "describe": { + "columns": [], + "parameters": { + "Right": 7 + }, + "nullable": [] + }, + "hash": "e3a3b56d63df433339bd8183f20ef33ded742ef03df885686cd5198f9f8e1c01" +} diff --git a/crates/adapters/sqlite/.sqlx/query-e431381ad41c1c2f7b9c89509d5e3f4c19cb52dcfff66772145cd80c53c16883.json b/crates/adapters/sqlite/.sqlx/query-e431381ad41c1c2f7b9c89509d5e3f4c19cb52dcfff66772145cd80c53c16883.json new file mode 100644 index 0000000..c2562bf --- /dev/null +++ b/crates/adapters/sqlite/.sqlx/query-e431381ad41c1c2f7b9c89509d5e3f4c19cb52dcfff66772145cd80c53c16883.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM movies WHERE id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "e431381ad41c1c2f7b9c89509d5e3f4c19cb52dcfff66772145cd80c53c16883" +} diff --git a/crates/adapters/sqlite/.sqlx/query-f84e5483ca4210aec67b38cc1a9de4a42c12891025236abc48ea4f175292a6cc.json b/crates/adapters/sqlite/.sqlx/query-f84e5483ca4210aec67b38cc1a9de4a42c12891025236abc48ea4f175292a6cc.json new file mode 100644 index 0000000..616cb43 --- /dev/null +++ b/crates/adapters/sqlite/.sqlx/query-f84e5483ca4210aec67b38cc1a9de4a42c12891025236abc48ea4f175292a6cc.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM reviews WHERE id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "f84e5483ca4210aec67b38cc1a9de4a42c12891025236abc48ea4f175292a6cc" +} diff --git a/crates/adapters/sqlite/.sqlx/query-f8559923b7a885fb0d4938479f11bd97708b401a6a2290abe6df49fcb7f28a8d.json b/crates/adapters/sqlite/.sqlx/query-f8559923b7a885fb0d4938479f11bd97708b401a6a2290abe6df49fcb7f28a8d.json new file mode 100644 index 0000000..5b31e39 --- /dev/null +++ b/crates/adapters/sqlite/.sqlx/query-f8559923b7a885fb0d4938479f11bd97708b401a6a2290abe6df49fcb7f28a8d.json @@ -0,0 +1,44 @@ +{ + "db_name": "SQLite", + "query": "SELECT id, user_id, name, field_mappings, created_at FROM import_profiles WHERE user_id = ? ORDER BY created_at DESC", + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "user_id", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "name", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "field_mappings", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "created_at", + "ordinal": 4, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false, + false + ] + }, + "hash": "f8559923b7a885fb0d4938479f11bd97708b401a6a2290abe6df49fcb7f28a8d" +} diff --git a/crates/adapters/sqlite/.sqlx/query-fdd5b522f26b5e0ce62f76c774fbb606fd9ee9884f4457831f693a0df3609317.json b/crates/adapters/sqlite/.sqlx/query-fdd5b522f26b5e0ce62f76c774fbb606fd9ee9884f4457831f693a0df3609317.json new file mode 100644 index 0000000..0e39030 --- /dev/null +++ b/crates/adapters/sqlite/.sqlx/query-fdd5b522f26b5e0ce62f76c774fbb606fd9ee9884f4457831f693a0df3609317.json @@ -0,0 +1,32 @@ +{ + "db_name": "SQLite", + "query": "SELECT strftime('%Y-%m', watched_at) AS \"month!\",\n AVG(CAST(rating AS REAL)) AS \"avg_rating!: f64\",\n COUNT(*) AS \"count!: i64\"\n FROM reviews\n WHERE user_id = ? AND watched_at >= datetime('now', '-12 months')\n GROUP BY \"month!\"\n ORDER BY \"month!\" ASC", + "describe": { + "columns": [ + { + "name": "month!", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "avg_rating!: f64", + "ordinal": 1, + "type_info": "Float" + }, + { + "name": "count!: i64", + "ordinal": 2, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + true, + false, + false + ] + }, + "hash": "fdd5b522f26b5e0ce62f76c774fbb606fd9ee9884f4457831f693a0df3609317" +} diff --git a/crates/adapters/sqlite/migrations/0009_user_profile.sql b/crates/adapters/sqlite/migrations/0009_user_profile.sql new file mode 100644 index 0000000..2d7dcfb --- /dev/null +++ b/crates/adapters/sqlite/migrations/0009_user_profile.sql @@ -0,0 +1,2 @@ +ALTER TABLE users ADD COLUMN bio TEXT; +ALTER TABLE users ADD COLUMN avatar_path TEXT; diff --git a/crates/adapters/sqlite/migrations/0010_ap_remote_actor_avatar.sql b/crates/adapters/sqlite/migrations/0010_ap_remote_actor_avatar.sql new file mode 100644 index 0000000..3662443 --- /dev/null +++ b/crates/adapters/sqlite/migrations/0010_ap_remote_actor_avatar.sql @@ -0,0 +1 @@ +ALTER TABLE ap_remote_actors ADD COLUMN avatar_url TEXT; diff --git a/crates/adapters/sqlite/migrations/0011_ap_announces.sql b/crates/adapters/sqlite/migrations/0011_ap_announces.sql new file mode 100644 index 0000000..2ab1467 --- /dev/null +++ b/crates/adapters/sqlite/migrations/0011_ap_announces.sql @@ -0,0 +1,8 @@ +CREATE TABLE ap_announces ( + id TEXT PRIMARY KEY, + object_url TEXT NOT NULL, + actor_url TEXT NOT NULL, + announced_at TEXT NOT NULL +); + +CREATE INDEX idx_ap_announces_object ON ap_announces (object_url); diff --git a/crates/adapters/sqlite/src/federation.rs b/crates/adapters/sqlite/src/federation.rs index f337fbb..dc418bc 100644 --- a/crates/adapters/sqlite/src/federation.rs +++ b/crates/adapters/sqlite/src/federation.rs @@ -98,7 +98,7 @@ impl FederationRepository for SqliteFederationRepository { let rows = sqlx::query( "SELECT f.remote_actor_url, f.status, - a.handle, a.inbox_url, a.shared_inbox_url, a.display_name + 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 = ?", @@ -116,9 +116,10 @@ impl FederationRepository for SqliteFederationRepository { 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 }, + actor: RemoteActor { url, handle, inbox_url, shared_inbox_url, display_name, avatar_url }, status: str_to_status(&status_str), } }) @@ -199,7 +200,7 @@ impl FederationRepository for SqliteFederationRepository { let uid = local_user_id.to_string(); let rows = sqlx::query( - "SELECT a.url, a.handle, a.inbox_url, a.shared_inbox_url, a.display_name + "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'", @@ -214,6 +215,7 @@ impl FederationRepository for SqliteFederationRepository { 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(), }).collect()) } @@ -233,13 +235,14 @@ impl FederationRepository for SqliteFederationRepository { let fetched_at = datetime_to_str(&now); sqlx::query( - "INSERT INTO ap_remote_actors (url, handle, inbox_url, shared_inbox_url, display_name, fetched_at) - VALUES (?, ?, ?, ?, ?, ?) + "INSERT INTO ap_remote_actors (url, handle, inbox_url, shared_inbox_url, display_name, avatar_url, fetched_at) + VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(url) DO UPDATE SET handle = excluded.handle, inbox_url = excluded.inbox_url, shared_inbox_url = excluded.shared_inbox_url, display_name = excluded.display_name, + avatar_url = excluded.avatar_url, fetched_at = excluded.fetched_at", ) .bind(&actor.url) @@ -247,6 +250,7 @@ impl FederationRepository for SqliteFederationRepository { .bind(&actor.inbox_url) .bind(&actor.shared_inbox_url) .bind(&actor.display_name) + .bind(&actor.avatar_url) .bind(&fetched_at) .execute(&self.pool) .await?; @@ -256,7 +260,7 @@ impl FederationRepository for SqliteFederationRepository { async fn get_remote_actor(&self, actor_url: &str) -> Result> { let row = sqlx::query( - "SELECT url, handle, inbox_url, shared_inbox_url, display_name + "SELECT url, handle, inbox_url, shared_inbox_url, display_name, avatar_url FROM ap_remote_actors WHERE url = ?", ) .bind(actor_url) @@ -269,6 +273,7 @@ impl FederationRepository for SqliteFederationRepository { 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(), })) } @@ -308,7 +313,7 @@ impl FederationRepository for SqliteFederationRepository { let rows = sqlx::query( "SELECT f.remote_actor_url, - a.handle, a.inbox_url, a.shared_inbox_url, a.display_name + 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 = 'pending'", @@ -323,6 +328,7 @@ impl FederationRepository for SqliteFederationRepository { inbox_url: row.try_get("inbox_url").unwrap_or_default(), shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(), display_name: row.try_get("display_name").ok().flatten(), + avatar_url: row.try_get("avatar_url").ok().flatten(), }).collect()) } @@ -353,6 +359,35 @@ impl FederationRepository for SqliteFederationRepository { Ok(()) } + + async fn add_announce( + &self, + activity_id: &str, + object_url: &str, + actor_url: &str, + announced_at: chrono::DateTime, + ) -> Result<()> { + let ts = announced_at.format("%Y-%m-%d %H:%M:%S").to_string(); + sqlx::query( + "INSERT OR IGNORE INTO ap_announces (id, object_url, actor_url, announced_at) + VALUES (?1, ?2, ?3, ?4)", + ) + .bind(activity_id) + .bind(object_url) + .bind(actor_url) + .bind(&ts) + .execute(&self.pool) + .await?; + Ok(()) + } + + async fn count_announces(&self, object_url: &str) -> Result { + let row = sqlx::query("SELECT COUNT(*) as cnt FROM ap_announces WHERE object_url = ?1") + .bind(object_url) + .fetch_one(&self.pool) + .await?; + Ok(row.get::("cnt") as usize) + } } // --- Content-specific repository (movies-diary) --- diff --git a/crates/adapters/sqlite/src/users.rs b/crates/adapters/sqlite/src/users.rs index 9b37feb..3d3398c 100644 --- a/crates/adapters/sqlite/src/users.rs +++ b/crates/adapters/sqlite/src/users.rs @@ -37,6 +37,8 @@ impl SqliteUserRepository { username_str: String, hash_str: String, role: UserRole, + bio: Option, + avatar_path: Option, ) -> Result { let id = uuid::Uuid::parse_str(&id_str) .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; @@ -52,6 +54,8 @@ impl SqliteUserRepository { username, hash, role, + bio, + avatar_path, )) } } @@ -61,7 +65,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 FROM users WHERE email = ?", + "SELECT id, email, username, password_hash, role, bio, avatar_path FROM users WHERE email = ?", email_str ) .fetch_optional(&self.pool) @@ -75,6 +79,8 @@ impl UserRepository for SqliteUserRepository { r.username, r.password_hash, Self::parse_role(&r.role), + r.bio, + r.avatar_path, ) }) .transpose() @@ -83,7 +89,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 FROM users WHERE username = ?", + "SELECT id, email, username, password_hash, role, bio, avatar_path FROM users WHERE username = ?", username_str ) .fetch_optional(&self.pool) @@ -97,6 +103,8 @@ impl UserRepository for SqliteUserRepository { r.username, r.password_hash, Self::parse_role(&r.role), + r.bio, + r.avatar_path, ) }) .transpose() @@ -140,7 +148,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 FROM users WHERE id = ?", + "SELECT id, email, username, password_hash, role, bio, avatar_path FROM users WHERE id = ?", id_str ) .fetch_optional(&self.pool) @@ -154,11 +162,30 @@ impl UserRepository for SqliteUserRepository { r.username, r.password_hash, Self::parse_role(&r.role), + r.bio, + r.avatar_path, ) }) .transpose() } + async fn update_profile( + &self, + user_id: &UserId, + bio: Option, + avatar_path: 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()))?; + Ok(()) + } + async fn list_with_stats(&self) -> Result, DomainError> { sqlx::query_as!( UserSummaryRow, @@ -183,12 +210,14 @@ impl UserRepository for SqliteUserRepository { #[cfg(test)] mod tests { use super::*; + use domain::models::UserRole; + use domain::value_objects::{Email, PasswordHash, Username}; 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')" + "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)" ) .execute(&pool) .await @@ -227,4 +256,48 @@ mod tests { assert!(result.is_some()); assert_eq!(result.unwrap().email().value(), "test@example.com"); } + + #[tokio::test] + async fn update_profile_persists_bio_and_avatar() { + let (_, repo) = setup().await; + let user = domain::models::User::new( + Email::new("test@example.com".to_string()).unwrap(), + Username::new("testuser".to_string()).unwrap(), + PasswordHash::new("hash".to_string()).unwrap(), + UserRole::Standard, + ); + repo.save(&user).await.unwrap(); + + repo.update_profile( + user.id(), + Some("My biography".to_string()), + Some("avatars/user1".to_string()), + ) + .await + .unwrap(); + + let found = repo.find_by_id(user.id()).await.unwrap().unwrap(); + assert_eq!(found.bio(), Some("My biography")); + assert_eq!(found.avatar_path(), Some("avatars/user1")); + } + + #[tokio::test] + async fn update_profile_clears_fields_with_none() { + let (_, repo) = setup().await; + let user = domain::models::User::new( + Email::new("test2@example.com".to_string()).unwrap(), + Username::new("testuser2".to_string()).unwrap(), + PasswordHash::new("hash".to_string()).unwrap(), + UserRole::Standard, + ); + repo.save(&user).await.unwrap(); + repo.update_profile(user.id(), Some("bio".to_string()), Some("path".to_string())) + .await + .unwrap(); + repo.update_profile(user.id(), None, None).await.unwrap(); + + let found = repo.find_by_id(user.id()).await.unwrap().unwrap(); + assert_eq!(found.bio(), None); + assert_eq!(found.avatar_path(), None); + } } diff --git a/crates/adapters/template-askama/src/lib.rs b/crates/adapters/template-askama/src/lib.rs index d750c68..1ba7986 100644 --- a/crates/adapters/template-askama/src/lib.rs +++ b/crates/adapters/template-askama/src/lib.rs @@ -2,7 +2,7 @@ use application::ports::{ ActivityFeedPageData, FollowersPageData, FollowingPageData, HtmlPageContext, HtmlRenderer, ImportMappingPageData, ImportPreviewPageData, ImportPreviewRow, ImportProfileView, ImportRowStatus, ImportUploadPageData, LoginPageData, MovieDetailPageData, NewReviewPageData, - ProfilePageData, RegisterPageData, UsersPageData, + ProfilePageData, ProfileSettingsPageData, RegisterPageData, UsersPageData, }; use askama::Template; use chrono::Datelike; @@ -305,6 +305,15 @@ fn bar_height_px(avg_rating: f64) -> i64 { (avg_rating / 5.0 * 60.0) as i64 } +#[derive(Template)] +#[template(path = "profile_settings.html")] +struct ProfileSettingsTemplate<'a> { + ctx: &'a HtmlPageContext, + bio: Option<&'a str>, + avatar_url: Option<&'a str>, + saved: bool, +} + #[derive(Template)] #[template(path = "import_upload.html")] struct ImportUploadTemplate<'a> { @@ -649,4 +658,18 @@ impl HtmlRenderer for AskamaHtmlRenderer { .render() .map_err(|e| e.to_string()) } + + fn render_profile_settings_page( + &self, + data: ProfileSettingsPageData, + ) -> Result { + ProfileSettingsTemplate { + ctx: &data.ctx, + bio: data.bio.as_deref(), + avatar_url: data.avatar_url.as_deref(), + saved: data.saved, + } + .render() + .map_err(|e| e.to_string()) + } } diff --git a/crates/adapters/template-askama/templates/profile_settings.html b/crates/adapters/template-askama/templates/profile_settings.html new file mode 100644 index 0000000..9ecebec --- /dev/null +++ b/crates/adapters/template-askama/templates/profile_settings.html @@ -0,0 +1,25 @@ +{% extends "base.html" %} +{% block content %} +

Profile Settings

+{% if saved %} +

Saved.

+{% endif %} +
+ + + {% if let Some(url) = avatar_url %} +
+

Current avatar:

+ Current avatar +
+ {% endif %} + + +
+{% endblock %} diff --git a/crates/application/src/context.rs b/crates/application/src/context.rs index 98f51e0..0561a99 100644 --- a/crates/application/src/context.rs +++ b/crates/application/src/context.rs @@ -2,9 +2,10 @@ use std::sync::Arc; use domain::ports::{ AuthService, DiaryExporter, DiaryRepository, DocumentParser, EventPublisher, + ImageStorage, ImportProfileRepository, ImportSessionRepository, MetadataClient, MovieRepository, PasswordHasher, PosterFetcherClient, - PosterStorage, ReviewRepository, StatsRepository, UserRepository, + ReviewRepository, StatsRepository, UserRepository, }; use crate::config::AppConfig; @@ -19,7 +20,7 @@ pub struct AppContext { pub stats_repository: Arc, pub metadata_client: Arc, pub poster_fetcher: Arc, - pub poster_storage: Arc, + pub image_storage: Arc, pub event_publisher: Arc, pub auth_service: Arc, pub password_hasher: Arc, diff --git a/crates/application/src/ports.rs b/crates/application/src/ports.rs index fcda9b0..ca95ca6 100644 --- a/crates/application/src/ports.rs +++ b/crates/application/src/ports.rs @@ -145,6 +145,13 @@ pub struct ImportPreviewPageData { pub rows: Vec, } +pub struct ProfileSettingsPageData { + pub ctx: HtmlPageContext, + pub bio: Option, + pub avatar_url: Option, + pub saved: bool, +} + pub trait HtmlRenderer: Send + Sync { fn render_diary_page( &self, @@ -163,6 +170,10 @@ pub trait HtmlRenderer: Send + Sync { fn render_import_upload_page(&self, data: ImportUploadPageData) -> Result; fn render_import_mapping_page(&self, data: ImportMappingPageData) -> Result; fn render_import_preview_page(&self, data: ImportPreviewPageData) -> Result; + fn render_profile_settings_page( + &self, + data: ProfileSettingsPageData, + ) -> Result; } pub trait RssFeedRenderer: Send + Sync { diff --git a/crates/application/src/use_cases/mod.rs b/crates/application/src/use_cases/mod.rs index f341419..7579425 100644 --- a/crates/application/src/use_cases/mod.rs +++ b/crates/application/src/use_cases/mod.rs @@ -18,3 +18,4 @@ pub mod log_review; pub mod login; pub mod register; pub mod sync_poster; +pub mod update_profile; diff --git a/crates/application/src/use_cases/sync_poster.rs b/crates/application/src/use_cases/sync_poster.rs index c1adfa2..5fd8d9c 100644 --- a/crates/application/src/use_cases/sync_poster.rs +++ b/crates/application/src/use_cases/sync_poster.rs @@ -1,6 +1,6 @@ use domain::{ errors::DomainError, - value_objects::{ExternalMetadataId, MovieId}, + value_objects::{ExternalMetadataId, MovieId, PosterPath}, }; use crate::{commands::SyncPosterCommand, context::AppContext}; @@ -36,11 +36,12 @@ pub async fn execute(ctx: &AppContext, cmd: SyncPosterCommand) -> Result<(), Dom let image_bytes = ctx.poster_fetcher.fetch_poster_bytes(&poster_url).await?; let stored_path = ctx - .poster_storage - .store_poster(&movie_id, &image_bytes) + .image_storage + .store(&movie_id.value().to_string(), &image_bytes) .await?; + let poster_path = PosterPath::new(stored_path)?; - movie.update_poster(stored_path); + movie.update_poster(poster_path); ctx.movie_repository.upsert_movie(&movie).await?; Ok(()) diff --git a/crates/application/src/use_cases/update_profile.rs b/crates/application/src/use_cases/update_profile.rs new file mode 100644 index 0000000..e055d99 --- /dev/null +++ b/crates/application/src/use_cases/update_profile.rs @@ -0,0 +1,51 @@ +use domain::{ + errors::DomainError, + events::DomainEvent, + value_objects::UserId, +}; + +use crate::context::AppContext; + +pub struct UpdateProfileCommand { + pub user_id: uuid::Uuid, + pub bio: Option, + pub avatar_bytes: Option>, + pub avatar_content_type: Option, +} + +pub async fn execute(ctx: &AppContext, cmd: UpdateProfileCommand) -> Result<(), DomainError> { + let user_id = UserId::from_uuid(cmd.user_id); + + let user = ctx + .user_repository + .find_by_id(&user_id) + .await? + .ok_or_else(|| DomainError::NotFound("User not found".into()))?; + + 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(), + )); + } + 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?; + Some(stored) + } else { + user.avatar_path().map(|s| s.to_string()) + }; + + ctx.user_repository + .update_profile(&user_id, cmd.bio, new_avatar_path) + .await?; + + ctx.event_publisher + .publish(&DomainEvent::UserUpdated { user_id }) + .await?; + + Ok(()) +} diff --git a/crates/application/src/worker.rs b/crates/application/src/worker.rs index ab13714..931c48d 100644 --- a/crates/application/src/worker.rs +++ b/crates/application/src/worker.rs @@ -94,6 +94,7 @@ mod tests { DomainEvent::ReviewLogged { .. } => "review_logged", DomainEvent::ReviewUpdated { .. } => "review_updated", DomainEvent::MovieDeleted { .. } => "movie_deleted", + DomainEvent::UserUpdated { .. } => "user_updated", }; self.calls.lock().unwrap().push(label); Ok(()) diff --git a/crates/domain/src/events.rs b/crates/domain/src/events.rs index 0d2df94..463daf6 100644 --- a/crates/domain/src/events.rs +++ b/crates/domain/src/events.rs @@ -30,6 +30,9 @@ pub enum DomainEvent { movie_id: MovieId, poster_path: Option, }, + UserUpdated { + user_id: UserId, + }, } #[async_trait] diff --git a/crates/domain/src/models/mod.rs b/crates/domain/src/models/mod.rs index f80b6f4..360ab6e 100644 --- a/crates/domain/src/models/mod.rs +++ b/crates/domain/src/models/mod.rs @@ -290,6 +290,8 @@ pub struct User { username: Username, password_hash: PasswordHash, role: UserRole, + bio: Option, + avatar_path: Option, } impl User { @@ -305,6 +307,8 @@ impl User { username, password_hash, role, + bio: None, + avatar_path: None, } } @@ -314,6 +318,8 @@ impl User { username: Username, password_hash: PasswordHash, role: UserRole, + bio: Option, + avatar_path: Option, ) -> Self { Self { id, @@ -321,6 +327,8 @@ impl User { username, password_hash, role, + bio, + avatar_path, } } @@ -328,6 +336,11 @@ impl User { self.password_hash = new_hash; } + pub fn update_profile(&mut self, bio: Option, avatar_path: Option) { + self.bio = bio; + self.avatar_path = avatar_path; + } + pub fn email(&self) -> &Email { &self.email } @@ -343,6 +356,13 @@ impl User { pub fn role(&self) -> &UserRole { &self.role } + pub fn bio(&self) -> Option<&str> { + self.bio.as_deref() + } + + pub fn avatar_path(&self) -> Option<&str> { + self.avatar_path.as_deref() + } } #[derive(Clone, Debug)] @@ -435,3 +455,38 @@ pub enum ExportFormat { Csv, Json, } + +#[cfg(test)] +mod tests { + use super::*; + use crate::value_objects::{Email, PasswordHash, UserId, Username}; + + fn make_user() -> User { + User::from_persistence( + UserId::generate(), + Email::new("a@b.com".to_string()).unwrap(), + Username::new("alice".to_string()).unwrap(), + PasswordHash::new("hash".to_string()).unwrap(), + UserRole::Standard, + None, + None, + ) + } + + #[test] + fn update_profile_sets_fields() { + let mut user = make_user(); + user.update_profile(Some("My bio".to_string()), Some("avatars/abc".to_string())); + assert_eq!(user.bio(), Some("My bio")); + assert_eq!(user.avatar_path(), Some("avatars/abc")); + } + + #[test] + fn update_profile_clears_with_none() { + let mut user = make_user(); + user.update_profile(Some("bio".to_string()), Some("path".to_string())); + user.update_profile(None, None); + assert_eq!(user.bio(), None); + assert_eq!(user.avatar_path(), None); + } +} diff --git a/crates/domain/src/ports.rs b/crates/domain/src/ports.rs index b77a7f3..3f3fb01 100644 --- a/crates/domain/src/ports.rs +++ b/crates/domain/src/ports.rs @@ -12,7 +12,7 @@ use crate::{ }, value_objects::{ Email, ExternalMetadataId, ImportProfileId, ImportSessionId, MovieId, MovieTitle, - PasswordHash, PosterPath, PosterUrl, ReleaseYear, ReviewId, UserId, Username, + PasswordHash, PosterUrl, ReleaseYear, ReviewId, UserId, Username, }, }; @@ -150,16 +150,11 @@ pub trait PosterFetcherClient: Send + Sync { } #[async_trait] -pub trait PosterStorage: Send + Sync { - async fn store_poster( - &self, - movie_id: &MovieId, - image_bytes: &[u8], - ) -> Result; - - async fn get_poster(&self, poster_path: &PosterPath) -> Result, DomainError>; - - async fn delete_poster(&self, path: &PosterPath) -> Result<(), DomainError>; +pub trait ImageStorage: Send + Sync { + /// Stores `image_bytes` at `key` and returns the stored key. + async fn store(&self, key: &str, image_bytes: &[u8]) -> Result; + async fn get(&self, key: &str) -> Result, DomainError>; + async fn delete(&self, key: &str) -> Result<(), DomainError>; } pub struct GeneratedToken { @@ -180,6 +175,12 @@ pub trait UserRepository: Send + Sync { async fn save(&self, user: &User) -> Result<(), DomainError>; async fn find_by_id(&self, id: &UserId) -> Result, DomainError>; async fn list_with_stats(&self) -> Result, DomainError>; + async fn update_profile( + &self, + user_id: &UserId, + bio: Option, + avatar_path: Option, + ) -> Result<(), DomainError>; } #[async_trait] diff --git a/crates/presentation/Cargo.toml b/crates/presentation/Cargo.toml index d973faf..9966a50 100644 --- a/crates/presentation/Cargo.toml +++ b/crates/presentation/Cargo.toml @@ -47,7 +47,7 @@ application = { workspace = true } auth = { workspace = true } metadata = { workspace = true } poster-fetcher = { workspace = true } -poster-storage = { workspace = true } +image-storage = { workspace = true } template-askama = { workspace = true } nats = { workspace = true, optional = true } rss = { workspace = true } diff --git a/crates/presentation/src/dtos.rs b/crates/presentation/src/dtos.rs index dee9d61..d261862 100644 --- a/crates/presentation/src/dtos.rs +++ b/crates/presentation/src/dtos.rs @@ -433,6 +433,13 @@ pub struct PaginationQueryParams { pub offset: Option, } +#[derive(serde::Serialize, utoipa::ToSchema)] +pub struct ProfileResponse { + pub username: String, + pub bio: Option, + pub avatar_url: Option, +} + #[derive(serde::Serialize, utoipa::ToSchema)] pub struct MovieStatsDto { pub total_count: u64, diff --git a/crates/presentation/src/extractors.rs b/crates/presentation/src/extractors.rs index b69eb01..a2c7a26 100644 --- a/crates/presentation/src/extractors.rs +++ b/crates/presentation/src/extractors.rs @@ -137,12 +137,12 @@ mod tests { collections::{PageParams, Paginated}, }, ports::{ - AuthService, DiaryRepository, EventPublisher, GeneratedToken, MetadataClient, - MovieRepository, PasswordHasher, PosterFetcherClient, PosterStorage, ReviewRepository, + AuthService, DiaryRepository, EventPublisher, GeneratedToken, ImageStorage, + MetadataClient, MovieRepository, PasswordHasher, PosterFetcherClient, ReviewRepository, StatsRepository, UserRepository, }, value_objects::{ - Email, ExternalMetadataId, MovieId, MovieTitle, PasswordHash, PosterPath, PosterUrl, + Email, ExternalMetadataId, MovieId, MovieTitle, PasswordHash, PosterUrl, ReleaseYear, ReviewId, UserId, }, }; @@ -279,16 +279,10 @@ mod tests { } } #[async_trait::async_trait] - impl PosterStorage for Panic { - async fn store_poster(&self, _: &MovieId, _: &[u8]) -> Result { - panic!() - } - async fn get_poster(&self, _: &PosterPath) -> Result, DomainError> { - panic!() - } - async fn delete_poster(&self, _: &PosterPath) -> Result<(), DomainError> { - panic!() - } + impl ImageStorage for Panic { + async fn store(&self, _: &str, _: &[u8]) -> Result { panic!() } + async fn get(&self, _: &str) -> Result, DomainError> { panic!() } + async fn delete(&self, _: &str) -> Result<(), DomainError> { panic!() } } #[async_trait::async_trait] impl AuthService for Panic { @@ -334,6 +328,9 @@ mod tests { async fn list_with_stats(&self) -> Result, DomainError> { panic!() } + async fn update_profile(&self, _: &UserId, _: Option, _: Option) -> Result<(), DomainError> { + panic!() + } } #[async_trait::async_trait] impl EventPublisher for Panic { @@ -442,6 +439,7 @@ mod tests { fn render_import_upload_page(&self, _: application::ports::ImportUploadPageData) -> Result { panic!() } fn render_import_mapping_page(&self, _: application::ports::ImportMappingPageData) -> Result { panic!() } fn render_import_preview_page(&self, _: application::ports::ImportPreviewPageData) -> Result { panic!() } + fn render_profile_settings_page(&self, _: application::ports::ProfileSettingsPageData) -> Result { panic!() } } impl crate::ports::RssFeedRenderer for Panic { fn render_feed(&self, _: &[DiaryEntry], _: &str) -> Result { @@ -474,7 +472,7 @@ mod tests { stats_repository: Arc::clone(&repo) as _, metadata_client: Arc::clone(&repo) as _, poster_fetcher: Arc::clone(&repo) as _, - poster_storage: Arc::clone(&repo) as _, + image_storage: Arc::clone(&repo) as _, event_publisher: Arc::clone(&repo) as _, password_hasher: Arc::clone(&repo) as _, user_repository: Arc::clone(&repo) as _, diff --git a/crates/presentation/src/handlers/api.rs b/crates/presentation/src/handlers/api.rs index 6ec7159..fb6e089 100644 --- a/crates/presentation/src/handlers/api.rs +++ b/crates/presentation/src/handlers/api.rs @@ -1,6 +1,6 @@ use axum::{ Json, - extract::{Path, Query, State}, + extract::{Multipart, Path, Query, State}, http::StatusCode, response::IntoResponse, }; @@ -20,7 +20,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_review_history, get_user_profile as get_user_profile_uc, get_users, log_review, login as login_uc, - register as register_uc, sync_poster, + register as register_uc, sync_poster, update_profile, }, }; use domain::{ @@ -37,8 +37,8 @@ use crate::{ ActivityFeedQueryParams, ActivityFeedResponse, DiaryEntryDto, DiaryQueryParams, DiaryResponse, DirectorStatDto, ExportQueryParams, FeedEntryDto, LogReviewData, LogReviewRequest, LoginRequest, LoginResponse, MonthActivityDto, MonthlyRatingDto, - MovieDetailResponse, MovieDto, MovieStatsDto, PaginationQueryParams, RegisterRequest, - ReviewDto, ReviewHistoryResponse, SocialFeedResponse, SocialReviewDto, + MovieDetailResponse, MovieDto, MovieStatsDto, PaginationQueryParams, ProfileResponse, + RegisterRequest, ReviewDto, ReviewHistoryResponse, SocialFeedResponse, SocialReviewDto, UserProfileQueryParams, UserProfileResponse, UserStatsDto, UserSummaryDto, UserTrendsDto, UsersResponse, }, @@ -290,6 +290,101 @@ pub async fn get_movie_detail( })) } +#[utoipa::path( + get, path = "/api/v1/profile", + responses( + (status = 200, body = ProfileResponse), + (status = 401, description = "Unauthorized"), + (status = 404, description = "User not found"), + ), + security(("bearer_auth" = [])) +)] +pub async fn get_profile( + State(state): State, + AuthenticatedUser(user_id): AuthenticatedUser, +) -> impl IntoResponse { + let user = match state.app_ctx.user_repository.find_by_id(&user_id).await { + Ok(Some(u)) => u, + Ok(None) => return StatusCode::NOT_FOUND.into_response(), + Err(e) => { + tracing::error!("get_profile user lookup: {:?}", e); + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + } + }; + + let base_url = &state.app_ctx.config.base_url; + let avatar_url = user + .avatar_path() + .map(|path| format!("{}/images/{}", base_url, path)); + + Json(ProfileResponse { + username: user.username().value().to_string(), + bio: user.bio().map(|s| s.to_string()), + avatar_url, + }) + .into_response() +} + +#[utoipa::path( + put, path = "/api/v1/profile", + responses( + (status = 204, description = "Profile updated"), + (status = 400, description = "Invalid input"), + (status = 401, description = "Unauthorized"), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] +pub async fn update_profile_handler( + State(state): State, + AuthenticatedUser(user_id): AuthenticatedUser, + mut multipart: Multipart, +) -> impl IntoResponse { + let mut bio: Option = None; + let mut avatar_bytes: Option> = None; + let mut avatar_content_type: Option = None; + + while let Ok(Some(field)) = multipart.next_field().await { + let name = field.name().unwrap_or("").to_string(); + match name.as_str() { + "bio" => { + if let Ok(text) = field.text().await { + bio = Some(text); + } + } + "avatar" => { + let content_type = 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 = content_type; + } + } + } + _ => {} + } + } + + let cmd = update_profile::UpdateProfileCommand { + user_id: user_id.value(), + bio, + avatar_bytes, + avatar_content_type, + }; + + match update_profile::execute(&state.app_ctx, cmd).await { + Ok(()) => StatusCode::NO_CONTENT.into_response(), + Err(domain::errors::DomainError::ValidationError(msg)) => { + tracing::warn!("update_profile validation: {}", msg); + StatusCode::BAD_REQUEST.into_response() + } + Err(e) => { + tracing::error!("update_profile 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 ff2cf73..f421688 100644 --- a/crates/presentation/src/handlers/html.rs +++ b/crates/presentation/src/handlers/html.rs @@ -2,7 +2,7 @@ use std::str::FromStr; use axum::{ Form, - extract::{Extension, Path, Query, State}, + extract::{Extension, Multipart, Path, Query, State}, http::{HeaderValue, StatusCode, header::SET_COOKIE}, response::{Html, IntoResponse, Redirect}, }; @@ -14,13 +14,13 @@ use application::ports::{FollowersPageData, FollowingPageData}; use application::{ commands::{DeleteReviewCommand, ExportCommand, LoginCommand, RegisterCommand}, ports::{ - HtmlPageContext, LoginPageData, MovieDetailPageData, NewReviewPageData, RegisterPageData, - RemoteActorView, + HtmlPageContext, LoginPageData, MovieDetailPageData, NewReviewPageData, + ProfileSettingsPageData, RegisterPageData, RemoteActorView, }, queries::GetMovieSocialPageQuery, use_cases::{ delete_review, export_diary as export_diary_uc, get_movie_social_page, log_review, - login as login_uc, register as register_uc, + login as login_uc, register as register_uc, update_profile, }, }; use domain::models::ExportFormat; @@ -966,3 +966,97 @@ pub async fn get_movie_detail( } } } + +#[derive(serde::Deserialize, Default)] +pub struct SavedQuery { + pub saved: Option, +} + +pub async fn get_profile_settings( + RequiredCookieUser(user_id): RequiredCookieUser, + State(state): State, + Query(params): Query, + Extension(csrf): Extension, +) -> impl IntoResponse { + let mut ctx = build_page_context(&state, Some(user_id.clone()), csrf.0).await; + ctx.page_title = "Profile Settings — Movies Diary".to_string(); + ctx.canonical_url = format!("{}/settings/profile", state.app_ctx.config.base_url); + + let user = match state + .app_ctx + .user_repository + .find_by_id(&user_id) + .await + { + Ok(Some(u)) => u, + Ok(None) => return StatusCode::NOT_FOUND.into_response(), + Err(e) => { + tracing::error!("get_profile_settings user lookup: {:?}", e); + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + } + }; + + let base_url = &state.app_ctx.config.base_url; + let avatar_url = user + .avatar_path() + .map(|path| format!("{}/images/{}", base_url, path)); + + let saved = params.saved.as_deref() == Some("1"); + + let data = ProfileSettingsPageData { + ctx, + bio: user.bio().map(|s| s.to_string()), + avatar_url, + saved, + }; + + match state.html_renderer.render_profile_settings_page(data) { + Ok(html) => Html(html).into_response(), + Err(e) => { + tracing::error!("profile_settings template error: {}", e); + StatusCode::INTERNAL_SERVER_ERROR.into_response() + } + } +} + +pub async fn post_profile_settings( + RequiredCookieUser(user_id): RequiredCookieUser, + State(state): State, + mut multipart: Multipart, +) -> impl IntoResponse { + let mut bio: Option = None; + let mut avatar_bytes: Option> = None; + let mut avatar_content_type: Option = None; + + while let Ok(Some(field)) = multipart.next_field().await { + let name = field.name().unwrap_or("").to_string(); + match name.as_str() { + "bio" => { + if let Ok(text) = field.text().await { + bio = Some(text); + } + } + "avatar" => { + let content_type = 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 = content_type; + } + } + } + _ => {} + } + } + + let cmd = update_profile::UpdateProfileCommand { + user_id: user_id.value(), + bio, + avatar_bytes, + avatar_content_type, + }; + + let _ = update_profile::execute(&state.app_ctx, cmd).await; + + Redirect::to("/settings/profile?saved=1").into_response() +} diff --git a/crates/presentation/src/handlers/images.rs b/crates/presentation/src/handlers/images.rs new file mode 100644 index 0000000..f04fd82 --- /dev/null +++ b/crates/presentation/src/handlers/images.rs @@ -0,0 +1,25 @@ +use axum::{ + extract::{Path, State}, + http::{StatusCode, header}, + response::IntoResponse, +}; + +use crate::state::AppState; + +pub async fn get_image( + State(state): State, + Path(key): Path, +) -> impl IntoResponse { + if key.starts_with("http://") || key.starts_with("https://") { + return axum::response::Redirect::temporary(&key).into_response(); + } + match state.app_ctx.image_storage.get(&key).await { + Ok(bytes) => { + let mime = infer::get(&bytes) + .map(|t| t.mime_type()) + .unwrap_or("application/octet-stream"); + ([(header::CONTENT_TYPE, mime)], bytes).into_response() + } + Err(_) => StatusCode::NOT_FOUND.into_response(), + } +} diff --git a/crates/presentation/src/handlers/mod.rs b/crates/presentation/src/handlers/mod.rs index 68fea13..4cb18e5 100644 --- a/crates/presentation/src/handlers/mod.rs +++ b/crates/presentation/src/handlers/mod.rs @@ -1,5 +1,5 @@ pub mod html; -pub mod posters; +pub mod images; pub mod rss; pub mod api; pub mod import; diff --git a/crates/presentation/src/handlers/posters.rs b/crates/presentation/src/handlers/posters.rs deleted file mode 100644 index d6b3024..0000000 --- a/crates/presentation/src/handlers/posters.rs +++ /dev/null @@ -1,33 +0,0 @@ -use axum::{ - extract::{Path, State}, - http::{StatusCode, header}, - response::IntoResponse, -}; - -use domain::value_objects::PosterPath; - -use crate::state::AppState; - -pub async fn get_poster( - State(state): State, - Path(path): Path, -) -> impl IntoResponse { - // If path is a remote URL, redirect directly instead of serving from local storage. - if path.starts_with("http://") || path.starts_with("https://") { - return axum::response::Redirect::temporary(&path).into_response(); - } - - let poster_path = match PosterPath::new(path) { - Ok(p) => p, - Err(_) => return StatusCode::BAD_REQUEST.into_response(), - }; - match state.app_ctx.poster_storage.get_poster(&poster_path).await { - Ok(bytes) => { - let mime = infer::get(&bytes) - .map(|t| t.mime_type()) - .unwrap_or("application/octet-stream"); - ([(header::CONTENT_TYPE, mime)], bytes).into_response() - } - Err(_) => StatusCode::NOT_FOUND.into_response(), - } -} diff --git a/crates/presentation/src/main.rs b/crates/presentation/src/main.rs index b476f77..9b93314 100644 --- a/crates/presentation/src/main.rs +++ b/crates/presentation/src/main.rs @@ -49,7 +49,7 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> { let (auth_service, password_hasher) = auth::create()?; let metadata_client = metadata::create()?; let poster_fetcher = poster_fetcher::create()?; - let poster_storage = poster_storage::create()?; + let image_storage = image_storage::create()?; let (movie_repository, review_repository, diary_repository, stats_repository, user_repository, import_session_repository, import_profile_repository, db_pool) = match backend.as_str() { @@ -155,7 +155,7 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> { stats_repository, metadata_client, poster_fetcher, - poster_storage, + image_storage, event_publisher: event_publisher_arc, auth_service, password_hasher, diff --git a/crates/presentation/src/routes.rs b/crates/presentation/src/routes.rs index 6dec425..116b774 100644 --- a/crates/presentation/src/routes.rs +++ b/crates/presentation/src/routes.rs @@ -74,8 +74,14 @@ fn html_routes(rate_limit: u64) -> Router { routing::post(handlers::html::post_delete_review), ) .route( - "/posters/{*path}", - routing::get(handlers::posters::get_poster), + "/images/{*key}", + routing::get(handlers::images::get_image), + ) + .route( + "/posters/{path}", + routing::get(|axum::extract::Path(p): axum::extract::Path| async move { + axum::response::Redirect::permanent(&format!("/images/{}", p)) + }), ) .route("/diary/export", routing::get(handlers::html::get_export)) .route("/import", routing::get(handlers::import::get_import_page)) @@ -89,6 +95,11 @@ fn html_routes(rate_limit: u64) -> Router { .route( "/users/{id}/feed.rss", routing::get(handlers::rss::get_user_feed), + ) + .route( + "/settings/profile", + routing::get(handlers::html::get_profile_settings) + .post(handlers::html::post_profile_settings), ); #[cfg(feature = "federation")] @@ -171,7 +182,8 @@ fn api_routes(rate_limit: u64) -> Router { .route("/import/sessions/{id}/mapping", routing::put(handlers::import::api_put_mapping)) .route("/import/sessions/{id}/confirm", routing::post(handlers::import::api_post_confirm)) .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("/import/profiles/{id}", routing::delete(handlers::import::api_delete_profile)) + .route("/profile", routing::get(handlers::api::get_profile).put(handlers::api::update_profile_handler)); #[cfg(feature = "federation")] let base = base.merge(federation_api_routes()); diff --git a/crates/presentation/tests/api_test.rs b/crates/presentation/tests/api_test.rs index 637329a..0991c56 100644 --- a/crates/presentation/tests/api_test.rs +++ b/crates/presentation/tests/api_test.rs @@ -12,11 +12,11 @@ use domain::{ events::DomainEvent, models::{Movie, User}, ports::{ - AuthService, EventPublisher, GeneratedToken, MetadataClient, MetadataSearchCriteria, - PasswordHasher, PosterFetcherClient, PosterStorage, UserRepository, + AuthService, EventPublisher, GeneratedToken, ImageStorage, MetadataClient, MetadataSearchCriteria, + PasswordHasher, PosterFetcherClient, UserRepository, }, value_objects::{ - Email, ExternalMetadataId, MovieId, PasswordHash, PosterPath, PosterUrl, UserId, + Email, ExternalMetadataId, PasswordHash, PosterUrl, UserId, }, }; use http_body_util::BodyExt; @@ -57,18 +57,12 @@ impl PosterFetcherClient for PanicFetcher { } } -struct PanicStorage; +struct PanicImageStorage; #[async_trait] -impl PosterStorage for PanicStorage { - async fn store_poster(&self, _: &MovieId, _: &[u8]) -> Result { - panic!() - } - async fn get_poster(&self, _: &PosterPath) -> Result, DomainError> { - panic!() - } - async fn delete_poster(&self, _: &PosterPath) -> Result<(), DomainError> { - panic!() - } +impl ImageStorage for PanicImageStorage { + async fn store(&self, _: &str, _: &[u8]) -> Result { panic!() } + async fn get(&self, _: &str) -> Result, DomainError> { panic!() } + async fn delete(&self, _: &str) -> Result<(), DomainError> { panic!() } } struct PanicHasher; @@ -114,6 +108,9 @@ impl UserRepository for NobodyUserRepo { async fn list_with_stats(&self) -> Result, DomainError> { panic!() } + async fn update_profile(&self, _: &UserId, _: Option, _: Option) -> Result<(), DomainError> { + Ok(()) + } } struct PanicExporter; @@ -194,7 +191,7 @@ async fn test_app() -> Router { stats_repository: Arc::clone(&repo) as _, metadata_client: Arc::new(PanicMeta), poster_fetcher: Arc::new(PanicFetcher), - poster_storage: Arc::new(PanicStorage), + image_storage: Arc::new(PanicImageStorage), event_publisher: Arc::new(NoopEventPublisher), auth_service: Arc::new(PanicAuth), password_hasher: Arc::new(PanicHasher), diff --git a/crates/worker/Cargo.toml b/crates/worker/Cargo.toml index 92c3f29..a7595f4 100644 --- a/crates/worker/Cargo.toml +++ b/crates/worker/Cargo.toml @@ -30,7 +30,7 @@ async-trait = { workspace = true } auth = { workspace = true } metadata = { workspace = true } poster-fetcher = { workspace = true } -poster-storage = { workspace = true } +image-storage = { workspace = true } poster-sync = { workspace = true } export = { workspace = true } importer = { workspace = true } diff --git a/crates/worker/src/main.rs b/crates/worker/src/main.rs index 322ccce..24d09eb 100644 --- a/crates/worker/src/main.rs +++ b/crates/worker/src/main.rs @@ -23,7 +23,7 @@ async fn main() -> anyhow::Result<()> { let (auth_service, password_hasher) = auth::create()?; let metadata_client = metadata::create()?; let poster_fetcher = poster_fetcher::create()?; - let poster_storage = poster_storage::create()?; + let image_storage = image_storage::create()?; let (movie_repository, review_repository, diary_repository, stats_repository, user_repository, import_session_repository, import_profile_repository, db_pool) = match backend.as_str() { @@ -82,7 +82,7 @@ async fn main() -> anyhow::Result<()> { stats_repository, metadata_client, poster_fetcher, - poster_storage, + image_storage, event_publisher: event_publisher_arc, auth_service, password_hasher, @@ -112,12 +112,12 @@ async fn main() -> anyhow::Result<()> { Arc::clone(&ctx.movie_repository), Arc::clone(&ctx.metadata_client), Arc::clone(&ctx.poster_fetcher), - Arc::clone(&ctx.poster_storage), + Arc::clone(&ctx.image_storage), 3, )) as Arc; - let cleanup = Arc::new(poster_storage::PosterCleanupHandler::new( - Arc::clone(&ctx.poster_storage), + let cleanup = Arc::new(image_storage::ImageCleanupHandler::new( + Arc::clone(&ctx.image_storage), )) as Arc; #[cfg(not(feature = "federation"))]