Compare commits

...

6 Commits

Author SHA1 Message Date
c3b89f6dc6 refactor: extract business logic from handlers to application layer
Some checks failed
CI / Check / Test (push) Has been cancelled
CI / Release build (push) Has been cancelled
Move domain logic out of 7 handlers into use cases:
- activity feed: FollowingFilter construction
- user profile: social counts + pending followers
- users list: parallel local+remote actor loading
- watchlist page: local-vs-remote branching
- sync_poster: movie lookup + validation
- get_profile: avatar URL construction
- post_register: register+login orchestration

Add SocialQueryPort.{count_following,count_accepted_followers,
get_pending_followers} to AppContext behind federation feature gate.
2026-05-29 11:41:16 +02:00
2355f89bed refactor: fix all clippy warnings properly
- UserProfile struct groups display_name/bio/avatar/banner/also_known_as/profile_fields
- User::from_persistence takes UserProfile (6 args, was 11)
- PersistedReview struct for Review::from_persistence (1 arg, was 8)
- WatchlistApInput struct for watchlist_to_ap_object (1 arg, was 8)
- ActivityPubDeps struct for activitypub::wire (1 arg, was 11)
- FederationRepos type alias for wire() return types
- FeedSortBy: impl std::str::FromStr instead of inherent from_str
- postgres users.rs: row_to_user takes &PgRow like sqlite
- collapse nested ifs in multipart handlers
- type alias for complex return types (image-converter, worker)
- tui: allow large_enum_variant at crate level (pre-existing, unrelated)
2026-05-29 11:19:02 +02:00
68a939f6c4 Refactor code for improved readability and consistency
- Simplified error handling in `PostgresApContentQuery` and `SqliteApContentQuery` by aligning the formatting of `try_get` calls.
- Removed unnecessary line breaks and improved formatting in various repository implementations for better readability.
- Consolidated imports in `lib.rs` and `factory.rs` to maintain a cleaner structure.
- Enhanced consistency in async function signatures across multiple files.
- Updated test helpers and use cases to streamline code and improve clarity.
- Refactored `InMemory` repositories to enhance readability by aligning method implementations.
2026-05-29 10:58:44 +02:00
412ab12695 feat: add Makefile for local development checks and formatting 2026-05-29 10:58:20 +02:00
36d15e1344 fix: AP bugs — backfill mapping, review activity type, also_known_as parse
- BackfillRequested now maps to BackfillFollower domain event (not FollowAccepted);
  worker calls run_backfill_for_follower to send LOCAL content to new follower inbox,
  instead of incorrectly trying to import from an inbox URL as if it were an outbox
- reviews broadcast as Create activity instead of Add (semantically correct)
- also_known_as JSON parse failure logs warning + preserves raw string as single-element
  vec instead of silently returning empty
2026-05-29 10:54:11 +02:00
624cfe5799 feat: migrate k-ap 0.1.10→0.3.1, fix AP gaps
- split FederationRepository into FollowRepository, ActorRepository, BlocklistRepository, ActivityRepository
- RemoteActor: 5 new fields (bio, banner_url, followers_url, following_url, also_known_as)
- ApObjectHandler split: get_local_objects_page/count_local_posts → ApContentReader trait
- builder API: positional args → named setters
- broadcast_create_note/update_note: add ApVisibility + mentioned_inboxes params
- backfill_outbox → import_remote_outbox
- ApUser: also_known_as Option<String> → Vec<String>, new fields

AP gaps fixed:
- add GET /users/{id}/followers + /following with content negotiation
- wire EventPublisher into builder via FederationEventBridge adapter
- add display_name field full stack (domain→DB→API→AP)
- DB-side outbox pagination (get_local_reviews_page)
- set featured_url on ApUser
2026-05-29 10:42:53 +02:00
64 changed files with 2405 additions and 1716 deletions

22
Cargo.lock generated
View File

@@ -2770,8 +2770,9 @@ dependencies = [
[[package]] [[package]]
name = "k-ap" name = "k-ap"
version = "0.1.10" version = "0.3.1"
source = "git+https://git.gabrielkaszewski.dev/GKaszewski/k-ap.git?tag=v0.1.10#d80cfd0431205498161db8665fd884710866ca95" source = "sparse+https://git.gabrielkaszewski.dev/api/packages/GKaszewski/cargo/"
checksum = "f73de37ac4feab6d7b78e73c60acbb07933c2be58dcbb12e8a34201f66e0480d"
dependencies = [ dependencies = [
"activitypub_federation", "activitypub_federation",
"anyhow", "anyhow",
@@ -2787,6 +2788,7 @@ dependencies = [
"tracing", "tracing",
"url", "url",
"uuid", "uuid",
"zeroize",
] ]
[[package]] [[package]]
@@ -3749,6 +3751,7 @@ dependencies = [
"chrono", "chrono",
"domain", "domain",
"k-ap", "k-ap",
"serde_json",
"sqlx", "sqlx",
"tracing", "tracing",
"uuid", "uuid",
@@ -5014,6 +5017,7 @@ dependencies = [
"chrono", "chrono",
"domain", "domain",
"k-ap", "k-ap",
"serde_json",
"sqlx", "sqlx",
"tokio", "tokio",
"tracing", "tracing",
@@ -7060,6 +7064,20 @@ name = "zeroize"
version = "1.8.2" version = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
dependencies = [
"zeroize_derive",
]
[[package]]
name = "zeroize_derive"
version = "1.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]] [[package]]
name = "zerotrie" name = "zerotrie"

28
Makefile Normal file
View File

@@ -0,0 +1,28 @@
.DEFAULT_GOAL := check
# Run the full local check suite — same order as CI would.
check: fmt-check clippy test
@echo "✅ All checks passed"
# Apply rustfmt to all files.
fmt:
cargo fmt
# Check formatting without modifying files (CI-safe).
fmt-check:
cargo fmt --check
# Run Clippy and treat warnings as errors.
clippy:
cargo clippy -- -D warnings
# Run the test suite.
test:
cargo test
# Apply fmt + clippy auto-fixes in one shot.
fix:
cargo fmt
cargo clippy --fix --allow-dirty --allow-staged
.PHONY: check fmt fmt-check clippy test fix

View File

@@ -4,7 +4,7 @@ version = "0.1.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
k-ap = { git = "https://git.gabrielkaszewski.dev/GKaszewski/k-ap.git", tag = "v0.1.10" } k-ap = { version = "0.3.1", registry = "gitea" }
domain = { workspace = true } domain = { workspace = true }
axum = { workspace = true } axum = { workspace = true }
serde = { workspace = true } serde = { workspace = true }

View File

@@ -1,8 +1,8 @@
use std::sync::Arc; use std::sync::Arc;
use k_ap::ApObjectHandler;
use async_trait::async_trait; use async_trait::async_trait;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use k_ap::{ApContentReader, ApObjectHandler};
use url::Url; use url::Url;
use crate::{review_handler::ReviewObjectHandler, watchlist_handler::WatchlistObjectHandler}; use crate::{review_handler::ReviewObjectHandler, watchlist_handler::WatchlistObjectHandler};
@@ -13,16 +13,7 @@ pub struct CompositeObjectHandler {
} }
#[async_trait] #[async_trait]
impl ApObjectHandler for CompositeObjectHandler { impl ApContentReader for CompositeObjectHandler {
async fn get_local_objects_for_user(
&self,
user_id: uuid::Uuid,
) -> anyhow::Result<Vec<(Url, serde_json::Value)>> {
let mut results = self.review.get_local_objects_for_user(user_id).await?;
results.extend(self.watchlist.get_local_objects_for_user(user_id).await?);
Ok(results)
}
async fn get_local_objects_page( async fn get_local_objects_page(
&self, &self,
user_id: uuid::Uuid, user_id: uuid::Uuid,
@@ -34,6 +25,13 @@ impl ApObjectHandler for CompositeObjectHandler {
.await .await
} }
async fn count_local_posts(&self) -> anyhow::Result<u64> {
self.review.count_local_posts().await
}
}
#[async_trait]
impl ApObjectHandler for CompositeObjectHandler {
async fn on_create( async fn on_create(
&self, &self,
ap_id: &Url, ap_id: &Url,
@@ -77,15 +75,23 @@ impl ApObjectHandler for CompositeObjectHandler {
Ok(()) Ok(())
} }
async fn count_local_posts(&self) -> anyhow::Result<u64> {
self.review.count_local_posts().await
}
async fn on_like(&self, _object_url: &Url, _actor_url: &Url) -> anyhow::Result<()> { async fn on_like(&self, _object_url: &Url, _actor_url: &Url) -> anyhow::Result<()> {
Ok(()) Ok(())
} }
async fn on_announce_received(&self, _object_url: &Url, _actor_url: &Url) -> anyhow::Result<()> { async fn on_announce_received(
&self,
_object_url: &Url,
_actor_url: &Url,
) -> anyhow::Result<()> {
Ok(())
}
async fn on_announce_of_remote(
&self,
_object_url: &Url,
_actor_url: &Url,
) -> anyhow::Result<()> {
Ok(()) Ok(())
} }
@@ -93,7 +99,12 @@ impl ApObjectHandler for CompositeObjectHandler {
Ok(()) Ok(())
} }
async fn on_mention(&self, _thought_ap_id: &Url, _mentioned_user_uuid: uuid::Uuid, _actor_url: &Url) -> anyhow::Result<()> { async fn on_mention(
&self,
_thought_ap_id: &Url,
_mentioned_user_uuid: uuid::Uuid,
_actor_url: &Url,
) -> anyhow::Result<()> {
Ok(()) Ok(())
} }
} }

View File

@@ -8,7 +8,7 @@ use domain::{
}; };
use std::sync::Arc; use std::sync::Arc;
use k_ap::ActivityPubService; use k_ap::{ActivityPubService, ApVisibility};
use crate::objects::review_to_ap_object; use crate::objects::review_to_ap_object;
use crate::urls::{actor_url, review_url}; use crate::urls::{actor_url, review_url};
@@ -105,7 +105,10 @@ impl ActivityPubEventHandler {
.as_ref() .as_ref()
.map(|m| m.title().value().to_string()) .map(|m| m.title().value().to_string())
.unwrap_or_else(|| "Unknown".to_string()); .unwrap_or_else(|| "Unknown".to_string());
let release_year = movie.as_ref().map(|m| m.release_year().value()).unwrap_or(0); let release_year = movie
.as_ref()
.map(|m| m.release_year().value())
.unwrap_or(0);
let poster_url = movie let poster_url = movie
.as_ref() .as_ref()
.and_then(|m| m.poster_path()) .and_then(|m| m.poster_path())
@@ -123,7 +126,7 @@ impl ActivityPubEventHandler {
let json = serde_json::to_value(obj)?; let json = serde_json::to_value(obj)?;
self.ap_service self.ap_service
.broadcast_add_to_followers(user_id.value(), ap_id, json) .broadcast_create_note(user_id.value(), json, ApVisibility::Public, vec![])
.await?; .await?;
Ok(()) Ok(())
@@ -152,7 +155,10 @@ impl ActivityPubEventHandler {
.as_ref() .as_ref()
.map(|m| m.title().value().to_string()) .map(|m| m.title().value().to_string())
.unwrap_or_else(|| "Unknown".to_string()); .unwrap_or_else(|| "Unknown".to_string());
let release_year = movie.as_ref().map(|m| m.release_year().value()).unwrap_or(0); let release_year = movie
.as_ref()
.map(|m| m.release_year().value())
.unwrap_or(0);
let poster_url = movie let poster_url = movie
.as_ref() .as_ref()
.and_then(|m| m.poster_path()) .and_then(|m| m.poster_path())
@@ -170,7 +176,7 @@ impl ActivityPubEventHandler {
let json = serde_json::to_value(obj)?; let json = serde_json::to_value(obj)?;
self.ap_service self.ap_service
.broadcast_update_note(user_id.value(), json) .broadcast_update_note(user_id.value(), json, ApVisibility::Public, vec![])
.await?; .await?;
Ok(()) Ok(())
@@ -214,20 +220,20 @@ impl ActivityPubEventHandler {
let added_at_utc = let added_at_utc =
chrono::DateTime::<chrono::Utc>::from_naive_utc_and_offset(*added_at, chrono::Utc); chrono::DateTime::<chrono::Utc>::from_naive_utc_and_offset(*added_at, chrono::Utc);
let obj = crate::objects::watchlist_to_ap_object( let obj = crate::objects::watchlist_to_ap_object(crate::objects::WatchlistApInput {
ap_id.clone(), ap_id: ap_id.clone(),
actor, actor_url: actor,
movie_title.to_string(), movie_title: movie_title.to_string(),
release_year, release_year,
external_metadata_id.clone(), external_metadata_id: external_metadata_id.clone(),
poster_url, poster_url,
added_at_utc, added_at: added_at_utc,
&self.base_url, base_url: self.base_url.clone(),
); });
let json = serde_json::to_value(obj)?; let json = serde_json::to_value(obj)?;
self.ap_service self.ap_service
.broadcast_create_note(user_id.value(), json) .broadcast_create_note(user_id.value(), json, ApVisibility::Public, vec![])
.await?; .await?;
Ok(()) Ok(())
} }

View File

@@ -0,0 +1,38 @@
use std::sync::Arc;
use domain::events::DomainEvent;
use domain::value_objects::UserId;
use k_ap::FederationEvent;
pub struct FederationEventBridge {
domain_publisher: Arc<dyn domain::ports::EventPublisher>,
}
impl FederationEventBridge {
pub fn new(domain_publisher: Arc<dyn domain::ports::EventPublisher>) -> Self {
Self { domain_publisher }
}
}
#[async_trait::async_trait]
impl k_ap::EventPublisher for FederationEventBridge {
async fn publish(&self, event: FederationEvent) -> anyhow::Result<()> {
match event {
FederationEvent::BackfillRequested {
owner_user_id,
follower_inbox_url,
} => self
.domain_publisher
.publish(&DomainEvent::BackfillFollower {
owner_user_id: UserId::from_uuid(owner_user_id),
follower_inbox_url,
})
.await
.map_err(|e| anyhow::anyhow!(e.to_string())),
_ => {
tracing::debug!("ignoring federation event: {:?}", event);
Ok(())
}
}
}
}

View File

@@ -1,5 +1,6 @@
pub mod composite_handler; pub mod composite_handler;
pub mod event_handler; pub mod event_handler;
pub mod federation_event_bridge;
pub mod objects; pub mod objects;
pub mod port; pub mod port;
pub mod remote_review_repository; pub mod remote_review_repository;
@@ -10,8 +11,9 @@ pub mod watchlist_handler;
// Re-export the generic base types that callers need // Re-export the generic base types that callers need
pub use k_ap::{ pub use k_ap::{
ActivityPubService, ApFederationConfig, ApObjectHandler, ApUser, ApUserRepository, ActivityPubService, ActivityRepository, ActorRepository, ApContentReader, ApFederationConfig,
FederationData, FederationRepository, Follower, FollowerStatus, FollowingStatus, RemoteActor, ApObjectHandler, ApUser, ApUserRepository, BlocklistRepository, FederationData,
FollowRepository, Follower, FollowerStatus, FollowingStatus, RemoteActor,
}; };
pub use event_handler::ActivityPubEventHandler; pub use event_handler::ActivityPubEventHandler;
@@ -20,22 +22,50 @@ pub use remote_review_repository::RemoteReviewRepository;
pub use review_handler::ReviewObjectHandler; pub use review_handler::ReviewObjectHandler;
pub use user_adapter::DomainUserRepoAdapter; pub use user_adapter::DomainUserRepoAdapter;
pub type FederationRepos = (
std::sync::Arc<dyn ActivityRepository>,
std::sync::Arc<dyn FollowRepository>,
std::sync::Arc<dyn ActorRepository>,
std::sync::Arc<dyn BlocklistRepository>,
std::sync::Arc<dyn domain::ports::SocialQueryPort>,
std::sync::Arc<dyn RemoteReviewRepository>,
std::sync::Arc<dyn domain::ports::RemoteWatchlistRepository>,
);
pub struct ActivityPubWire { pub struct ActivityPubWire {
pub service: std::sync::Arc<dyn ActivityPubPort>, pub service: std::sync::Arc<dyn ActivityPubPort>,
pub router: axum::Router, pub router: axum::Router,
pub event_handler: std::sync::Arc<dyn domain::ports::EventHandler>, pub event_handler: std::sync::Arc<dyn domain::ports::EventHandler>,
} }
pub async fn wire( pub struct ActivityPubDeps {
federation_repo: std::sync::Arc<dyn FederationRepository>, pub activity_repo: std::sync::Arc<dyn ActivityRepository>,
review_store: std::sync::Arc<dyn RemoteReviewRepository>, pub follow_repo: std::sync::Arc<dyn FollowRepository>,
remote_watchlist_repo: std::sync::Arc<dyn domain::ports::RemoteWatchlistRepository>, pub actor_repo: std::sync::Arc<dyn ActorRepository>,
local_ap_content: std::sync::Arc<dyn domain::ports::LocalApContentQuery>, pub blocklist_repo: std::sync::Arc<dyn BlocklistRepository>,
user_repo: std::sync::Arc<dyn domain::ports::UserRepository>, pub review_store: std::sync::Arc<dyn RemoteReviewRepository>,
base_url: String, pub remote_watchlist_repo: std::sync::Arc<dyn domain::ports::RemoteWatchlistRepository>,
allow_registration: bool, pub local_ap_content: std::sync::Arc<dyn domain::ports::LocalApContentQuery>,
_event_publisher: std::sync::Arc<dyn domain::ports::EventPublisher>, pub user_repo: std::sync::Arc<dyn domain::ports::UserRepository>,
) -> anyhow::Result<ActivityPubWire> { pub base_url: String,
pub allow_registration: bool,
pub event_publisher: std::sync::Arc<dyn domain::ports::EventPublisher>,
}
pub async fn wire(deps: ActivityPubDeps) -> anyhow::Result<ActivityPubWire> {
let ActivityPubDeps {
activity_repo,
follow_repo,
actor_repo,
blocklist_repo,
review_store,
remote_watchlist_repo,
local_ap_content,
user_repo,
base_url,
allow_registration,
event_publisher,
} = deps;
let review_handler = std::sync::Arc::new(ReviewObjectHandler { let review_handler = std::sync::Arc::new(ReviewObjectHandler {
content_query: std::sync::Arc::clone(&local_ap_content), content_query: std::sync::Arc::clone(&local_ap_content),
review_store, review_store,
@@ -62,18 +92,28 @@ pub async fn wire(
); );
} }
let fed_event_bridge = std::sync::Arc::new(
federation_event_bridge::FederationEventBridge::new(event_publisher),
);
let concrete = std::sync::Arc::new( let concrete = std::sync::Arc::new(
ActivityPubService::builder( ActivityPubService::builder(base_url.clone())
federation_repo, .activity_repo(activity_repo)
std::sync::Arc::new(DomainUserRepoAdapter::new(user_repo, base_url.clone())), .follow_repo(follow_repo)
composite, .actor_repo(actor_repo)
base_url.clone(), .blocklist_repo(blocklist_repo)
) .user_repo(std::sync::Arc::new(DomainUserRepoAdapter::new(
.allow_registration(allow_registration) user_repo,
.software_name("movies-diary") base_url.clone(),
.debug(federation_debug) )))
.build() .content_reader(composite.clone() as std::sync::Arc<dyn ApContentReader>)
.await?, .object_handler(composite as std::sync::Arc<dyn ApObjectHandler>)
.event_publisher(fed_event_bridge)
.allow_registration(allow_registration)
.software_name("movies-diary")
.debug(federation_debug)
.build()
.await?,
); );
let router = concrete.router(); let router = concrete.router();

View File

@@ -1,6 +1,6 @@
use chrono::{DateTime, Utc};
use k_ap::AS_PUBLIC; use k_ap::AS_PUBLIC;
use k_ap::NoteType; use k_ap::NoteType;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use url::Url; use url::Url;
@@ -131,16 +131,28 @@ pub struct WatchlistObject {
pub(crate) cc: Vec<String>, pub(crate) cc: Vec<String>,
} }
pub fn watchlist_to_ap_object( pub struct WatchlistApInput {
ap_id: Url, pub ap_id: Url,
actor_url: Url, pub actor_url: Url,
movie_title: String, pub movie_title: String,
release_year: u16, pub release_year: u16,
external_metadata_id: Option<String>, pub external_metadata_id: Option<String>,
poster_url: Option<String>, pub poster_url: Option<String>,
added_at: chrono::DateTime<chrono::Utc>, pub added_at: chrono::DateTime<chrono::Utc>,
base_url: &str, pub base_url: String,
) -> WatchlistObject { }
pub fn watchlist_to_ap_object(input: WatchlistApInput) -> WatchlistObject {
let WatchlistApInput {
ap_id,
actor_url,
movie_title,
release_year,
external_metadata_id,
poster_url,
added_at,
base_url,
} = input;
let year_str = if release_year > 0 { let year_str = if release_year > 0 {
format!(" ({})", release_year) format!(" ({})", release_year)
} else { } else {

View File

@@ -31,7 +31,22 @@ pub trait ActivityPubPort: Send + Sync {
async fn add_blocked_domain(&self, domain: &str, reason: Option<&str>) -> anyhow::Result<()>; async fn add_blocked_domain(&self, domain: &str, reason: Option<&str>) -> anyhow::Result<()>;
async fn remove_blocked_domain(&self, domain: &str) -> anyhow::Result<()>; async fn remove_blocked_domain(&self, domain: &str) -> anyhow::Result<()>;
async fn get_blocked_domains(&self) -> anyhow::Result<Vec<BlockedDomain>>; async fn get_blocked_domains(&self) -> anyhow::Result<Vec<BlockedDomain>>;
async fn backfill_outbox(&self, outbox_url: &str, actor_url: &str) -> anyhow::Result<()>; async fn import_remote_outbox(&self, outbox_url: &str, actor_url: &str) -> anyhow::Result<()>;
async fn followers_collection_json(
&self,
user_id: Uuid,
page: Option<u32>,
) -> anyhow::Result<String>;
async fn following_collection_json(
&self,
user_id: Uuid,
page: Option<u32>,
) -> anyhow::Result<String>;
async fn run_backfill_for_follower(
&self,
owner_user_id: Uuid,
follower_inbox_url: String,
) -> anyhow::Result<()>;
} }
#[async_trait] #[async_trait]
@@ -98,8 +113,30 @@ impl ActivityPubPort for ActivityPubService {
async fn get_blocked_domains(&self) -> anyhow::Result<Vec<BlockedDomain>> { async fn get_blocked_domains(&self) -> anyhow::Result<Vec<BlockedDomain>> {
self.get_blocked_domains().await self.get_blocked_domains().await
} }
async fn backfill_outbox(&self, outbox_url: &str, actor_url: &str) -> anyhow::Result<()> { async fn import_remote_outbox(&self, outbox_url: &str, actor_url: &str) -> anyhow::Result<()> {
self.backfill_outbox(outbox_url, actor_url).await self.import_remote_outbox(outbox_url, actor_url).await
}
async fn followers_collection_json(
&self,
user_id: Uuid,
page: Option<u32>,
) -> anyhow::Result<String> {
self.followers_collection_json(user_id, page).await
}
async fn following_collection_json(
&self,
user_id: Uuid,
page: Option<u32>,
) -> anyhow::Result<String> {
self.following_collection_json(user_id, page).await
}
async fn run_backfill_for_follower(
&self,
owner_user_id: Uuid,
follower_inbox_url: String,
) -> anyhow::Result<()> {
self.run_backfill_for_follower(owner_user_id, follower_inbox_url)
.await
} }
} }
@@ -158,7 +195,16 @@ impl ActivityPubPort for NoopActivityPubService {
async fn get_blocked_domains(&self) -> anyhow::Result<Vec<BlockedDomain>> { async fn get_blocked_domains(&self) -> anyhow::Result<Vec<BlockedDomain>> {
Ok(vec![]) Ok(vec![])
} }
async fn backfill_outbox(&self, _: &str, _: &str) -> anyhow::Result<()> { async fn import_remote_outbox(&self, _: &str, _: &str) -> anyhow::Result<()> {
Ok(())
}
async fn followers_collection_json(&self, _: Uuid, _: Option<u32>) -> anyhow::Result<String> {
Ok(String::new())
}
async fn following_collection_json(&self, _: Uuid, _: Option<u32>) -> anyhow::Result<String> {
Ok(String::new())
}
async fn run_backfill_for_follower(&self, _: Uuid, _: String) -> anyhow::Result<()> {
Ok(()) Ok(())
} }
} }

View File

@@ -1,12 +1,12 @@
use std::sync::Arc; use std::sync::Arc;
use k_ap::ApObjectHandler;
use async_trait::async_trait; use async_trait::async_trait;
use domain::{ use domain::{
models::ReviewSource, models::ReviewSource,
ports::LocalApContentQuery, ports::LocalApContentQuery,
value_objects::{Comment, MovieId, Rating, ReviewId, UserId}, value_objects::{Comment, MovieId, Rating, ReviewId, UserId},
}; };
use k_ap::{ApContentReader, ApObjectHandler};
use url::Url; use url::Url;
use crate::objects::{ReviewObject, review_to_ap_object}; use crate::objects::{ReviewObject, review_to_ap_object};
@@ -20,43 +20,7 @@ pub struct ReviewObjectHandler {
} }
#[async_trait] #[async_trait]
impl ApObjectHandler for ReviewObjectHandler { impl ApContentReader for ReviewObjectHandler {
async fn get_local_objects_for_user(
&self,
user_id: uuid::Uuid,
) -> anyhow::Result<Vec<(Url, serde_json::Value)>> {
let domain_user_id = UserId::from_uuid(user_id);
let entries = self
.content_query
.get_local_reviews_for_user(&domain_user_id)
.await
.map_err(|e| anyhow::anyhow!(e.to_string()))?;
let actor = actor_url(&self.base_url, user_id);
let mut results = Vec::new();
for entry in entries {
let review = entry.review();
let movie = entry.movie();
let ap_id = review_url(&self.base_url, review.id());
let poster_url = movie
.poster_path()
.map(|p| format!("{}/images/{}", self.base_url, p.value()));
let obj = review_to_ap_object(
review,
ap_id.clone(),
actor.clone(),
movie.title().value().to_string(),
movie.release_year().value(),
poster_url,
&self.base_url,
);
results.push((ap_id, serde_json::to_value(obj)?));
}
Ok(results)
}
async fn get_local_objects_page( async fn get_local_objects_page(
&self, &self,
user_id: uuid::Uuid, user_id: uuid::Uuid,
@@ -64,9 +28,10 @@ impl ApObjectHandler for ReviewObjectHandler {
limit: usize, limit: usize,
) -> anyhow::Result<Vec<(url::Url, serde_json::Value, chrono::DateTime<chrono::Utc>)>> { ) -> anyhow::Result<Vec<(url::Url, serde_json::Value, chrono::DateTime<chrono::Utc>)>> {
let domain_user_id = UserId::from_uuid(user_id); let domain_user_id = UserId::from_uuid(user_id);
let before_naive = before.map(|dt| dt.naive_utc());
let entries = self let entries = self
.content_query .content_query
.get_local_reviews_for_user(&domain_user_id) .get_local_reviews_page(&domain_user_id, before_naive, limit)
.await .await
.map_err(|e| anyhow::anyhow!(e.to_string()))?; .map_err(|e| anyhow::anyhow!(e.to_string()))?;
@@ -74,14 +39,8 @@ impl ApObjectHandler for ReviewObjectHandler {
let mut results = Vec::new(); let mut results = Vec::new();
for entry in entries { for entry in entries {
let review = entry.review(); let review = entry.review();
let published = chrono::DateTime::from_naive_utc_and_offset(*review.watched_at(), chrono::Utc); let published =
chrono::DateTime::from_naive_utc_and_offset(*review.watched_at(), chrono::Utc);
if let Some(cutoff) = before
&& published >= cutoff
{
continue;
}
let movie = entry.movie(); let movie = entry.movie();
let ap_id = review_url(&self.base_url, review.id()); let ap_id = review_url(&self.base_url, review.id());
let poster_url = movie let poster_url = movie
@@ -98,14 +57,20 @@ impl ApObjectHandler for ReviewObjectHandler {
&self.base_url, &self.base_url,
); );
results.push((ap_id, serde_json::to_value(obj)?, published)); results.push((ap_id, serde_json::to_value(obj)?, published));
if results.len() >= limit {
break;
}
} }
Ok(results) Ok(results)
} }
async fn count_local_posts(&self) -> anyhow::Result<u64> {
self.content_query
.count_local_posts()
.await
.map_err(|e| anyhow::anyhow!(e.to_string()))
}
}
#[async_trait]
impl ApObjectHandler for ReviewObjectHandler {
async fn on_create( async fn on_create(
&self, &self,
_ap_id: &Url, _ap_id: &Url,
@@ -133,18 +98,18 @@ impl ApObjectHandler for ReviewObjectHandler {
let rating = Rating::new(obj.rating.min(5))?; let rating = Rating::new(obj.rating.min(5))?;
let comment = obj.comment.map(Comment::new).transpose()?; let comment = obj.comment.map(Comment::new).transpose()?;
let review = domain::models::Review::from_persistence( let review = domain::models::Review::from_persistence(domain::models::PersistedReview {
review_id, id: review_id,
movie_id, movie_id,
user_id, user_id,
rating, rating,
comment, comment,
obj.watched_at.naive_utc(), watched_at: obj.watched_at.naive_utc(),
obj.published.naive_utc(), created_at: obj.published.naive_utc(),
ReviewSource::Remote { source: ReviewSource::Remote {
actor_url: actor_url_str, actor_url: actor_url_str,
}, },
); });
self.review_store self.review_store
.save_remote_review( .save_remote_review(
@@ -200,18 +165,23 @@ impl ApObjectHandler for ReviewObjectHandler {
self.review_store.delete_by_actor(actor_url.as_str()).await self.review_store.delete_by_actor(actor_url.as_str()).await
} }
async fn count_local_posts(&self) -> anyhow::Result<u64> {
self.content_query
.count_local_posts()
.await
.map_err(|e| anyhow::anyhow!(e.to_string()))
}
async fn on_like(&self, _object_url: &Url, _actor_url: &Url) -> anyhow::Result<()> { async fn on_like(&self, _object_url: &Url, _actor_url: &Url) -> anyhow::Result<()> {
Ok(()) Ok(())
} }
async fn on_announce_received(&self, _object_url: &Url, _actor_url: &Url) -> anyhow::Result<()> { async fn on_announce_received(
&self,
_object_url: &Url,
_actor_url: &Url,
) -> anyhow::Result<()> {
Ok(())
}
async fn on_announce_of_remote(
&self,
_object_url: &Url,
_actor_url: &Url,
) -> anyhow::Result<()> {
Ok(()) Ok(())
} }
@@ -219,7 +189,12 @@ impl ApObjectHandler for ReviewObjectHandler {
Ok(()) Ok(())
} }
async fn on_mention(&self, _thought_ap_id: &Url, _mentioned_user_uuid: uuid::Uuid, _actor_url: &Url) -> anyhow::Result<()> { async fn on_mention(
&self,
_thought_ap_id: &Url,
_mentioned_user_uuid: uuid::Uuid,
_actor_url: &Url,
) -> anyhow::Result<()> {
Ok(()) Ok(())
} }
} }

View File

@@ -14,20 +14,22 @@ fn normalize_hashtag_strips_non_alphanumeric() {
fn review_to_ap_object_includes_two_hashtags() { fn review_to_ap_object_includes_two_hashtags() {
use chrono::NaiveDateTime; use chrono::NaiveDateTime;
use domain::{ use domain::{
models::{Review, ReviewSource}, models::{PersistedReview, Review, ReviewSource},
value_objects::{MovieId, Rating, ReviewId, UserId}, value_objects::{MovieId, Rating, ReviewId, UserId},
}; };
let review = Review::from_persistence( let review = Review::from_persistence(PersistedReview {
ReviewId::generate(), id: ReviewId::generate(),
MovieId::from_uuid(uuid::Uuid::new_v4()), movie_id: MovieId::from_uuid(uuid::Uuid::new_v4()),
UserId::from_uuid(uuid::Uuid::new_v4()), user_id: UserId::from_uuid(uuid::Uuid::new_v4()),
Rating::new(4).unwrap(), rating: Rating::new(4).unwrap(),
None, comment: None,
NaiveDateTime::parse_from_str("2024-01-01 00:00:00", "%Y-%m-%d %H:%M:%S").unwrap(), watched_at: NaiveDateTime::parse_from_str("2024-01-01 00:00:00", "%Y-%m-%d %H:%M:%S")
NaiveDateTime::parse_from_str("2024-01-01 00:00:00", "%Y-%m-%d %H:%M:%S").unwrap(), .unwrap(),
ReviewSource::Local, created_at: NaiveDateTime::parse_from_str("2024-01-01 00:00:00", "%Y-%m-%d %H:%M:%S")
); .unwrap(),
source: ReviewSource::Local,
});
let obj = review_to_ap_object( let obj = review_to_ap_object(
&review, &review,
"https://example.com/reviews/1".parse().unwrap(), "https://example.com/reviews/1".parse().unwrap(),
@@ -47,20 +49,22 @@ fn review_to_ap_object_includes_two_hashtags() {
fn review_to_ap_object_has_public_addressing() { fn review_to_ap_object_has_public_addressing() {
use chrono::NaiveDateTime; use chrono::NaiveDateTime;
use domain::{ use domain::{
models::{Review, ReviewSource}, models::{PersistedReview, Review, ReviewSource},
value_objects::{MovieId, Rating, ReviewId, UserId}, value_objects::{MovieId, Rating, ReviewId, UserId},
}; };
let review = Review::from_persistence( let review = Review::from_persistence(PersistedReview {
ReviewId::generate(), id: ReviewId::generate(),
MovieId::from_uuid(uuid::Uuid::new_v4()), movie_id: MovieId::from_uuid(uuid::Uuid::new_v4()),
UserId::from_uuid(uuid::Uuid::new_v4()), user_id: UserId::from_uuid(uuid::Uuid::new_v4()),
Rating::new(3).unwrap(), rating: Rating::new(3).unwrap(),
None, comment: None,
NaiveDateTime::parse_from_str("2024-06-01 00:00:00", "%Y-%m-%d %H:%M:%S").unwrap(), watched_at: NaiveDateTime::parse_from_str("2024-06-01 00:00:00", "%Y-%m-%d %H:%M:%S")
NaiveDateTime::parse_from_str("2024-06-01 00:00:00", "%Y-%m-%d %H:%M:%S").unwrap(), .unwrap(),
ReviewSource::Local, created_at: NaiveDateTime::parse_from_str("2024-06-01 00:00:00", "%Y-%m-%d %H:%M:%S")
); .unwrap(),
source: ReviewSource::Local,
});
let actor_url: url::Url = "https://example.com/users/abc".parse().unwrap(); let actor_url: url::Url = "https://example.com/users/abc".parse().unwrap();
let obj = review_to_ap_object( let obj = review_to_ap_object(
&review, &review,
@@ -78,16 +82,16 @@ fn review_to_ap_object_has_public_addressing() {
#[test] #[test]
fn watchlist_to_ap_object_has_public_addressing() { fn watchlist_to_ap_object_has_public_addressing() {
let actor_url: url::Url = "https://example.com/users/abc".parse().unwrap(); let actor_url: url::Url = "https://example.com/users/abc".parse().unwrap();
let obj = watchlist_to_ap_object( let obj = watchlist_to_ap_object(WatchlistApInput {
"https://example.com/watchlist/1".parse().unwrap(), ap_id: "https://example.com/watchlist/1".parse().unwrap(),
actor_url.clone(), actor_url: actor_url.clone(),
"Alien".to_string(), movie_title: "Alien".to_string(),
1979, release_year: 1979,
None, external_metadata_id: None,
None, poster_url: None,
chrono::Utc::now(), added_at: chrono::Utc::now(),
"https://example.com", base_url: "https://example.com".to_string(),
); });
assert_eq!(obj.to, vec!["https://www.w3.org/ns/activitystreams#Public"]); assert_eq!(obj.to, vec!["https://www.w3.org/ns/activitystreams#Public"]);
assert_eq!(obj.cc, vec!["https://example.com/users/abc/followers"]); assert_eq!(obj.cc, vec!["https://example.com/users/abc/followers"]);
} }

View File

@@ -1,8 +1,8 @@
use std::sync::Arc; use std::sync::Arc;
use k_ap::{ApProfileField, ApUser, ApUserRepository};
use async_trait::async_trait; use async_trait::async_trait;
use domain::{ports::UserRepository, value_objects::UserId}; use domain::{ports::UserRepository, value_objects::UserId};
use k_ap::{ApProfileField, ApUser, ApUserRepository};
use url::Url; use url::Url;
pub struct DomainUserRepoAdapter { pub struct DomainUserRepoAdapter {
@@ -26,10 +26,14 @@ impl DomainUserRepoAdapter {
ApUser { ApUser {
id: u.id().value(), id: u.id().value(),
username: u.username().value().to_string(), username: u.username().value().to_string(),
display_name: u.display_name().map(|s| s.to_string()),
bio: u.bio().map(|s| s.to_string()), bio: u.bio().map(|s| s.to_string()),
avatar_url, avatar_url,
banner_url, banner_url,
also_known_as: u.also_known_as().map(|s| s.to_string()), also_known_as: u
.also_known_as()
.map(|s| vec![s.to_string()])
.unwrap_or_default(),
profile_url, profile_url,
attachment: u attachment: u
.profile_fields() .profile_fields()
@@ -39,6 +43,15 @@ impl DomainUserRepoAdapter {
value: f.value.clone(), value: f.value.clone(),
}) })
.collect(), .collect(),
manually_approves_followers: true,
discoverable: true,
actor_type: Default::default(),
featured_url: Url::parse(&format!(
"{}/users/{}/featured",
self.base_url,
u.id().value()
))
.ok(),
} }
} }
} }

View File

@@ -1,16 +1,14 @@
use std::sync::Arc; use std::sync::Arc;
use k_ap::ApObjectHandler;
use async_trait::async_trait; use async_trait::async_trait;
use chrono::Utc;
use domain::{ use domain::{
models::RemoteWatchlistEntry, models::RemoteWatchlistEntry,
ports::{LocalApContentQuery, RemoteWatchlistRepository}, ports::{LocalApContentQuery, RemoteWatchlistRepository},
value_objects::UserId,
}; };
use k_ap::ApObjectHandler;
use url::Url; use url::Url;
use crate::{objects::{WatchlistObject, watchlist_to_ap_object}, urls::{actor_url, watchlist_entry_url}}; use crate::objects::WatchlistObject;
pub struct WatchlistObjectHandler { pub struct WatchlistObjectHandler {
pub remote_watchlist_repo: Arc<dyn RemoteWatchlistRepository>, pub remote_watchlist_repo: Arc<dyn RemoteWatchlistRepository>,
@@ -20,55 +18,6 @@ pub struct WatchlistObjectHandler {
#[async_trait] #[async_trait]
impl ApObjectHandler for WatchlistObjectHandler { impl ApObjectHandler for WatchlistObjectHandler {
async fn get_local_objects_for_user(
&self,
user_id: uuid::Uuid,
) -> anyhow::Result<Vec<(Url, serde_json::Value)>> {
let domain_user_id = UserId::from_uuid(user_id);
let entries = self
.content_query
.get_local_watchlist_for_user(&domain_user_id)
.await
.map_err(|e| anyhow::anyhow!(e.to_string()))?;
let actor = actor_url(&self.base_url, user_id);
let mut results = Vec::new();
for wm in entries {
let movie_id = wm.entry.movie_id.value();
let ap_id = watchlist_entry_url(&self.base_url, user_id, movie_id);
let added_at = chrono::DateTime::from_naive_utc_and_offset(wm.entry.added_at, Utc);
let external_metadata_id = wm
.movie
.external_metadata_id()
.map(|id| id.value().to_string());
let poster_url = wm
.movie
.poster_path()
.map(|p| format!("{}/images/{}", self.base_url, p.value()));
let obj = watchlist_to_ap_object(
ap_id.clone(),
actor.clone(),
wm.movie.title().value().to_string(),
wm.movie.release_year().value(),
external_metadata_id,
poster_url,
added_at,
&self.base_url,
);
results.push((ap_id, serde_json::to_value(obj)?));
}
Ok(results)
}
async fn get_local_objects_page(
&self,
_user_id: uuid::Uuid,
_before: Option<chrono::DateTime<Utc>>,
_limit: usize,
) -> anyhow::Result<Vec<(Url, serde_json::Value, chrono::DateTime<Utc>)>> {
Ok(vec![])
}
async fn on_create( async fn on_create(
&self, &self,
ap_id: &Url, ap_id: &Url,
@@ -115,15 +64,23 @@ impl ApObjectHandler for WatchlistObjectHandler {
Ok(()) Ok(())
} }
async fn count_local_posts(&self) -> anyhow::Result<u64> {
Ok(0)
}
async fn on_like(&self, _object_url: &Url, _actor_url: &Url) -> anyhow::Result<()> { async fn on_like(&self, _object_url: &Url, _actor_url: &Url) -> anyhow::Result<()> {
Ok(()) Ok(())
} }
async fn on_announce_received(&self, _object_url: &Url, _actor_url: &Url) -> anyhow::Result<()> { async fn on_announce_received(
&self,
_object_url: &Url,
_actor_url: &Url,
) -> anyhow::Result<()> {
Ok(())
}
async fn on_announce_of_remote(
&self,
_object_url: &Url,
_actor_url: &Url,
) -> anyhow::Result<()> {
Ok(()) Ok(())
} }
@@ -131,7 +88,12 @@ impl ApObjectHandler for WatchlistObjectHandler {
Ok(()) Ok(())
} }
async fn on_mention(&self, _thought_ap_id: &Url, _mentioned_user_uuid: uuid::Uuid, _actor_url: &Url) -> anyhow::Result<()> { async fn on_mention(
&self,
_thought_ap_id: &Url,
_mentioned_user_uuid: uuid::Uuid,
_actor_url: &Url,
) -> anyhow::Result<()> {
Ok(()) Ok(())
} }
} }

View File

@@ -63,6 +63,10 @@ pub enum EventPayload {
remote_actor_url: String, remote_actor_url: String,
outbox_url: String, outbox_url: String,
}, },
BackfillFollower {
owner_user_id: String,
follower_inbox_url: String,
},
} }
impl EventPayload { impl EventPayload {
@@ -79,6 +83,7 @@ impl EventPayload {
EventPayload::WatchlistEntryAdded { .. } => "WatchlistEntryAdded", EventPayload::WatchlistEntryAdded { .. } => "WatchlistEntryAdded",
EventPayload::WatchlistEntryRemoved { .. } => "WatchlistEntryRemoved", EventPayload::WatchlistEntryRemoved { .. } => "WatchlistEntryRemoved",
EventPayload::FollowAccepted { .. } => "FollowAccepted", EventPayload::FollowAccepted { .. } => "FollowAccepted",
EventPayload::BackfillFollower { .. } => "BackfillFollower",
} }
} }
} }
@@ -181,6 +186,13 @@ impl From<&DomainEvent> for EventPayload {
remote_actor_url: remote_actor_url.clone(), remote_actor_url: remote_actor_url.clone(),
outbox_url: outbox_url.clone(), outbox_url: outbox_url.clone(),
}, },
DomainEvent::BackfillFollower {
owner_user_id,
follower_inbox_url,
} => EventPayload::BackfillFollower {
owner_user_id: owner_user_id.value().to_string(),
follower_inbox_url: follower_inbox_url.clone(),
},
} }
} }
} }
@@ -281,6 +293,13 @@ impl TryFrom<EventPayload> for DomainEvent {
remote_actor_url, remote_actor_url,
outbox_url, outbox_url,
}), }),
EventPayload::BackfillFollower {
owner_user_id,
follower_inbox_url,
} => Ok(DomainEvent::BackfillFollower {
owner_user_id: UserId::from_uuid(parse_uuid(&owner_user_id, "owner_user_id")?),
follower_inbox_url,
}),
} }
} }
} }

View File

@@ -11,12 +11,14 @@ use domain::ports::{
}; };
use std::sync::Arc; use std::sync::Arc;
type ConversionPair = (Arc<dyn EventHandler>, Arc<dyn PeriodicJob>);
pub fn build( pub fn build(
image_storage: Arc<dyn ImageStorage>, image_storage: Arc<dyn ImageStorage>,
image_ref_command: Arc<dyn ImageRefCommand>, image_ref_command: Arc<dyn ImageRefCommand>,
image_ref_query: Arc<dyn ImageRefQuery>, image_ref_query: Arc<dyn ImageRefQuery>,
event_publisher: Arc<dyn EventPublisher>, event_publisher: Arc<dyn EventPublisher>,
) -> anyhow::Result<Option<(Arc<dyn EventHandler>, Arc<dyn PeriodicJob>)>> { ) -> anyhow::Result<Option<ConversionPair>> {
let config = match ConversionConfig::from_env()? { let config = match ConversionConfig::from_env()? {
Some(c) => c, Some(c) => c,
None => return Ok(None), None => return Ok(None),

View File

@@ -13,6 +13,7 @@ pub fn event_to_subject(prefix: &str, event: &DomainEvent) -> String {
DomainEvent::WatchlistEntryAdded { .. } => "watchlist.entry.added", DomainEvent::WatchlistEntryAdded { .. } => "watchlist.entry.added",
DomainEvent::WatchlistEntryRemoved { .. } => "watchlist.entry.removed", DomainEvent::WatchlistEntryRemoved { .. } => "watchlist.entry.removed",
DomainEvent::FollowAccepted { .. } => "follow.accepted", DomainEvent::FollowAccepted { .. } => "follow.accepted",
DomainEvent::BackfillFollower { .. } => "backfill.follower",
}; };
format!("{prefix}.{suffix}") format!("{prefix}.{suffix}")
} }

View File

@@ -11,12 +11,12 @@ sqlx = { version = "0.8.6", features = [
"macros", "macros",
"chrono", "chrono",
] } ] }
activitypub = { workspace = true } activitypub = { workspace = true }
k-ap = { git = "https://git.gabrielkaszewski.dev/GKaszewski/k-ap.git", tag = "v0.1.10" } k-ap = { version = "0.3.1", registry = "gitea" }
domain = { workspace = true } domain = { workspace = true }
uuid = { workspace = true } uuid = { workspace = true }
chrono = { workspace = true } chrono = { workspace = true }
tracing = { workspace = true } tracing = { workspace = true }
async-trait = { workspace = true } async-trait = { workspace = true }
anyhow = { workspace = true } anyhow = { workspace = true }
serde_json = { workspace = true }

View File

@@ -4,11 +4,12 @@ use chrono::{NaiveDateTime, Utc};
use sqlx::{PgPool, Row}; use sqlx::{PgPool, Row};
use activitypub::RemoteReviewRepository; use activitypub::RemoteReviewRepository;
use k_ap::{
BlockedDomain, FederationRepository, Follower, FollowerStatus, FollowingStatus, RemoteActor,
};
use domain::models::{RemoteWatchlistEntry, Review, ReviewSource}; use domain::models::{RemoteWatchlistEntry, Review, ReviewSource};
use domain::ports::RemoteWatchlistRepository; use domain::ports::RemoteWatchlistRepository;
use k_ap::{
ActivityRepository, ActorRepository, BlockedDomain, BlocklistRepository, FollowRepository,
Follower, FollowerStatus, FollowingStatus, RemoteActor,
};
fn datetime_to_str(dt: &NaiveDateTime) -> String { fn datetime_to_str(dt: &NaiveDateTime) -> String {
dt.format("%Y-%m-%d %H:%M:%S").to_string() dt.format("%Y-%m-%d %H:%M:%S").to_string()
@@ -40,8 +41,37 @@ fn str_to_status(s: &str) -> FollowerStatus {
} }
} }
fn pg_remote_actor(row: &sqlx::postgres::PgRow, url_col: &str) -> RemoteActor {
RemoteActor {
url: row.get(url_col),
handle: row.try_get("handle").unwrap_or_default(),
inbox_url: row.try_get("inbox_url").unwrap_or_default(),
shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(),
display_name: row.try_get("display_name").ok().flatten(),
avatar_url: row.try_get("avatar_url").ok().flatten(),
outbox_url: row.try_get("outbox_url").ok().flatten(),
bio: row.try_get("bio").ok().flatten(),
banner_url: row.try_get("banner_url").ok().flatten(),
followers_url: row.try_get("followers_url").ok().flatten(),
following_url: row.try_get("following_url").ok().flatten(),
also_known_as: row
.try_get::<Option<String>, _>("also_known_as")
.ok()
.flatten()
.map(|s| {
serde_json::from_str::<Vec<String>>(&s).unwrap_or_else(|e| {
tracing::warn!(raw = %s, error = %e, "failed to parse also_known_as JSON");
vec![s]
})
})
.unwrap_or_default(),
}
}
const PG_ACTOR_COLS: &str = "a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.avatar_url, a.outbox_url, a.bio, a.banner_url, a.followers_url, a.following_url, a.also_known_as";
#[async_trait] #[async_trait]
impl FederationRepository for PostgresFederationRepository { impl FollowRepository for PostgresFederationRepository {
async fn add_follower( async fn add_follower(
&self, &self,
local_user_id: uuid::Uuid, local_user_id: uuid::Uuid,
@@ -102,37 +132,18 @@ impl FederationRepository for PostgresFederationRepository {
async fn get_followers(&self, local_user_id: uuid::Uuid) -> Result<Vec<Follower>> { async fn get_followers(&self, local_user_id: uuid::Uuid) -> Result<Vec<Follower>> {
let uid = local_user_id.to_string(); let uid = local_user_id.to_string();
let rows = sqlx::query( let q = format!(
"SELECT f.remote_actor_url, f.status, "SELECT f.remote_actor_url, f.status, {PG_ACTOR_COLS}
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
FROM ap_followers f WHERE f.local_user_id = $1"
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url );
WHERE f.local_user_id = $1", let rows = sqlx::query(&q).bind(&uid).fetch_all(&self.pool).await?;
)
.bind(&uid)
.fetch_all(&self.pool)
.await?;
Ok(rows Ok(rows
.into_iter() .iter()
.map(|row| { .map(|row| {
let url: String = row.get("remote_actor_url");
let status_str: String = row.get("status"); let status_str: String = row.get("status");
let handle: String = row.try_get("handle").unwrap_or_default();
let inbox_url: String = row.try_get("inbox_url").unwrap_or_default();
let shared_inbox_url: Option<String> =
row.try_get("shared_inbox_url").ok().flatten();
let display_name: Option<String> = row.try_get("display_name").ok().flatten();
let avatar_url: Option<String> = row.try_get("avatar_url").ok().flatten();
Follower { Follower {
actor: RemoteActor { actor: pg_remote_actor(row, "remote_actor_url"),
url,
handle,
inbox_url,
shared_inbox_url,
display_name,
avatar_url,
outbox_url: row.try_get("outbox_url").ok().flatten(),
},
status: str_to_status(&status_str), status: str_to_status(&status_str),
} }
}) })
@@ -146,45 +157,24 @@ impl FederationRepository for PostgresFederationRepository {
limit: usize, limit: usize,
) -> Result<Vec<Follower>> { ) -> Result<Vec<Follower>> {
let uid = local_user_id.to_string(); let uid = local_user_id.to_string();
let limit_i64 = limit as i64; let q = format!(
let offset_i64 = offset as i64; "SELECT f.remote_actor_url, f.status, {PG_ACTOR_COLS}
FROM ap_followers f LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
let rows = sqlx::query(
"SELECT f.remote_actor_url, f.status,
a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.avatar_url
FROM ap_followers f
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
WHERE f.local_user_id = $1 AND f.status = 'accepted' WHERE f.local_user_id = $1 AND f.status = 'accepted'
ORDER BY f.created_at ASC ORDER BY f.created_at ASC LIMIT $2 OFFSET $3"
LIMIT $2 OFFSET $3", );
) let rows = sqlx::query(&q)
.bind(&uid) .bind(&uid)
.bind(limit_i64) .bind(limit as i64)
.bind(offset_i64) .bind(offset as i64)
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await?; .await?;
Ok(rows Ok(rows
.into_iter() .iter()
.map(|row| { .map(|row| {
let url: String = row.get("remote_actor_url");
let status_str: String = row.get("status"); let status_str: String = row.get("status");
let handle: String = row.try_get("handle").unwrap_or_default();
let inbox_url: String = row.try_get("inbox_url").unwrap_or_default();
let shared_inbox_url: Option<String> =
row.try_get("shared_inbox_url").ok().flatten();
let display_name: Option<String> = row.try_get("display_name").ok().flatten();
let avatar_url: Option<String> = row.try_get("avatar_url").ok().flatten();
Follower { Follower {
actor: RemoteActor { actor: pg_remote_actor(row, "remote_actor_url"),
url,
handle,
inbox_url,
shared_inbox_url,
display_name,
avatar_url,
outbox_url: row.try_get("outbox_url").ok().flatten(),
},
status: str_to_status(&status_str), status: str_to_status(&status_str),
} }
}) })
@@ -212,18 +202,86 @@ impl FederationRepository for PostgresFederationRepository {
let status_str = status_to_str(&status); let status_str = status_to_str(&status);
let result = sqlx::query( let result = sqlx::query(
"UPDATE ap_followers SET status = $1 WHERE local_user_id = $2 AND remote_actor_url = $3", "UPDATE ap_followers SET status = $1 WHERE local_user_id = $2 AND remote_actor_url = $3",
) ).bind(status_str).bind(&uid).bind(remote_actor_url).execute(&self.pool).await?;
.bind(status_str)
.bind(&uid)
.bind(remote_actor_url)
.execute(&self.pool)
.await?;
if result.rows_affected() == 0 { if result.rows_affected() == 0 {
tracing::warn!(local_user_id = %local_user_id, remote_actor_url, "update_follower_status: no row found"); tracing::warn!(local_user_id = %local_user_id, remote_actor_url, "update_follower_status: no row found");
} }
Ok(()) Ok(())
} }
async fn get_pending_followers(&self, local_user_id: uuid::Uuid) -> Result<Vec<RemoteActor>> {
let uid = local_user_id.to_string();
let q = format!(
"SELECT f.remote_actor_url, {PG_ACTOR_COLS}
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'"
);
let rows = sqlx::query(&q).bind(&uid).fetch_all(&self.pool).await?;
Ok(rows
.iter()
.map(|row| pg_remote_actor(row, "remote_actor_url"))
.collect())
}
async fn get_accepted_follower_inboxes(
&self,
local_user_id: uuid::Uuid,
) -> Result<Vec<String>> {
let uid = local_user_id.to_string();
let rows = sqlx::query(
"SELECT DISTINCT COALESCE(a.shared_inbox_url, a.inbox_url) as inbox
FROM ap_followers f
INNER JOIN ap_remote_actors a ON a.url = f.remote_actor_url
WHERE f.local_user_id = $1 AND f.status = 'accepted'
AND f.remote_actor_url NOT IN (
SELECT remote_actor_url FROM blocked_actors WHERE local_user_id = $1
)",
)
.bind(&uid)
.fetch_all(&self.pool)
.await?;
Ok(rows
.iter()
.filter_map(|r| r.try_get::<String, _>("inbox").ok())
.collect())
}
async fn count_accepted_followers(&self, local_user_id: uuid::Uuid) -> Result<usize> {
let uid = local_user_id.to_string();
let count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM ap_followers WHERE local_user_id = $1 AND status = 'accepted'",
)
.bind(&uid)
.fetch_one(&self.pool)
.await?;
Ok(count as usize)
}
async fn get_accepted_followers_page(
&self,
local_user_id: uuid::Uuid,
offset: u32,
limit: usize,
) -> Result<Vec<RemoteActor>> {
let uid = local_user_id.to_string();
let q = format!(
"SELECT f.remote_actor_url, {PG_ACTOR_COLS}
FROM ap_followers f LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
WHERE f.local_user_id = $1 AND f.status = 'accepted'
ORDER BY f.created_at ASC LIMIT $2 OFFSET $3"
);
let rows = sqlx::query(&q)
.bind(&uid)
.bind(limit as i64)
.bind(offset as i64)
.fetch_all(&self.pool)
.await?;
Ok(rows
.iter()
.map(|row| pg_remote_actor(row, "remote_actor_url"))
.collect())
}
async fn add_following( async fn add_following(
&self, &self,
local_user_id: uuid::Uuid, local_user_id: uuid::Uuid,
@@ -233,18 +291,11 @@ impl FederationRepository for PostgresFederationRepository {
let uid = local_user_id.to_string(); let uid = local_user_id.to_string();
let now = Utc::now().naive_utc(); let now = Utc::now().naive_utc();
let created_at = datetime_to_str(&now); let created_at = datetime_to_str(&now);
self.upsert_remote_actor(actor.clone()).await?; ActorRepository::upsert_remote_actor(self, actor.clone()).await?;
sqlx::query( sqlx::query(
"INSERT INTO ap_following (local_user_id, remote_actor_url, follow_activity_id, created_at) "INSERT INTO ap_following (local_user_id, remote_actor_url, follow_activity_id, created_at)
VALUES ($1, $2, $3, $4::timestamptz) VALUES ($1, $2, $3, $4::timestamptz) ON CONFLICT DO NOTHING",
ON CONFLICT DO NOTHING", ).bind(&uid).bind(&actor.url).bind(follow_activity_id).bind(&created_at).execute(&self.pool).await?;
)
.bind(&uid)
.bind(&actor.url)
.bind(follow_activity_id)
.bind(&created_at)
.execute(&self.pool)
.await?;
Ok(()) Ok(())
} }
@@ -256,11 +307,7 @@ impl FederationRepository for PostgresFederationRepository {
let uid = local_user_id.to_string(); let uid = local_user_id.to_string();
let row: Option<String> = sqlx::query_scalar( let row: Option<String> = sqlx::query_scalar(
"SELECT follow_activity_id FROM ap_following WHERE local_user_id = $1 AND remote_actor_url = $2", "SELECT follow_activity_id FROM ap_following WHERE local_user_id = $1 AND remote_actor_url = $2",
) ).bind(&uid).bind(remote_actor_url).fetch_optional(&self.pool).await?;
.bind(&uid)
.bind(remote_actor_url)
.fetch_optional(&self.pool)
.await?;
Ok(row) Ok(row)
} }
@@ -276,27 +323,13 @@ impl FederationRepository for PostgresFederationRepository {
async fn get_following(&self, local_user_id: uuid::Uuid) -> Result<Vec<RemoteActor>> { async fn get_following(&self, local_user_id: uuid::Uuid) -> Result<Vec<RemoteActor>> {
let uid = local_user_id.to_string(); let uid = local_user_id.to_string();
let rows = sqlx::query( let q = format!(
"SELECT a.url, a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.avatar_url "SELECT a.url, {PG_ACTOR_COLS}
FROM ap_following f FROM ap_following f INNER JOIN ap_remote_actors a ON a.url = f.remote_actor_url
INNER JOIN ap_remote_actors a ON a.url = f.remote_actor_url WHERE f.local_user_id = $1 AND f.status = 'accepted'"
WHERE f.local_user_id = $1 AND f.status = 'accepted'", );
) let rows = sqlx::query(&q).bind(&uid).fetch_all(&self.pool).await?;
.bind(&uid) Ok(rows.iter().map(|row| pg_remote_actor(row, "url")).collect())
.fetch_all(&self.pool)
.await?;
Ok(rows
.into_iter()
.map(|row| RemoteActor {
url: row.get("url"),
handle: row.get("handle"),
inbox_url: row.get("inbox_url"),
shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(),
display_name: row.try_get("display_name").ok().flatten(),
avatar_url: row.try_get("avatar_url").ok().flatten(),
outbox_url: row.try_get("outbox_url").ok().flatten(),
})
.collect())
} }
async fn count_following(&self, local_user_id: uuid::Uuid) -> Result<usize> { async fn count_following(&self, local_user_id: uuid::Uuid) -> Result<usize> {
@@ -317,84 +350,82 @@ impl FederationRepository for PostgresFederationRepository {
limit: usize, limit: usize,
) -> Result<Vec<RemoteActor>> { ) -> Result<Vec<RemoteActor>> {
let uid = local_user_id.to_string(); let uid = local_user_id.to_string();
let limit_i64 = limit as i64; let q = format!(
let offset_i64 = offset as i64; "SELECT a.url, {PG_ACTOR_COLS}
FROM ap_following f INNER JOIN ap_remote_actors a ON a.url = f.remote_actor_url
let rows = sqlx::query(
"SELECT a.url, a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.avatar_url
FROM ap_following f
INNER JOIN ap_remote_actors a ON a.url = f.remote_actor_url
WHERE f.local_user_id = $1 AND f.status = 'accepted' WHERE f.local_user_id = $1 AND f.status = 'accepted'
ORDER BY f.created_at ASC ORDER BY f.created_at ASC LIMIT $2 OFFSET $3"
LIMIT $2 OFFSET $3", );
) let rows = sqlx::query(&q)
.bind(&uid) .bind(&uid)
.bind(limit_i64) .bind(limit as i64)
.bind(offset_i64) .bind(offset as i64)
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await?; .await?;
Ok(rows.iter().map(|row| pg_remote_actor(row, "url")).collect())
Ok(rows
.into_iter()
.map(|row| RemoteActor {
url: row.get("url"),
handle: row.get("handle"),
inbox_url: row.get("inbox_url"),
shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(),
display_name: row.try_get("display_name").ok().flatten(),
avatar_url: row.try_get("avatar_url").ok().flatten(),
outbox_url: row.try_get("outbox_url").ok().flatten(),
})
.collect())
} }
async fn upsert_remote_actor(&self, actor: RemoteActor) -> Result<()> { async fn update_following_status(
let now = Utc::now().naive_utc(); &self,
let fetched_at = datetime_to_str(&now); local_user_id: uuid::Uuid,
sqlx::query( remote_actor_url: &str,
"INSERT INTO ap_remote_actors (url, handle, inbox_url, shared_inbox_url, display_name, avatar_url, outbox_url, fetched_at) status: FollowingStatus,
VALUES ($1, $2, $3, $4, $5, $6, $7, $8::timestamptz) ) -> Result<()> {
ON CONFLICT(url) DO UPDATE SET let uid = local_user_id.to_string();
handle = EXCLUDED.handle, let status_str = match status {
inbox_url = EXCLUDED.inbox_url, FollowingStatus::Pending => "pending",
shared_inbox_url = EXCLUDED.shared_inbox_url, FollowingStatus::Accepted => "accepted",
display_name = EXCLUDED.display_name, };
avatar_url = EXCLUDED.avatar_url, let result = sqlx::query(
outbox_url = COALESCE(EXCLUDED.outbox_url, ap_remote_actors.outbox_url), "UPDATE ap_following SET status = $1 WHERE local_user_id = $2 AND remote_actor_url = $3",
fetched_at = EXCLUDED.fetched_at", ).bind(status_str).bind(&uid).bind(remote_actor_url).execute(&self.pool).await?;
) if result.rows_affected() == 0 {
.bind(&actor.url) tracing::warn!(local_user_id = %local_user_id, remote_actor_url, "update_following_status: no row found");
.bind(&actor.handle) }
.bind(&actor.inbox_url)
.bind(&actor.shared_inbox_url)
.bind(&actor.display_name)
.bind(&actor.avatar_url)
.bind(&actor.outbox_url)
.bind(&fetched_at)
.execute(&self.pool)
.await?;
Ok(()) Ok(())
} }
async fn get_remote_actor(&self, actor_url: &str) -> Result<Option<RemoteActor>> { async fn get_following_outbox_url(
let row = sqlx::query( &self,
"SELECT url, handle, inbox_url, shared_inbox_url, display_name, avatar_url local_user_id: uuid::Uuid,
FROM ap_remote_actors WHERE url = $1", remote_actor_url: &str,
) ) -> Result<Option<String>> {
.bind(actor_url) let uid = local_user_id.to_string();
.fetch_optional(&self.pool) let row: Option<Option<String>> = sqlx::query_scalar(
.await?; "SELECT a.outbox_url FROM ap_following f INNER JOIN ap_remote_actors a ON a.url = f.remote_actor_url
Ok(row.map(|row| RemoteActor { WHERE f.local_user_id = $1 AND f.remote_actor_url = $2",
url: row.get("url"), ).bind(&uid).bind(remote_actor_url).fetch_optional(&self.pool).await?;
handle: row.get("handle"), Ok(row.flatten())
inbox_url: row.get("inbox_url"),
shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(),
display_name: row.try_get("display_name").ok().flatten(),
avatar_url: row.try_get("avatar_url").ok().flatten(),
outbox_url: row.try_get("outbox_url").ok().flatten(),
}))
} }
async fn migrate_follower_actor(
&self,
old_actor_url: &str,
new_actor_url: &str,
) -> Result<Vec<uuid::Uuid>> {
let candidates: Vec<String> = sqlx::query_scalar(
"SELECT local_user_id FROM ap_following WHERE remote_actor_url = $1
AND local_user_id NOT IN (SELECT local_user_id FROM ap_following WHERE remote_actor_url = $2)",
).bind(old_actor_url).bind(new_actor_url).fetch_all(&self.pool).await?;
if candidates.is_empty() {
return Ok(vec![]);
}
sqlx::query(
"UPDATE ap_following SET remote_actor_url = $1 WHERE remote_actor_url = $2
AND local_user_id NOT IN (SELECT local_user_id FROM ap_following WHERE remote_actor_url = $1)",
).bind(new_actor_url).bind(old_actor_url).execute(&self.pool).await?;
candidates
.into_iter()
.map(|s| uuid::Uuid::parse_str(&s).map_err(|e| anyhow::anyhow!(e)))
.collect()
}
}
#[async_trait]
impl ActorRepository for PostgresFederationRepository {
async fn get_local_actor_keypair( async fn get_local_actor_keypair(
&self, &self,
user_id: uuid::Uuid, user_id: uuid::Uuid,
@@ -420,86 +451,40 @@ impl FederationRepository for PostgresFederationRepository {
sqlx::query( sqlx::query(
"INSERT INTO ap_local_actors (user_id, public_key, private_key, created_at) "INSERT INTO ap_local_actors (user_id, public_key, private_key, created_at)
VALUES ($1, $2, $3, $4::timestamptz) VALUES ($1, $2, $3, $4::timestamptz)
ON CONFLICT(user_id) DO UPDATE SET ON CONFLICT(user_id) DO UPDATE SET public_key = EXCLUDED.public_key, private_key = EXCLUDED.private_key",
public_key = EXCLUDED.public_key, ).bind(&uid).bind(&public_key).bind(&private_key).bind(&created_at).execute(&self.pool).await?;
private_key = EXCLUDED.private_key",
)
.bind(&uid)
.bind(&public_key)
.bind(&private_key)
.bind(&created_at)
.execute(&self.pool)
.await?;
Ok(()) Ok(())
} }
async fn get_pending_followers(&self, local_user_id: uuid::Uuid) -> Result<Vec<RemoteActor>> { async fn upsert_remote_actor(&self, actor: RemoteActor) -> Result<()> {
let uid = local_user_id.to_string(); let now = Utc::now().naive_utc();
let rows = sqlx::query( let fetched_at = datetime_to_str(&now);
"SELECT f.remote_actor_url, a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.avatar_url let aka_json = serde_json::to_string(&actor.also_known_as).unwrap_or_default();
FROM ap_followers f sqlx::query(
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url "INSERT INTO ap_remote_actors (url, handle, inbox_url, shared_inbox_url, display_name, avatar_url, outbox_url, bio, banner_url, followers_url, following_url, also_known_as, fetched_at)
WHERE f.local_user_id = $1 AND f.status = 'pending'", VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13::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,
outbox_url=COALESCE(EXCLUDED.outbox_url, ap_remote_actors.outbox_url),
bio=EXCLUDED.bio, banner_url=EXCLUDED.banner_url, followers_url=EXCLUDED.followers_url,
following_url=EXCLUDED.following_url, also_known_as=EXCLUDED.also_known_as, fetched_at=EXCLUDED.fetched_at",
) )
.bind(&uid) .bind(&actor.url).bind(&actor.handle).bind(&actor.inbox_url).bind(&actor.shared_inbox_url)
.fetch_all(&self.pool) .bind(&actor.display_name).bind(&actor.avatar_url).bind(&actor.outbox_url)
.await?; .bind(&actor.bio).bind(&actor.banner_url).bind(&actor.followers_url).bind(&actor.following_url)
Ok(rows .bind(&aka_json).bind(&fetched_at)
.into_iter() .execute(&self.pool).await?;
.map(|row| RemoteActor {
url: row.get("remote_actor_url"),
handle: row.try_get("handle").unwrap_or_default(),
inbox_url: row.try_get("inbox_url").unwrap_or_default(),
shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(),
display_name: row.try_get("display_name").ok().flatten(),
avatar_url: row.try_get("avatar_url").ok().flatten(),
outbox_url: row.try_get("outbox_url").ok().flatten(),
})
.collect())
}
async fn update_following_status(
&self,
local_user_id: uuid::Uuid,
remote_actor_url: &str,
status: FollowingStatus,
) -> Result<()> {
let uid = local_user_id.to_string();
let status_str = match status {
FollowingStatus::Pending => "pending",
FollowingStatus::Accepted => "accepted",
};
let result = sqlx::query(
"UPDATE ap_following SET status = $1 WHERE local_user_id = $2 AND remote_actor_url = $3",
)
.bind(status_str)
.bind(&uid)
.bind(remote_actor_url)
.execute(&self.pool)
.await?;
if result.rows_affected() == 0 {
tracing::warn!(local_user_id = %local_user_id, remote_actor_url, "update_following_status: no row found");
}
Ok(()) Ok(())
} }
async fn get_following_outbox_url( async fn get_remote_actor(&self, actor_url: &str) -> Result<Option<RemoteActor>> {
&self, let q = format!("SELECT url, {PG_ACTOR_COLS} FROM ap_remote_actors a WHERE url = $1");
local_user_id: uuid::Uuid, let row = sqlx::query(&q)
remote_actor_url: &str, .bind(actor_url)
) -> Result<Option<String>> { .fetch_optional(&self.pool)
let uid = local_user_id.to_string(); .await?;
let row: Option<Option<String>> = sqlx::query_scalar( Ok(row.as_ref().map(|r| pg_remote_actor(r, "url")))
"SELECT a.outbox_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.remote_actor_url = $2",
)
.bind(&uid)
.bind(remote_actor_url)
.fetch_optional(&self.pool)
.await?;
Ok(row.flatten())
} }
async fn add_announce( async fn add_announce(
@@ -510,15 +495,17 @@ impl FederationRepository for PostgresFederationRepository {
announced_at: chrono::DateTime<chrono::Utc>, announced_at: chrono::DateTime<chrono::Utc>,
) -> Result<()> { ) -> Result<()> {
let ts = announced_at.format("%Y-%m-%d %H:%M:%S").to_string(); let ts = announced_at.format("%Y-%m-%d %H:%M:%S").to_string();
sqlx::query( sqlx::query("INSERT INTO ap_announces (id, object_url, actor_url, announced_at) VALUES ($1, $2, $3, $4) ON CONFLICT (id) DO NOTHING")
"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(())
.bind(activity_id) }
.bind(object_url)
.bind(actor_url) async fn remove_announce(&self, activity_id: &str, actor_url: &str) -> Result<()> {
.bind(&ts) sqlx::query("DELETE FROM ap_announces WHERE id = $1 AND actor_url = $2")
.execute(&self.pool) .bind(activity_id)
.await?; .bind(actor_url)
.execute(&self.pool)
.await?;
Ok(()) Ok(())
} }
@@ -529,22 +516,16 @@ impl FederationRepository for PostgresFederationRepository {
.await?; .await?;
Ok(row.get::<i64, _>("cnt") as usize) Ok(row.get::<i64, _>("cnt") as usize)
} }
}
#[async_trait]
impl BlocklistRepository for PostgresFederationRepository {
async fn add_blocked_domain(&self, domain: &str, reason: Option<&str>) -> Result<()> { async fn add_blocked_domain(&self, domain: &str, reason: Option<&str>) -> Result<()> {
let now = Utc::now().naive_utc(); let ts = datetime_to_str(&Utc::now().naive_utc());
let ts = datetime_to_str(&now); sqlx::query("INSERT INTO blocked_domains (domain, reason, blocked_at) VALUES ($1, $2, $3) ON CONFLICT(domain) DO UPDATE SET reason = EXCLUDED.reason")
sqlx::query( .bind(domain).bind(reason).bind(&ts).execute(&self.pool).await?;
"INSERT INTO blocked_domains (domain, reason, blocked_at) VALUES ($1, $2, $3)
ON CONFLICT(domain) DO UPDATE SET reason = EXCLUDED.reason",
)
.bind(domain)
.bind(reason)
.bind(&ts)
.execute(&self.pool)
.await?;
Ok(()) Ok(())
} }
async fn remove_blocked_domain(&self, domain: &str) -> Result<()> { async fn remove_blocked_domain(&self, domain: &str) -> Result<()> {
sqlx::query("DELETE FROM blocked_domains WHERE domain = $1") sqlx::query("DELETE FROM blocked_domains WHERE domain = $1")
.bind(domain) .bind(domain)
@@ -552,7 +533,6 @@ impl FederationRepository for PostgresFederationRepository {
.await?; .await?;
Ok(()) Ok(())
} }
async fn get_blocked_domains(&self) -> Result<Vec<BlockedDomain>> { async fn get_blocked_domains(&self) -> Result<Vec<BlockedDomain>> {
let rows = sqlx::query( let rows = sqlx::query(
"SELECT domain, reason, blocked_at FROM blocked_domains ORDER BY blocked_at DESC", "SELECT domain, reason, blocked_at FROM blocked_domains ORDER BY blocked_at DESC",
@@ -568,7 +548,6 @@ impl FederationRepository for PostgresFederationRepository {
}) })
.collect()) .collect())
} }
async fn is_domain_blocked(&self, domain: &str) -> Result<bool> { async fn is_domain_blocked(&self, domain: &str) -> Result<bool> {
let count: i64 = let count: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM blocked_domains WHERE domain = $1") sqlx::query_scalar("SELECT COUNT(*) FROM blocked_domains WHERE domain = $1")
@@ -577,23 +556,13 @@ impl FederationRepository for PostgresFederationRepository {
.await?; .await?;
Ok(count > 0) Ok(count > 0)
} }
async fn add_blocked_actor(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()> { async fn add_blocked_actor(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()> {
let uid = local_user_id.to_string(); let uid = local_user_id.to_string();
let ts = datetime_to_str(&Utc::now().naive_utc()); let ts = datetime_to_str(&Utc::now().naive_utc());
sqlx::query( sqlx::query("INSERT INTO blocked_actors (local_user_id, remote_actor_url, blocked_at) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING")
"INSERT INTO blocked_actors (local_user_id, remote_actor_url, blocked_at) .bind(&uid).bind(actor_url).bind(&ts).execute(&self.pool).await?;
VALUES ($1, $2, $3)
ON CONFLICT DO NOTHING",
)
.bind(&uid)
.bind(actor_url)
.bind(&ts)
.execute(&self.pool)
.await?;
Ok(()) Ok(())
} }
async fn remove_blocked_actor(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()> { async fn remove_blocked_actor(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()> {
let uid = local_user_id.to_string(); let uid = local_user_id.to_string();
sqlx::query( sqlx::query(
@@ -605,70 +574,42 @@ impl FederationRepository for PostgresFederationRepository {
.await?; .await?;
Ok(()) Ok(())
} }
async fn get_blocked_actors(&self, local_user_id: uuid::Uuid) -> Result<Vec<String>> { async fn get_blocked_actors(&self, local_user_id: uuid::Uuid) -> Result<Vec<String>> {
let uid = local_user_id.to_string(); let uid = local_user_id.to_string();
let rows = sqlx::query( let rows = sqlx::query("SELECT remote_actor_url FROM blocked_actors WHERE local_user_id = $1 ORDER BY blocked_at DESC")
"SELECT remote_actor_url FROM blocked_actors WHERE local_user_id = $1 ORDER BY blocked_at DESC", .bind(&uid).fetch_all(&self.pool).await?;
)
.bind(&uid)
.fetch_all(&self.pool)
.await?;
Ok(rows Ok(rows
.iter() .iter()
.map(|r| r.get::<String, _>("remote_actor_url")) .map(|r| r.get::<String, _>("remote_actor_url"))
.collect()) .collect())
} }
async fn is_actor_blocked(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<bool> { async fn is_actor_blocked(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<bool> {
let uid = local_user_id.to_string(); let uid = local_user_id.to_string();
let count: i64 = sqlx::query_scalar( let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM blocked_actors WHERE local_user_id = $1 AND remote_actor_url = $2")
"SELECT COUNT(*) FROM blocked_actors WHERE local_user_id = $1 AND remote_actor_url = $2", .bind(&uid).bind(actor_url).fetch_one(&self.pool).await?;
)
.bind(&uid)
.bind(actor_url)
.fetch_one(&self.pool)
.await?;
Ok(count > 0) Ok(count > 0)
} }
}
async fn migrate_follower_actor( #[async_trait]
&self, impl ActivityRepository for PostgresFederationRepository {
old_actor_url: &str, async fn is_activity_processed(&self, activity_id: &str) -> Result<bool> {
new_actor_url: &str, let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM ap_activities WHERE id = $1")
) -> Result<Vec<uuid::Uuid>> { .bind(activity_id)
let candidates: Vec<String> = sqlx::query_scalar( .fetch_one(&self.pool)
"SELECT local_user_id FROM ap_following .await?;
WHERE remote_actor_url = $1 Ok(count > 0)
AND local_user_id NOT IN ( }
SELECT local_user_id FROM ap_following WHERE remote_actor_url = $2 async fn mark_activity_processed(&self, activity_id: &str) -> Result<()> {
)", let ts = datetime_to_str(&Utc::now().naive_utc());
)
.bind(old_actor_url)
.bind(new_actor_url)
.fetch_all(&self.pool)
.await?;
if candidates.is_empty() {
return Ok(vec![]);
}
sqlx::query( sqlx::query(
"UPDATE ap_following SET remote_actor_url = $1 "INSERT INTO ap_activities (id, processed_at) VALUES ($1, $2) ON CONFLICT DO NOTHING",
WHERE remote_actor_url = $2
AND local_user_id NOT IN (
SELECT local_user_id FROM ap_following WHERE remote_actor_url = $1
)",
) )
.bind(new_actor_url) .bind(activity_id)
.bind(old_actor_url) .bind(&ts)
.execute(&self.pool) .execute(&self.pool)
.await?; .await?;
Ok(())
candidates
.into_iter()
.map(|s| uuid::Uuid::parse_str(&s).map_err(|e| anyhow::anyhow!(e)))
.collect()
} }
} }
@@ -807,6 +748,64 @@ impl domain::ports::SocialQueryPort for PostgresFederationRepository {
) )
.collect()) .collect())
} }
async fn count_following(
&self,
user_id: uuid::Uuid,
) -> Result<usize, domain::errors::DomainError> {
let uid = user_id.to_string();
let count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM ap_following WHERE local_user_id = $1 AND status = 'accepted'",
)
.bind(&uid)
.fetch_one(&self.pool)
.await
.map_err(|e| domain::errors::DomainError::InfrastructureError(e.to_string()))?;
Ok(count as usize)
}
async fn count_accepted_followers(
&self,
user_id: uuid::Uuid,
) -> Result<usize, domain::errors::DomainError> {
let uid = user_id.to_string();
let count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM ap_followers WHERE local_user_id = $1 AND status = 'accepted'",
)
.bind(&uid)
.fetch_one(&self.pool)
.await
.map_err(|e| domain::errors::DomainError::InfrastructureError(e.to_string()))?;
Ok(count as usize)
}
async fn get_pending_followers(
&self,
user_id: uuid::Uuid,
) -> Result<Vec<domain::ports::PendingFollowerInfo>, domain::errors::DomainError> {
let uid = user_id.to_string();
let rows = sqlx::query_as::<_, (String, String, Option<String>, Option<String>)>(
"SELECT ar.url, ar.handle, ar.display_name, ar.avatar_url
FROM ap_followers f
JOIN ap_remote_actors ar ON ar.url = f.remote_actor_url
WHERE f.local_user_id = $1 AND f.status = 'pending'",
)
.bind(&uid)
.fetch_all(&self.pool)
.await
.map_err(|e| domain::errors::DomainError::InfrastructureError(e.to_string()))?;
Ok(rows
.into_iter()
.map(
|(url, handle, display_name, avatar_url)| domain::ports::PendingFollowerInfo {
url,
handle,
display_name,
avatar_url,
},
)
.collect())
}
} }
#[async_trait] #[async_trait]
@@ -913,16 +912,12 @@ impl RemoteWatchlistRepository for PostgresFederationRepository {
} }
} }
pub fn wire( pub fn wire(pool: sqlx::PgPool) -> activitypub::FederationRepos {
pool: sqlx::PgPool,
) -> (
std::sync::Arc<dyn activitypub::FederationRepository>,
std::sync::Arc<dyn domain::ports::SocialQueryPort>,
std::sync::Arc<dyn activitypub::RemoteReviewRepository>,
std::sync::Arc<dyn domain::ports::RemoteWatchlistRepository>,
) {
let fed = std::sync::Arc::new(PostgresFederationRepository::new(pool)); let fed = std::sync::Arc::new(PostgresFederationRepository::new(pool));
( (
std::sync::Arc::clone(&fed) as _,
std::sync::Arc::clone(&fed) as _,
std::sync::Arc::clone(&fed) as _,
std::sync::Arc::clone(&fed) as _, std::sync::Arc::clone(&fed) as _,
std::sync::Arc::clone(&fed) as _, std::sync::Arc::clone(&fed) as _,
std::sync::Arc::clone(&fed) as _, std::sync::Arc::clone(&fed) as _,

View File

@@ -0,0 +1,12 @@
-- k-ap 0.3: new RemoteActor fields + activity dedup table
ALTER TABLE ap_remote_actors ADD COLUMN IF NOT EXISTS bio TEXT;
ALTER TABLE ap_remote_actors ADD COLUMN IF NOT EXISTS banner_url TEXT;
ALTER TABLE ap_remote_actors ADD COLUMN IF NOT EXISTS followers_url TEXT;
ALTER TABLE ap_remote_actors ADD COLUMN IF NOT EXISTS following_url TEXT;
ALTER TABLE ap_remote_actors ADD COLUMN IF NOT EXISTS also_known_as TEXT; -- JSON array
CREATE TABLE IF NOT EXISTS ap_activities (
id TEXT PRIMARY KEY,
processed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

View File

@@ -0,0 +1 @@
ALTER TABLE users ADD COLUMN IF NOT EXISTS display_name TEXT;

View File

@@ -90,12 +90,24 @@ impl LocalApContentQuery for PostgresApContentQuery {
)?, )?,
}; };
let movie = MovieRow { let movie = MovieRow {
id: row.try_get("m_id").map_err(|e| DomainError::InfrastructureError(e.to_string()))?, id: row
external_metadata_id: row.try_get("external_metadata_id").map_err(|e| DomainError::InfrastructureError(e.to_string()))?, .try_get("m_id")
title: row.try_get("title").map_err(|e| DomainError::InfrastructureError(e.to_string()))?, .map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
release_year: row.try_get("release_year").map_err(|e| DomainError::InfrastructureError(e.to_string()))?, external_metadata_id: row
director: row.try_get("director").map_err(|e| DomainError::InfrastructureError(e.to_string()))?, .try_get("external_metadata_id")
poster_path: row.try_get("poster_path").map_err(|e| DomainError::InfrastructureError(e.to_string()))?, .map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
title: row
.try_get("title")
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
release_year: row
.try_get("release_year")
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
director: row
.try_get("director")
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
poster_path: row
.try_get("poster_path")
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
} }
.into_domain()?; .into_domain()?;
Ok(WatchlistWithMovie { entry, movie }) Ok(WatchlistWithMovie { entry, movie })
@@ -103,10 +115,7 @@ impl LocalApContentQuery for PostgresApContentQuery {
.collect() .collect()
} }
async fn get_review_by_id( async fn get_review_by_id(&self, review_id: &ReviewId) -> Result<Option<Review>, DomainError> {
&self,
review_id: &ReviewId,
) -> Result<Option<Review>, DomainError> {
let id = review_id.value().to_string(); let id = review_id.value().to_string();
sqlx::query_as::<_, ReviewRow>( sqlx::query_as::<_, ReviewRow>(
"SELECT id, movie_id, user_id, rating, comment, "SELECT id, movie_id, user_id, rating, comment,
@@ -145,4 +154,55 @@ impl LocalApContentQuery for PostgresApContentQuery {
.map_err(Self::map_err)?; .map_err(Self::map_err)?;
Ok(count as u64) Ok(count as u64)
} }
async fn get_local_reviews_page(
&self,
user_id: &UserId,
before: Option<chrono::NaiveDateTime>,
limit: usize,
) -> Result<Vec<DiaryEntry>, DomainError> {
let uid = user_id.value().to_string();
let limit_i64 = limit as i64;
let rows = if let Some(before_ts) = before {
let ts = before_ts.format("%Y-%m-%d %H:%M:%S").to_string();
sqlx::query_as::<_, DiaryRow>(
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment,
to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
r.remote_actor_url
FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id
WHERE r.user_id = $1 AND r.remote_actor_url IS NULL AND r.watched_at < $2::timestamptz
ORDER BY r.watched_at DESC
LIMIT $3",
)
.bind(&uid)
.bind(&ts)
.bind(limit_i64)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)?
} else {
sqlx::query_as::<_, DiaryRow>(
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment,
to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
r.remote_actor_url
FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id
WHERE r.user_id = $1 AND r.remote_actor_url IS NULL
ORDER BY r.watched_at DESC
LIMIT $2",
)
.bind(&uid)
.bind(limit_i64)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)?
};
rows.into_iter().map(DiaryRow::into_domain).collect()
}
} }

View File

@@ -1,7 +1,10 @@
use chrono::NaiveDateTime; use chrono::NaiveDateTime;
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
models::{DiaryEntry, FeedEntry, Movie, MovieSummary, Review, ReviewSource, UserSummary}, models::{
DiaryEntry, FeedEntry, Movie, MovieSummary, PersistedReview, Review, ReviewSource,
UserSummary,
},
value_objects::{ value_objects::{
Comment, Email, ExternalMetadataId, MovieId, MovieTitle, PosterPath, Rating, ReleaseYear, Comment, Email, ExternalMetadataId, MovieId, MovieTitle, PosterPath, Rating, ReleaseYear,
ReviewId, UserId, ReviewId, UserId,
@@ -102,9 +105,16 @@ impl ReviewRow {
None => ReviewSource::Local, None => ReviewSource::Local,
Some(url) => ReviewSource::Remote { actor_url: url }, Some(url) => ReviewSource::Remote { actor_url: url },
}; };
Ok(Review::from_persistence( Ok(Review::from_persistence(PersistedReview {
id, movie_id, user_id, rating, comment, watched_at, created_at, source, id,
)) movie_id,
user_id,
rating,
comment,
watched_at,
created_at,
source,
}))
} }
} }

View File

@@ -1,6 +1,6 @@
use async_trait::async_trait; use async_trait::async_trait;
use chrono::Utc; use chrono::Utc;
use sqlx::PgPool; use sqlx::{PgPool, Row};
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
@@ -33,116 +33,67 @@ impl PostgresUserRepository {
} }
fn row_to_user( fn row_to_user(
id_str: String, row: &sqlx::postgres::PgRow,
email_str: String,
username_str: String,
hash_str: String,
role: UserRole,
bio: Option<String>,
avatar_path: Option<String>,
banner_path: Option<String>,
also_known_as: Option<String>,
profile_fields: Vec<ProfileField>, profile_fields: Vec<ProfileField>,
) -> Result<User, DomainError> { ) -> Result<User, DomainError> {
let id_str: String = row.get("id");
let id = uuid::Uuid::parse_str(&id_str) let id = uuid::Uuid::parse_str(&id_str)
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?; .map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
let email = let email = Email::new(row.get::<String, _>("email"))
Email::new(email_str).map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
let username = Username::new(username_str)
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?; .map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
let hash = PasswordHash::new(hash_str) let username = Username::new(row.get::<String, _>("username"))
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?; .map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
let hash = PasswordHash::new(row.get::<String, _>("password_hash"))
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
let role_str: String = row.get("role");
Ok(User::from_persistence( Ok(User::from_persistence(
UserId::from_uuid(id), UserId::from_uuid(id),
email, email,
username, username,
hash, hash,
role, Self::parse_role(&role_str),
bio, domain::models::UserProfile {
avatar_path, display_name: row.try_get("display_name").ok().flatten(),
banner_path, bio: row.try_get("bio").ok().flatten(),
also_known_as, avatar_path: row.try_get("avatar_path").ok().flatten(),
profile_fields, banner_path: row.try_get("banner_path").ok().flatten(),
also_known_as: row.try_get("also_known_as").ok().flatten(),
profile_fields,
},
)) ))
} }
} }
const PG_USER_COLS: &str = "id, email, username, password_hash, role, display_name, bio, avatar_path, banner_path, also_known_as";
#[async_trait] #[async_trait]
impl UserRepository for PostgresUserRepository { impl UserRepository for PostgresUserRepository {
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError> { async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError> {
let email_str = email.value(); let email_str = email.value();
#[derive(sqlx::FromRow)] let row = sqlx::query(&format!(
struct Row { "SELECT {PG_USER_COLS} FROM users WHERE email = $1"
id: String, ))
email: String,
username: String,
password_hash: String,
role: String,
bio: Option<String>,
avatar_path: Option<String>,
banner_path: Option<String>,
also_known_as: Option<String>,
}
let row = sqlx::query_as::<_, Row>(
"SELECT id, email, username, password_hash, role, bio, avatar_path, banner_path, also_known_as FROM users WHERE email = $1",
)
.bind(email_str) .bind(email_str)
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await .await
.map_err(Self::map_err)?; .map_err(Self::map_err)?;
row.map(|r| { row.as_ref()
Self::row_to_user( .map(|r| Self::row_to_user(r, vec![]))
r.id, .transpose()
r.email,
r.username,
r.password_hash,
Self::parse_role(&r.role),
r.bio,
r.avatar_path,
r.banner_path,
r.also_known_as,
vec![],
)
})
.transpose()
} }
async fn find_by_username(&self, username: &Username) -> Result<Option<User>, DomainError> { async fn find_by_username(&self, username: &Username) -> Result<Option<User>, DomainError> {
let username_str = username.value(); let username_str = username.value();
#[derive(sqlx::FromRow)] let row = sqlx::query(&format!(
struct Row { "SELECT {PG_USER_COLS} FROM users WHERE username = $1"
id: String, ))
email: String,
username: String,
password_hash: String,
role: String,
bio: Option<String>,
avatar_path: Option<String>,
banner_path: Option<String>,
also_known_as: Option<String>,
}
let row = sqlx::query_as::<_, Row>(
"SELECT id, email, username, password_hash, role, bio, avatar_path, banner_path, also_known_as FROM users WHERE username = $1",
)
.bind(username_str) .bind(username_str)
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await .await
.map_err(Self::map_err)?; .map_err(Self::map_err)?;
row.map(|r| { row.as_ref()
Self::row_to_user( .map(|r| Self::row_to_user(r, vec![]))
r.id, .transpose()
r.email,
r.username,
r.password_hash,
Self::parse_role(&r.role),
r.bio,
r.avatar_path,
r.banner_path,
r.also_known_as,
vec![],
)
})
.transpose()
} }
async fn save(&self, user: &User) -> Result<(), DomainError> { async fn save(&self, user: &User) -> Result<(), DomainError> {
@@ -185,34 +136,15 @@ impl UserRepository for PostgresUserRepository {
async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError> { async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError> {
let id_str = id.value().to_string(); let id_str = id.value().to_string();
#[derive(sqlx::FromRow)] let row = sqlx::query(&format!("SELECT {PG_USER_COLS} FROM users WHERE id = $1"))
struct Row { .bind(&id_str)
id: String, .fetch_optional(&self.pool)
email: String, .await
username: String, .map_err(Self::map_err)?;
password_hash: String,
role: String,
bio: Option<String>,
avatar_path: Option<String>,
banner_path: Option<String>,
also_known_as: Option<String>,
}
let row = sqlx::query_as::<_, Row>(
"SELECT id, email, username, password_hash, role, bio, avatar_path, banner_path, also_known_as FROM users WHERE id = $1",
)
.bind(&id_str)
.fetch_optional(&self.pool)
.await
.map_err(Self::map_err)?;
let Some(r) = row else { return Ok(None) }; let Some(r) = row else { return Ok(None) };
#[derive(sqlx::FromRow)] let field_rows = sqlx::query(
struct FieldRow {
name: String,
value: String,
}
let field_rows = sqlx::query_as::<_, FieldRow>(
"SELECT name, value FROM user_profile_fields WHERE user_id = $1 ORDER BY position ASC", "SELECT name, value FROM user_profile_fields WHERE user_id = $1 ORDER BY position ASC",
) )
.bind(&id_str) .bind(&id_str)
@@ -221,44 +153,30 @@ impl UserRepository for PostgresUserRepository {
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?; .map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
let profile_fields = field_rows let profile_fields = field_rows
.into_iter() .iter()
.map(|f| ProfileField { .map(|f| ProfileField {
name: f.name, name: f.get("name"),
value: f.value, value: f.get("value"),
}) })
.collect(); .collect();
Self::row_to_user( Self::row_to_user(&r, profile_fields).map(Some)
r.id,
r.email,
r.username,
r.password_hash,
Self::parse_role(&r.role),
r.bio,
r.avatar_path,
r.banner_path,
r.also_known_as,
profile_fields,
)
.map(Some)
} }
async fn update_profile( async fn update_profile(
&self, &self,
user_id: &UserId, user_id: &UserId,
bio: Option<String>, profile: &domain::models::UserProfile,
avatar_path: Option<String>,
banner_path: Option<String>,
also_known_as: Option<String>,
) -> Result<(), DomainError> { ) -> Result<(), DomainError> {
let id_str = user_id.value().to_string(); let id_str = user_id.value().to_string();
sqlx::query( sqlx::query(
"UPDATE users SET bio = $1, avatar_path = $2, banner_path = $3, also_known_as = $4 WHERE id = $5", "UPDATE users SET display_name = $1, bio = $2, avatar_path = $3, banner_path = $4, also_known_as = $5 WHERE id = $6",
) )
.bind(&bio) .bind(&profile.display_name)
.bind(&avatar_path) .bind(&profile.bio)
.bind(&banner_path) .bind(&profile.avatar_path)
.bind(&also_known_as) .bind(&profile.banner_path)
.bind(&profile.also_known_as)
.bind(&id_str) .bind(&id_str)
.execute(&self.pool) .execute(&self.pool)
.await .await

View File

@@ -6,9 +6,10 @@ edition = "2024"
[dependencies] [dependencies]
sqlx = { workspace = true } sqlx = { workspace = true }
activitypub = { workspace = true } activitypub = { workspace = true }
k-ap = { git = "https://git.gabrielkaszewski.dev/GKaszewski/k-ap.git", tag = "v0.1.10" } k-ap = { version = "0.3.1", registry = "gitea" }
domain = { workspace = true } domain = { workspace = true }
anyhow = { workspace = true } anyhow = { workspace = true }
serde_json = { workspace = true }
uuid = { workspace = true } uuid = { workspace = true }
chrono = { workspace = true } chrono = { workspace = true }
tracing = { workspace = true } tracing = { workspace = true }

View File

@@ -4,11 +4,12 @@ use chrono::{NaiveDateTime, Utc};
use sqlx::{Row, SqlitePool}; use sqlx::{Row, SqlitePool};
use activitypub::RemoteReviewRepository; use activitypub::RemoteReviewRepository;
use k_ap::{
BlockedDomain, FederationRepository, Follower, FollowerStatus, FollowingStatus, RemoteActor,
};
use domain::models::{RemoteWatchlistEntry, Review, ReviewSource}; use domain::models::{RemoteWatchlistEntry, Review, ReviewSource};
use domain::ports::RemoteWatchlistRepository; use domain::ports::RemoteWatchlistRepository;
use k_ap::{
ActivityRepository, ActorRepository, BlockedDomain, BlocklistRepository, FollowRepository,
Follower, FollowerStatus, FollowingStatus, RemoteActor,
};
fn datetime_to_str(dt: &NaiveDateTime) -> String { fn datetime_to_str(dt: &NaiveDateTime) -> String {
dt.format("%Y-%m-%d %H:%M:%S").to_string() dt.format("%Y-%m-%d %H:%M:%S").to_string()
@@ -40,8 +41,35 @@ fn str_to_status(s: &str) -> FollowerStatus {
} }
} }
fn remote_actor_from_row(row: &sqlx::sqlite::SqliteRow, url_col: &str) -> RemoteActor {
RemoteActor {
url: row.get(url_col),
handle: row.try_get("handle").unwrap_or_default(),
inbox_url: row.try_get("inbox_url").unwrap_or_default(),
shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(),
display_name: row.try_get("display_name").ok().flatten(),
avatar_url: row.try_get("avatar_url").ok().flatten(),
outbox_url: row.try_get("outbox_url").ok().flatten(),
bio: row.try_get("bio").ok().flatten(),
banner_url: row.try_get("banner_url").ok().flatten(),
followers_url: row.try_get("followers_url").ok().flatten(),
following_url: row.try_get("following_url").ok().flatten(),
also_known_as: row
.try_get::<Option<String>, _>("also_known_as")
.ok()
.flatten()
.map(|s| {
serde_json::from_str::<Vec<String>>(&s).unwrap_or_else(|e| {
tracing::warn!(raw = %s, error = %e, "failed to parse also_known_as JSON");
vec![s]
})
})
.unwrap_or_default(),
}
}
#[async_trait] #[async_trait]
impl FederationRepository for SqliteFederationRepository { impl FollowRepository for SqliteFederationRepository {
async fn add_follower( async fn add_follower(
&self, &self,
local_user_id: uuid::Uuid, local_user_id: uuid::Uuid,
@@ -107,7 +135,8 @@ impl FederationRepository for SqliteFederationRepository {
let rows = sqlx::query( let rows = sqlx::query(
"SELECT f.remote_actor_url, f.status, "SELECT f.remote_actor_url, f.status,
a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.avatar_url a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.avatar_url,
a.outbox_url, a.bio, a.banner_url, a.followers_url, a.following_url, a.also_known_as
FROM ap_followers f FROM ap_followers f
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
WHERE f.local_user_id = ?", WHERE f.local_user_id = ?",
@@ -116,34 +145,16 @@ impl FederationRepository for SqliteFederationRepository {
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await?; .await?;
let followers = rows Ok(rows
.into_iter() .iter()
.map(|row| { .map(|row| {
let url: String = row.get("remote_actor_url");
let status_str: String = row.get("status"); let status_str: String = row.get("status");
let handle: String = row.try_get("handle").unwrap_or_default();
let inbox_url: String = row.try_get("inbox_url").unwrap_or_default();
let shared_inbox_url: Option<String> =
row.try_get("shared_inbox_url").ok().flatten();
let display_name: Option<String> = row.try_get("display_name").ok().flatten();
let avatar_url: Option<String> = row.try_get("avatar_url").ok().flatten();
Follower { Follower {
actor: RemoteActor { actor: remote_actor_from_row(row, "remote_actor_url"),
url,
handle,
inbox_url,
shared_inbox_url,
display_name,
avatar_url,
outbox_url: row.try_get("outbox_url").ok().flatten(),
},
status: str_to_status(&status_str), status: str_to_status(&status_str),
} }
}) })
.collect(); .collect())
Ok(followers)
} }
async fn get_followers_page( async fn get_followers_page(
@@ -158,7 +169,8 @@ impl FederationRepository for SqliteFederationRepository {
let rows = sqlx::query( let rows = sqlx::query(
"SELECT f.remote_actor_url, f.status, "SELECT f.remote_actor_url, f.status,
a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.avatar_url a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.avatar_url,
a.outbox_url, a.bio, a.banner_url, a.followers_url, a.following_url, a.also_known_as
FROM ap_followers f FROM ap_followers f
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
WHERE f.local_user_id = ? AND f.status = 'accepted' WHERE f.local_user_id = ? AND f.status = 'accepted'
@@ -172,26 +184,11 @@ impl FederationRepository for SqliteFederationRepository {
.await?; .await?;
Ok(rows Ok(rows
.into_iter() .iter()
.map(|row| { .map(|row| {
let url: String = row.get("remote_actor_url");
let status_str: String = row.get("status"); let status_str: String = row.get("status");
let handle: String = row.try_get("handle").unwrap_or_default();
let inbox_url: String = row.try_get("inbox_url").unwrap_or_default();
let shared_inbox_url: Option<String> =
row.try_get("shared_inbox_url").ok().flatten();
let display_name: Option<String> = row.try_get("display_name").ok().flatten();
let avatar_url: Option<String> = row.try_get("avatar_url").ok().flatten();
Follower { Follower {
actor: RemoteActor { actor: remote_actor_from_row(row, "remote_actor_url"),
url,
handle,
inbox_url,
shared_inbox_url,
display_name,
avatar_url,
outbox_url: row.try_get("outbox_url").ok().flatten(),
},
status: str_to_status(&status_str), status: str_to_status(&status_str),
} }
}) })
@@ -234,6 +231,95 @@ impl FederationRepository for SqliteFederationRepository {
Ok(()) Ok(())
} }
async fn get_pending_followers(&self, local_user_id: uuid::Uuid) -> Result<Vec<RemoteActor>> {
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, a.avatar_url,
a.outbox_url, a.bio, a.banner_url, a.followers_url, a.following_url, a.also_known_as
FROM ap_followers f
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
WHERE f.local_user_id = ? AND f.status = 'pending'",
)
.bind(&uid)
.fetch_all(&self.pool)
.await?;
Ok(rows
.iter()
.map(|row| remote_actor_from_row(row, "remote_actor_url"))
.collect())
}
async fn get_accepted_follower_inboxes(
&self,
local_user_id: uuid::Uuid,
) -> Result<Vec<String>> {
let uid = local_user_id.to_string();
let rows = sqlx::query(
"SELECT DISTINCT COALESCE(a.shared_inbox_url, a.inbox_url) as inbox
FROM ap_followers f
INNER JOIN ap_remote_actors a ON a.url = f.remote_actor_url
WHERE f.local_user_id = ? AND f.status = 'accepted'
AND f.remote_actor_url NOT IN (
SELECT remote_actor_url FROM blocked_actors WHERE local_user_id = ?
)",
)
.bind(&uid)
.bind(&uid)
.fetch_all(&self.pool)
.await?;
Ok(rows
.iter()
.filter_map(|r| r.try_get::<String, _>("inbox").ok())
.collect())
}
async fn count_accepted_followers(&self, local_user_id: uuid::Uuid) -> Result<usize> {
let uid = local_user_id.to_string();
let count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM ap_followers WHERE local_user_id = ? AND status = 'accepted'",
)
.bind(&uid)
.fetch_one(&self.pool)
.await?;
Ok(count as usize)
}
async fn get_accepted_followers_page(
&self,
local_user_id: uuid::Uuid,
offset: u32,
limit: usize,
) -> Result<Vec<RemoteActor>> {
let uid = local_user_id.to_string();
let limit_i64 = limit as i64;
let offset_i64 = offset as i64;
let rows = sqlx::query(
"SELECT f.remote_actor_url,
a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.avatar_url,
a.outbox_url, a.bio, a.banner_url, a.followers_url, a.following_url, a.also_known_as
FROM ap_followers f
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
WHERE f.local_user_id = ? AND f.status = 'accepted'
ORDER BY f.created_at ASC
LIMIT ? OFFSET ?",
)
.bind(&uid)
.bind(limit_i64)
.bind(offset_i64)
.fetch_all(&self.pool)
.await?;
Ok(rows
.iter()
.map(|row| remote_actor_from_row(row, "remote_actor_url"))
.collect())
}
async fn add_following( async fn add_following(
&self, &self,
local_user_id: uuid::Uuid, local_user_id: uuid::Uuid,
@@ -244,7 +330,7 @@ impl FederationRepository for SqliteFederationRepository {
let now = Utc::now().naive_utc(); let now = Utc::now().naive_utc();
let created_at = datetime_to_str(&now); let created_at = datetime_to_str(&now);
self.upsert_remote_actor(actor.clone()).await?; ActorRepository::upsert_remote_actor(self, actor.clone()).await?;
sqlx::query( sqlx::query(
"INSERT OR IGNORE INTO ap_following (local_user_id, remote_actor_url, follow_activity_id, created_at) "INSERT OR IGNORE INTO ap_following (local_user_id, remote_actor_url, follow_activity_id, created_at)
@@ -290,7 +376,8 @@ impl FederationRepository for SqliteFederationRepository {
let uid = local_user_id.to_string(); let uid = local_user_id.to_string();
let rows = sqlx::query( let rows = sqlx::query(
"SELECT a.url, a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.avatar_url "SELECT a.url, a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.avatar_url,
a.outbox_url, a.bio, a.banner_url, a.followers_url, a.following_url, a.also_known_as
FROM ap_following f FROM ap_following f
INNER JOIN ap_remote_actors a ON a.url = f.remote_actor_url INNER JOIN ap_remote_actors a ON a.url = f.remote_actor_url
WHERE f.local_user_id = ? AND f.status = 'accepted'", WHERE f.local_user_id = ? AND f.status = 'accepted'",
@@ -300,16 +387,8 @@ impl FederationRepository for SqliteFederationRepository {
.await?; .await?;
Ok(rows Ok(rows
.into_iter() .iter()
.map(|row| RemoteActor { .map(|row| remote_actor_from_row(row, "url"))
url: row.get("url"),
handle: row.get("handle"),
inbox_url: row.get("inbox_url"),
shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(),
display_name: row.try_get("display_name").ok().flatten(),
avatar_url: row.try_get("avatar_url").ok().flatten(),
outbox_url: row.try_get("outbox_url").ok().flatten(),
})
.collect()) .collect())
} }
@@ -335,7 +414,8 @@ impl FederationRepository for SqliteFederationRepository {
let offset_i64 = offset as i64; let offset_i64 = offset as i64;
let rows = sqlx::query( let rows = sqlx::query(
"SELECT a.url, a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.avatar_url "SELECT a.url, a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.avatar_url,
a.outbox_url, a.bio, a.banner_url, a.followers_url, a.following_url, a.also_known_as
FROM ap_following f FROM ap_following f
INNER JOIN ap_remote_actors a ON a.url = f.remote_actor_url INNER JOIN ap_remote_actors a ON a.url = f.remote_actor_url
WHERE f.local_user_id = ? AND f.status = 'accepted' WHERE f.local_user_id = ? AND f.status = 'accepted'
@@ -349,134 +429,8 @@ impl FederationRepository for SqliteFederationRepository {
.await?; .await?;
Ok(rows Ok(rows
.into_iter() .iter()
.map(|row| RemoteActor { .map(|row| remote_actor_from_row(row, "url"))
url: row.get("url"),
handle: row.get("handle"),
inbox_url: row.get("inbox_url"),
shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(),
display_name: row.try_get("display_name").ok().flatten(),
avatar_url: row.try_get("avatar_url").ok().flatten(),
outbox_url: row.try_get("outbox_url").ok().flatten(),
})
.collect())
}
async fn upsert_remote_actor(&self, actor: RemoteActor) -> Result<()> {
let now = Utc::now().naive_utc();
let fetched_at = datetime_to_str(&now);
sqlx::query(
"INSERT INTO ap_remote_actors (url, handle, inbox_url, shared_inbox_url, display_name, avatar_url, outbox_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,
outbox_url = COALESCE(excluded.outbox_url, ap_remote_actors.outbox_url),
fetched_at = excluded.fetched_at",
)
.bind(&actor.url)
.bind(&actor.handle)
.bind(&actor.inbox_url)
.bind(&actor.shared_inbox_url)
.bind(&actor.display_name)
.bind(&actor.avatar_url)
.bind(&actor.outbox_url)
.bind(&fetched_at)
.execute(&self.pool)
.await?;
Ok(())
}
async fn get_remote_actor(&self, actor_url: &str) -> Result<Option<RemoteActor>> {
let row = sqlx::query(
"SELECT url, handle, inbox_url, shared_inbox_url, display_name, avatar_url
FROM ap_remote_actors WHERE url = ?",
)
.bind(actor_url)
.fetch_optional(&self.pool)
.await?;
Ok(row.map(|row| RemoteActor {
url: row.get("url"),
handle: row.get("handle"),
inbox_url: row.get("inbox_url"),
shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(),
display_name: row.try_get("display_name").ok().flatten(),
avatar_url: row.try_get("avatar_url").ok().flatten(),
outbox_url: row.try_get("outbox_url").ok().flatten(),
}))
}
async fn get_local_actor_keypair(
&self,
user_id: uuid::Uuid,
) -> Result<Option<(String, String)>> {
let uid = user_id.to_string();
let row =
sqlx::query("SELECT public_key, private_key FROM ap_local_actors WHERE user_id = ?")
.bind(&uid)
.fetch_optional(&self.pool)
.await?;
Ok(row.map(|r| (r.get("public_key"), r.get("private_key"))))
}
async fn save_local_actor_keypair(
&self,
user_id: uuid::Uuid,
public_key: String,
private_key: String,
) -> Result<()> {
let uid = user_id.to_string();
let now = Utc::now().naive_utc();
let created_at = datetime_to_str(&now);
sqlx::query(
"INSERT INTO ap_local_actors (user_id, public_key, private_key, created_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET
public_key = excluded.public_key,
private_key = excluded.private_key",
)
.bind(&uid)
.bind(&public_key)
.bind(&private_key)
.bind(&created_at)
.execute(&self.pool)
.await?;
Ok(())
}
async fn get_pending_followers(&self, local_user_id: uuid::Uuid) -> Result<Vec<RemoteActor>> {
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, 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'",
)
.bind(&uid)
.fetch_all(&self.pool)
.await?;
Ok(rows
.into_iter()
.map(|row| RemoteActor {
url: row.get("remote_actor_url"),
handle: row.try_get("handle").unwrap_or_default(),
inbox_url: row.try_get("inbox_url").unwrap_or_default(),
shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(),
display_name: row.try_get("display_name").ok().flatten(),
avatar_url: row.try_get("avatar_url").ok().flatten(),
outbox_url: row.try_get("outbox_url").ok().flatten(),
})
.collect()) .collect())
} }
@@ -527,6 +481,142 @@ impl FederationRepository for SqliteFederationRepository {
Ok(row.flatten()) Ok(row.flatten())
} }
async fn migrate_follower_actor(
&self,
old_actor_url: &str,
new_actor_url: &str,
) -> Result<Vec<uuid::Uuid>> {
let candidates: Vec<String> = sqlx::query_scalar(
"SELECT local_user_id FROM ap_following
WHERE remote_actor_url = ?1
AND local_user_id NOT IN (
SELECT local_user_id FROM ap_following WHERE remote_actor_url = ?2
)",
)
.bind(old_actor_url)
.bind(new_actor_url)
.fetch_all(&self.pool)
.await?;
if candidates.is_empty() {
return Ok(vec![]);
}
sqlx::query(
"UPDATE ap_following SET remote_actor_url = ?1
WHERE remote_actor_url = ?2
AND local_user_id NOT IN (
SELECT local_user_id FROM ap_following WHERE remote_actor_url = ?1
)",
)
.bind(new_actor_url)
.bind(old_actor_url)
.execute(&self.pool)
.await?;
candidates
.into_iter()
.map(|s| uuid::Uuid::parse_str(&s).map_err(|e| anyhow::anyhow!(e)))
.collect()
}
}
#[async_trait]
impl ActorRepository for SqliteFederationRepository {
async fn get_local_actor_keypair(
&self,
user_id: uuid::Uuid,
) -> Result<Option<(String, String)>> {
let uid = user_id.to_string();
let row =
sqlx::query("SELECT public_key, private_key FROM ap_local_actors WHERE user_id = ?")
.bind(&uid)
.fetch_optional(&self.pool)
.await?;
Ok(row.map(|r| (r.get("public_key"), r.get("private_key"))))
}
async fn save_local_actor_keypair(
&self,
user_id: uuid::Uuid,
public_key: String,
private_key: String,
) -> Result<()> {
let uid = user_id.to_string();
let now = Utc::now().naive_utc();
let created_at = datetime_to_str(&now);
sqlx::query(
"INSERT INTO ap_local_actors (user_id, public_key, private_key, created_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET
public_key = excluded.public_key,
private_key = excluded.private_key",
)
.bind(&uid)
.bind(&public_key)
.bind(&private_key)
.bind(&created_at)
.execute(&self.pool)
.await?;
Ok(())
}
async fn upsert_remote_actor(&self, actor: RemoteActor) -> Result<()> {
let now = Utc::now().naive_utc();
let fetched_at = datetime_to_str(&now);
let aka_json = serde_json::to_string(&actor.also_known_as).unwrap_or_default();
sqlx::query(
"INSERT INTO ap_remote_actors (url, handle, inbox_url, shared_inbox_url, display_name, avatar_url, outbox_url, bio, banner_url, followers_url, following_url, also_known_as, 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,
outbox_url = COALESCE(excluded.outbox_url, ap_remote_actors.outbox_url),
bio = excluded.bio,
banner_url = excluded.banner_url,
followers_url = excluded.followers_url,
following_url = excluded.following_url,
also_known_as = excluded.also_known_as,
fetched_at = excluded.fetched_at",
)
.bind(&actor.url)
.bind(&actor.handle)
.bind(&actor.inbox_url)
.bind(&actor.shared_inbox_url)
.bind(&actor.display_name)
.bind(&actor.avatar_url)
.bind(&actor.outbox_url)
.bind(&actor.bio)
.bind(&actor.banner_url)
.bind(&actor.followers_url)
.bind(&actor.following_url)
.bind(&aka_json)
.bind(&fetched_at)
.execute(&self.pool)
.await?;
Ok(())
}
async fn get_remote_actor(&self, actor_url: &str) -> Result<Option<RemoteActor>> {
let row = sqlx::query(
"SELECT url, handle, inbox_url, shared_inbox_url, display_name, avatar_url,
outbox_url, bio, banner_url, followers_url, following_url, also_known_as
FROM ap_remote_actors WHERE url = ?",
)
.bind(actor_url)
.fetch_optional(&self.pool)
.await?;
Ok(row.as_ref().map(|r| remote_actor_from_row(r, "url")))
}
async fn add_announce( async fn add_announce(
&self, &self,
activity_id: &str, activity_id: &str,
@@ -548,6 +638,15 @@ impl FederationRepository for SqliteFederationRepository {
Ok(()) Ok(())
} }
async fn remove_announce(&self, activity_id: &str, actor_url: &str) -> Result<()> {
sqlx::query("DELETE FROM ap_announces WHERE id = ?1 AND actor_url = ?2")
.bind(activity_id)
.bind(actor_url)
.execute(&self.pool)
.await?;
Ok(())
}
async fn count_announces(&self, object_url: &str) -> Result<usize> { async fn count_announces(&self, object_url: &str) -> Result<usize> {
let row = sqlx::query("SELECT COUNT(*) as cnt FROM ap_announces WHERE object_url = ?1") let row = sqlx::query("SELECT COUNT(*) as cnt FROM ap_announces WHERE object_url = ?1")
.bind(object_url) .bind(object_url)
@@ -555,7 +654,10 @@ impl FederationRepository for SqliteFederationRepository {
.await?; .await?;
Ok(row.get::<i64, _>("cnt") as usize) Ok(row.get::<i64, _>("cnt") as usize)
} }
}
#[async_trait]
impl BlocklistRepository for SqliteFederationRepository {
async fn add_blocked_domain(&self, domain: &str, reason: Option<&str>) -> Result<()> { async fn add_blocked_domain(&self, domain: &str, reason: Option<&str>) -> Result<()> {
let now = Utc::now().naive_utc(); let now = Utc::now().naive_utc();
let ts = datetime_to_str(&now); let ts = datetime_to_str(&now);
@@ -656,44 +758,26 @@ impl FederationRepository for SqliteFederationRepository {
.await?; .await?;
Ok(count > 0) Ok(count > 0)
} }
}
async fn migrate_follower_actor( #[async_trait]
&self, impl ActivityRepository for SqliteFederationRepository {
old_actor_url: &str, async fn is_activity_processed(&self, activity_id: &str) -> Result<bool> {
new_actor_url: &str, let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM ap_activities WHERE id = ?1")
) -> Result<Vec<uuid::Uuid>> { .bind(activity_id)
let candidates: Vec<String> = sqlx::query_scalar( .fetch_one(&self.pool)
"SELECT local_user_id FROM ap_following .await?;
WHERE remote_actor_url = ?1 Ok(count > 0)
AND local_user_id NOT IN ( }
SELECT local_user_id FROM ap_following WHERE remote_actor_url = ?2
)",
)
.bind(old_actor_url)
.bind(new_actor_url)
.fetch_all(&self.pool)
.await?;
if candidates.is_empty() { async fn mark_activity_processed(&self, activity_id: &str) -> Result<()> {
return Ok(vec![]); let ts = datetime_to_str(&Utc::now().naive_utc());
} sqlx::query("INSERT OR IGNORE INTO ap_activities (id, processed_at) VALUES (?1, ?2)")
.bind(activity_id)
sqlx::query( .bind(&ts)
"UPDATE ap_following SET remote_actor_url = ?1 .execute(&self.pool)
WHERE remote_actor_url = ?2 .await?;
AND local_user_id NOT IN ( Ok(())
SELECT local_user_id FROM ap_following WHERE remote_actor_url = ?1
)",
)
.bind(new_actor_url)
.bind(old_actor_url)
.execute(&self.pool)
.await?;
candidates
.into_iter()
.map(|s| uuid::Uuid::parse_str(&s).map_err(|e| anyhow::anyhow!(e)))
.collect()
} }
} }
@@ -840,6 +924,64 @@ impl domain::ports::SocialQueryPort for SqliteFederationRepository {
) )
.collect()) .collect())
} }
async fn count_following(
&self,
user_id: uuid::Uuid,
) -> Result<usize, domain::errors::DomainError> {
let uid = user_id.to_string();
let count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM ap_following WHERE local_user_id = ? AND status = 'accepted'",
)
.bind(&uid)
.fetch_one(&self.pool)
.await
.map_err(|e| domain::errors::DomainError::InfrastructureError(e.to_string()))?;
Ok(count as usize)
}
async fn count_accepted_followers(
&self,
user_id: uuid::Uuid,
) -> Result<usize, domain::errors::DomainError> {
let uid = user_id.to_string();
let count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM ap_followers WHERE local_user_id = ? AND status = 'accepted'",
)
.bind(&uid)
.fetch_one(&self.pool)
.await
.map_err(|e| domain::errors::DomainError::InfrastructureError(e.to_string()))?;
Ok(count as usize)
}
async fn get_pending_followers(
&self,
user_id: uuid::Uuid,
) -> Result<Vec<domain::ports::PendingFollowerInfo>, domain::errors::DomainError> {
let uid = user_id.to_string();
let rows = sqlx::query_as::<_, (String, String, Option<String>, Option<String>)>(
"SELECT ar.url, ar.handle, ar.display_name, ar.avatar_url
FROM ap_followers f
JOIN ap_remote_actors ar ON ar.url = f.remote_actor_url
WHERE f.local_user_id = ? AND f.status = 'pending'",
)
.bind(&uid)
.fetch_all(&self.pool)
.await
.map_err(|e| domain::errors::DomainError::InfrastructureError(e.to_string()))?;
Ok(rows
.into_iter()
.map(
|(url, handle, display_name, avatar_url)| domain::ports::PendingFollowerInfo {
url,
handle,
display_name,
avatar_url,
},
)
.collect())
}
} }
#[async_trait] #[async_trait]
@@ -954,16 +1096,12 @@ impl RemoteWatchlistRepository for SqliteFederationRepository {
} }
} }
pub fn wire( pub fn wire(pool: sqlx::SqlitePool) -> activitypub::FederationRepos {
pool: sqlx::SqlitePool,
) -> (
std::sync::Arc<dyn activitypub::FederationRepository>,
std::sync::Arc<dyn domain::ports::SocialQueryPort>,
std::sync::Arc<dyn activitypub::RemoteReviewRepository>,
std::sync::Arc<dyn domain::ports::RemoteWatchlistRepository>,
) {
let fed = std::sync::Arc::new(SqliteFederationRepository::new(pool)); let fed = std::sync::Arc::new(SqliteFederationRepository::new(pool));
( (
std::sync::Arc::clone(&fed) as _,
std::sync::Arc::clone(&fed) as _,
std::sync::Arc::clone(&fed) as _,
std::sync::Arc::clone(&fed) as _, std::sync::Arc::clone(&fed) as _,
std::sync::Arc::clone(&fed) as _, std::sync::Arc::clone(&fed) as _,
std::sync::Arc::clone(&fed) as _, std::sync::Arc::clone(&fed) as _,
@@ -974,7 +1112,7 @@ pub fn wire(
#[cfg(test)] #[cfg(test)]
mod outbox_url_tests { mod outbox_url_tests {
use super::*; use super::*;
use k_ap::{FederationRepository, FollowingStatus, RemoteActor}; use k_ap::{FollowRepository, FollowingStatus, RemoteActor};
async fn setup_pool() -> SqlitePool { async fn setup_pool() -> SqlitePool {
let pool = SqlitePool::connect(":memory:").await.unwrap(); let pool = SqlitePool::connect(":memory:").await.unwrap();
@@ -982,7 +1120,8 @@ mod outbox_url_tests {
"CREATE TABLE ap_remote_actors ( "CREATE TABLE ap_remote_actors (
url TEXT PRIMARY KEY, handle TEXT NOT NULL, inbox_url TEXT NOT NULL, url TEXT PRIMARY KEY, handle TEXT NOT NULL, inbox_url TEXT NOT NULL,
shared_inbox_url TEXT, display_name TEXT, avatar_url TEXT, shared_inbox_url TEXT, display_name TEXT, avatar_url TEXT,
outbox_url TEXT, fetched_at TEXT NOT NULL outbox_url TEXT, bio TEXT, banner_url TEXT, followers_url TEXT,
following_url TEXT, also_known_as TEXT, fetched_at TEXT NOT NULL
); );
CREATE TABLE ap_following ( CREATE TABLE ap_following (
local_user_id TEXT NOT NULL, remote_actor_url TEXT NOT NULL, local_user_id TEXT NOT NULL, remote_actor_url TEXT NOT NULL,
@@ -1010,6 +1149,11 @@ mod outbox_url_tests {
display_name: None, display_name: None,
avatar_url: None, avatar_url: None,
outbox_url: Some("https://remote.example/users/alice/outbox".to_string()), outbox_url: Some("https://remote.example/users/alice/outbox".to_string()),
bio: None,
banner_url: None,
followers_url: None,
following_url: None,
also_known_as: vec![],
}; };
repo.add_following(local_user, actor, "https://local/activities/1") repo.add_following(local_user, actor, "https://local/activities/1")
.await .await

View File

@@ -0,0 +1,12 @@
-- k-ap 0.3: new RemoteActor fields + activity dedup table
ALTER TABLE ap_remote_actors ADD COLUMN bio TEXT;
ALTER TABLE ap_remote_actors ADD COLUMN banner_url TEXT;
ALTER TABLE ap_remote_actors ADD COLUMN followers_url TEXT;
ALTER TABLE ap_remote_actors ADD COLUMN following_url TEXT;
ALTER TABLE ap_remote_actors ADD COLUMN also_known_as TEXT; -- JSON array
CREATE TABLE IF NOT EXISTS ap_activities (
id TEXT PRIMARY KEY,
processed_at TEXT NOT NULL
);

View File

@@ -0,0 +1 @@
ALTER TABLE users ADD COLUMN display_name TEXT;

View File

@@ -67,10 +67,7 @@ impl LocalApContentQuery for SqliteApContentQuery {
rows.into_iter().map(WatchlistRow::into_domain).collect() rows.into_iter().map(WatchlistRow::into_domain).collect()
} }
async fn get_review_by_id( async fn get_review_by_id(&self, review_id: &ReviewId) -> Result<Option<Review>, DomainError> {
&self,
review_id: &ReviewId,
) -> Result<Option<Review>, DomainError> {
let id = review_id.value().to_string(); let id = review_id.value().to_string();
sqlx::query_as::<_, ReviewRow>( sqlx::query_as::<_, ReviewRow>(
"SELECT id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url "SELECT id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url
@@ -106,4 +103,49 @@ impl LocalApContentQuery for SqliteApContentQuery {
.map_err(Self::map_err)?; .map_err(Self::map_err)?;
Ok(count as u64) Ok(count as u64)
} }
async fn get_local_reviews_page(
&self,
user_id: &UserId,
before: Option<chrono::NaiveDateTime>,
limit: usize,
) -> Result<Vec<DiaryEntry>, DomainError> {
let uid = user_id.value().to_string();
let limit_i64 = limit as i64;
let rows = if let Some(before_ts) = before {
let ts = before_ts.format("%Y-%m-%d %H:%M:%S").to_string();
sqlx::query_as::<_, DiaryRow>(
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url
FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id
WHERE r.user_id = ? AND r.remote_actor_url IS NULL AND r.watched_at < ?
ORDER BY r.watched_at DESC
LIMIT ?",
)
.bind(&uid)
.bind(&ts)
.bind(limit_i64)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)?
} else {
sqlx::query_as::<_, DiaryRow>(
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url
FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id
WHERE r.user_id = ? AND r.remote_actor_url IS NULL
ORDER BY r.watched_at DESC
LIMIT ?",
)
.bind(&uid)
.bind(limit_i64)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)?
};
rows.into_iter().map(DiaryRow::into_domain).collect()
}
} }

View File

@@ -2,8 +2,8 @@ use chrono::NaiveDateTime;
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
models::{ models::{
DiaryEntry, FeedEntry, Movie, MovieSummary, Review, ReviewSource, UserSummary, DiaryEntry, FeedEntry, Movie, MovieSummary, PersistedReview, Review, ReviewSource,
WatchlistEntry, WatchlistWithMovie, UserSummary, WatchlistEntry, WatchlistWithMovie,
}, },
value_objects::{ value_objects::{
Comment, Email, ExternalMetadataId, MovieId, MovieTitle, PosterPath, Rating, ReleaseYear, Comment, Email, ExternalMetadataId, MovieId, MovieTitle, PosterPath, Rating, ReleaseYear,
@@ -109,9 +109,16 @@ impl ReviewRow {
None => ReviewSource::Local, None => ReviewSource::Local,
Some(url) => ReviewSource::Remote { actor_url: url }, Some(url) => ReviewSource::Remote { actor_url: url },
}; };
Ok(Review::from_persistence( Ok(Review::from_persistence(PersistedReview {
id, movie_id, user_id, rating, comment, watched_at, created_at, source, id,
)) movie_id,
user_id,
rating,
comment,
watched_at,
created_at,
source,
}))
} }
} }

View File

@@ -6,7 +6,7 @@ use sqlx::SqlitePool;
async fn setup() -> (SqlitePool, SqliteUserRepository) { async fn setup() -> (SqlitePool, SqliteUserRepository) {
let pool = SqlitePool::connect(":memory:").await.unwrap(); let pool = SqlitePool::connect(":memory:").await.unwrap();
sqlx::query( sqlx::query(
"CREATE TABLE users (id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, created_at TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'standard', bio TEXT, avatar_path TEXT, banner_path TEXT, also_known_as TEXT)" "CREATE TABLE users (id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, created_at TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'standard', display_name TEXT, bio TEXT, avatar_path TEXT, banner_path TEXT, also_known_as TEXT)"
) )
.execute(&pool) .execute(&pool)
.await .await
@@ -65,10 +65,11 @@ async fn update_profile_persists_bio_and_avatar() {
repo.update_profile( repo.update_profile(
user.id(), user.id(),
Some("My biography".to_string()), &domain::models::UserProfile {
Some("avatars/user1".to_string()), bio: Some("My biography".to_string()),
None, avatar_path: Some("avatars/user1".to_string()),
None, ..Default::default()
},
) )
.await .await
.unwrap(); .unwrap();
@@ -90,14 +91,15 @@ async fn update_profile_clears_fields_with_none() {
repo.save(&user).await.unwrap(); repo.save(&user).await.unwrap();
repo.update_profile( repo.update_profile(
user.id(), user.id(),
Some("bio".to_string()), &domain::models::UserProfile {
Some("path".to_string()), bio: Some("bio".to_string()),
None, avatar_path: Some("path".to_string()),
None, ..Default::default()
},
) )
.await .await
.unwrap(); .unwrap();
repo.update_profile(user.id(), None, None, None, None) repo.update_profile(user.id(), &domain::models::UserProfile::default())
.await .await
.unwrap(); .unwrap();

View File

@@ -1,6 +1,6 @@
use async_trait::async_trait; use async_trait::async_trait;
use chrono::Utc; use chrono::Utc;
use sqlx::SqlitePool; use sqlx::{Row, SqlitePool};
use super::models::UserSummaryRow; use super::models::UserSummaryRow;
use domain::{ use domain::{
@@ -32,104 +32,73 @@ impl SqliteUserRepository {
} }
fn row_to_user( fn row_to_user(
id_str: String, row: &sqlx::sqlite::SqliteRow,
email_str: String,
username_str: String,
hash_str: String,
role: UserRole,
bio: Option<String>,
avatar_path: Option<String>,
banner_path: Option<String>,
also_known_as: Option<String>,
profile_fields: Vec<ProfileField>, profile_fields: Vec<ProfileField>,
) -> Result<User, DomainError> { ) -> Result<User, DomainError> {
let id_str: String = row.try_get("id").unwrap_or_default();
let id = uuid::Uuid::parse_str(&id_str) let id = uuid::Uuid::parse_str(&id_str)
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?; .map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
let email = let email = Email::new(row.get("email"))
Email::new(email_str).map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
let username = Username::new(username_str)
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?; .map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
let hash = PasswordHash::new(hash_str) let username = Username::new(row.get("username"))
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?; .map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
let hash = PasswordHash::new(row.get("password_hash"))
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
let role_str: String = row.get("role");
Ok(User::from_persistence( Ok(User::from_persistence(
UserId::from_uuid(id), UserId::from_uuid(id),
email, email,
username, username,
hash, hash,
role, Self::parse_role(&role_str),
bio, domain::models::UserProfile {
avatar_path, display_name: row.try_get("display_name").ok().flatten(),
banner_path, bio: row.try_get("bio").ok().flatten(),
also_known_as, avatar_path: row.try_get("avatar_path").ok().flatten(),
profile_fields, banner_path: row.try_get("banner_path").ok().flatten(),
also_known_as: row.try_get("also_known_as").ok().flatten(),
profile_fields,
},
)) ))
} }
} }
const USER_COLS: &str = "id, email, username, password_hash, role, display_name, bio, avatar_path, banner_path, also_known_as";
#[async_trait] #[async_trait]
impl UserRepository for SqliteUserRepository { impl UserRepository for SqliteUserRepository {
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError> { async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError> {
let email_str = email.value(); let email_str = email.value();
let row = sqlx::query!( let row = sqlx::query(&format!("SELECT {USER_COLS} FROM users WHERE email = ?"))
"SELECT id, email, username, password_hash, role, bio, avatar_path, banner_path, also_known_as FROM users WHERE email = ?", .bind(email_str)
email_str .fetch_optional(&self.pool)
) .await
.fetch_optional(&self.pool) .map_err(Self::map_err)?;
.await
.map_err(Self::map_err)?;
row.map(|r| { row.as_ref()
Self::row_to_user( .map(|r| Self::row_to_user(r, vec![]))
r.id.unwrap_or_default(), .transpose()
r.email,
r.username,
r.password_hash,
Self::parse_role(&r.role),
r.bio,
r.avatar_path,
r.banner_path,
r.also_known_as,
vec![],
)
})
.transpose()
} }
async fn find_by_username(&self, username: &Username) -> Result<Option<User>, DomainError> { async fn find_by_username(&self, username: &Username) -> Result<Option<User>, DomainError> {
let username_str = username.value(); let username_str = username.value();
let row = sqlx::query!( let row = sqlx::query(&format!("SELECT {USER_COLS} FROM users WHERE username = ?"))
"SELECT id, email, username, password_hash, role, bio, avatar_path, banner_path, also_known_as FROM users WHERE username = ?", .bind(username_str)
username_str .fetch_optional(&self.pool)
) .await
.fetch_optional(&self.pool) .map_err(Self::map_err)?;
.await
.map_err(Self::map_err)?;
row.map(|r| { row.as_ref()
Self::row_to_user( .map(|r| Self::row_to_user(r, vec![]))
r.id.unwrap_or_default(), .transpose()
r.email,
r.username,
r.password_hash,
Self::parse_role(&r.role),
r.bio,
r.avatar_path,
r.banner_path,
r.also_known_as,
vec![],
)
})
.transpose()
} }
async fn save(&self, user: &User) -> Result<(), DomainError> { async fn save(&self, user: &User) -> Result<(), DomainError> {
// Check email uniqueness first (clearer error than INSERT OR IGNORE)
if self.find_by_email(user.email()).await?.is_some() { if self.find_by_email(user.email()).await?.is_some() {
return Err(DomainError::ValidationError( return Err(DomainError::ValidationError(
"Email already registered".into(), "Email already registered".into(),
)); ));
} }
// Check username uniqueness
if self.find_by_username(user.username()).await?.is_some() { if self.find_by_username(user.username()).await?.is_some() {
return Err(DomainError::ValidationError( return Err(DomainError::ValidationError(
"Username already taken".into(), "Username already taken".into(),
@@ -141,15 +110,20 @@ impl UserRepository for SqliteUserRepository {
let username = user.username().value(); let username = user.username().value();
let hash = user.password_hash().value(); let hash = user.password_hash().value();
let created_at = Utc::now().to_rfc3339(); let created_at = Utc::now().to_rfc3339();
let role = match user.role() { let role = match user.role() {
UserRole::Admin => "admin", UserRole::Admin => "admin",
UserRole::Standard => "standard", UserRole::Standard => "standard",
}; };
sqlx::query!(
sqlx::query(
"INSERT INTO users (id, email, username, password_hash, created_at, role) VALUES (?, ?, ?, ?, ?, ?)", "INSERT INTO users (id, email, username, password_hash, created_at, role) VALUES (?, ?, ?, ?, ?, ?)",
id, email, username, hash, created_at, role
) )
.bind(&id)
.bind(email)
.bind(username)
.bind(hash)
.bind(&created_at)
.bind(role)
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(Self::map_err)?; .map_err(Self::map_err)?;
@@ -159,63 +133,47 @@ impl UserRepository for SqliteUserRepository {
async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError> { async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError> {
let id_str = id.value().to_string(); let id_str = id.value().to_string();
let row = sqlx::query!( let row = sqlx::query(&format!("SELECT {USER_COLS} FROM users WHERE id = ?"))
"SELECT id, email, username, password_hash, role, bio, avatar_path, banner_path, also_known_as FROM users WHERE id = ?", .bind(&id_str)
id_str .fetch_optional(&self.pool)
) .await
.fetch_optional(&self.pool) .map_err(Self::map_err)?;
.await
.map_err(Self::map_err)?;
let Some(r) = row else { return Ok(None) }; let Some(r) = row else { return Ok(None) };
let field_rows = sqlx::query!( let field_rows = sqlx::query(
"SELECT name, value FROM user_profile_fields WHERE user_id = ? ORDER BY position ASC", "SELECT name, value FROM user_profile_fields WHERE user_id = ? ORDER BY position ASC",
id_str
) )
.bind(&id_str)
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?; .map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
let profile_fields = field_rows let profile_fields = field_rows
.into_iter() .iter()
.map(|f| ProfileField { .map(|f| ProfileField {
name: f.name, name: f.get("name"),
value: f.value, value: f.get("value"),
}) })
.collect(); .collect();
Self::row_to_user( Self::row_to_user(&r, profile_fields).map(Some)
r.id.unwrap_or_default(),
r.email,
r.username,
r.password_hash,
Self::parse_role(&r.role),
r.bio,
r.avatar_path,
r.banner_path,
r.also_known_as,
profile_fields,
)
.map(Some)
} }
async fn update_profile( async fn update_profile(
&self, &self,
user_id: &UserId, user_id: &UserId,
bio: Option<String>, profile: &domain::models::UserProfile,
avatar_path: Option<String>,
banner_path: Option<String>,
also_known_as: Option<String>,
) -> Result<(), DomainError> { ) -> Result<(), DomainError> {
let id_str = user_id.value().to_string(); let id_str = user_id.value().to_string();
sqlx::query( sqlx::query(
"UPDATE users SET bio = ?, avatar_path = ?, banner_path = ?, also_known_as = ? WHERE id = ?", "UPDATE users SET display_name = ?, bio = ?, avatar_path = ?, banner_path = ?, also_known_as = ? WHERE id = ?",
) )
.bind(&bio) .bind(&profile.display_name)
.bind(&avatar_path) .bind(&profile.bio)
.bind(&banner_path) .bind(&profile.avatar_path)
.bind(&also_known_as) .bind(&profile.banner_path)
.bind(&profile.also_known_as)
.bind(&id_str) .bind(&id_str)
.execute(&self.pool) .execute(&self.pool)
.await .await

View File

@@ -21,7 +21,6 @@ pub struct LogReviewCommand {
#[derive(Clone)] #[derive(Clone)]
pub struct SyncPosterCommand { pub struct SyncPosterCommand {
pub movie_id: Uuid, pub movie_id: Uuid,
pub external_metadata_id: String,
} }
pub struct RegisterCommand { pub struct RegisterCommand {
@@ -75,6 +74,7 @@ pub struct DeleteImportProfileCommand {
pub struct UpdateProfileCommand { pub struct UpdateProfileCommand {
pub user_id: Uuid, pub user_id: Uuid,
pub display_name: Option<String>,
pub bio: Option<String>, pub bio: Option<String>,
pub avatar_bytes: Option<Vec<u8>>, pub avatar_bytes: Option<Vec<u8>>,
pub avatar_content_type: Option<String>, pub avatar_content_type: Option<String>,
@@ -102,3 +102,9 @@ pub struct RemoveFromWatchlistCommand {
pub user_id: Uuid, pub user_id: Uuid,
pub movie_id: Uuid, pub movie_id: Uuid,
} }
pub struct RegisterAndLoginCommand {
pub email: String,
pub username: String,
pub password: String,
}

View File

@@ -6,8 +6,8 @@ use domain::ports::{
AuthService, DiaryExporter, DiaryRepository, DocumentParser, EventPublisher, ImageStorage, AuthService, DiaryExporter, DiaryRepository, DocumentParser, EventPublisher, ImageStorage,
ImportProfileRepository, ImportSessionRepository, MetadataClient, MovieProfileRepository, ImportProfileRepository, ImportSessionRepository, MetadataClient, MovieProfileRepository,
MovieRepository, PasswordHasher, PersonCommand, PersonQuery, PosterFetcherClient, MovieRepository, PasswordHasher, PersonCommand, PersonQuery, PosterFetcherClient,
ReviewRepository, SearchCommand, SearchPort, StatsRepository, UserProfileFieldsRepository, ReviewRepository, SearchCommand, SearchPort, SocialQueryPort, StatsRepository,
UserRepository, WatchlistRepository, UserProfileFieldsRepository, UserRepository, WatchlistRepository,
}; };
use crate::config::AppConfig; use crate::config::AppConfig;
@@ -38,5 +38,7 @@ pub struct AppContext {
pub profile_fields_repository: Arc<dyn UserProfileFieldsRepository>, pub profile_fields_repository: Arc<dyn UserProfileFieldsRepository>,
#[cfg(feature = "federation")] #[cfg(feature = "federation")]
pub remote_watchlist_repository: Arc<dyn RemoteWatchlistRepository>, pub remote_watchlist_repository: Arc<dyn RemoteWatchlistRepository>,
#[cfg(feature = "federation")]
pub social_query: Arc<dyn SocialQueryPort>,
pub config: AppConfig, pub config: AppConfig,
} }

View File

@@ -117,11 +117,10 @@ impl ResolutionStrategy for TitleSearchStrategy {
match deps.metadata_client.fetch_movie_metadata(&criteria).await { match deps.metadata_client.fetch_movie_metadata(&criteria).await {
Ok(m) => { Ok(m) => {
// Movie may already exist in DB under this external_metadata_id // Movie may already exist in DB under this external_metadata_id
if let Some(ext_id) = m.external_metadata_id() { if let Some(ext_id) = m.external_metadata_id()
if let Some(existing) = deps.repository.get_movie_by_external_id(ext_id).await? && let Some(existing) = deps.repository.get_movie_by_external_id(ext_id).await?
{ {
return Ok(Some((existing, false))); return Ok(Some((existing, false)));
}
} }
Ok(Some((m, true))) Ok(Some((m, true)))
} }

View File

@@ -28,7 +28,8 @@ pub struct GetActivityFeedQuery {
pub offset: u32, pub offset: u32,
pub sort_by: domain::ports::FeedSortBy, pub sort_by: domain::ports::FeedSortBy,
pub search: Option<String>, pub search: Option<String>,
pub following: Option<domain::ports::FollowingFilter>, pub viewer_user_id: Option<Uuid>,
pub filter_following: bool,
} }
pub struct GetUsersQuery; pub struct GetUsersQuery;
@@ -73,6 +74,7 @@ pub struct GetUserProfileQuery {
pub offset: Option<u32>, pub offset: Option<u32>,
pub sort_by: domain::ports::FeedSortBy, pub sort_by: domain::ports::FeedSortBy,
pub search: Option<String>, pub search: Option<String>,
pub is_own_profile: bool,
} }
pub struct GetMovieSocialPageQuery { pub struct GetMovieSocialPageQuery {
@@ -99,3 +101,7 @@ pub struct IsOnWatchlistQuery {
pub user_id: Uuid, pub user_id: Uuid,
pub movie_id: Uuid, pub movie_id: Uuid,
} }
pub struct GetCurrentProfileQuery {
pub user_id: Uuid,
}

View File

@@ -1,5 +1,9 @@
use std::sync::Arc; use std::sync::Arc;
#[cfg(feature = "federation")]
use domain::testing::PanicRemoteWatchlistRepository;
#[cfg(feature = "federation")]
use domain::testing::PanicSocialQueryPort;
use domain::{ use domain::{
ports::{ ports::{
AuthService, DiaryExporter, DiaryRepository, DocumentParser, EventPublisher, ImageStorage, AuthService, DiaryExporter, DiaryRepository, DocumentParser, EventPublisher, ImageStorage,
@@ -18,13 +22,8 @@ use domain::{
PanicSearchPort, PanicStatsRepository, PanicSearchPort, PanicStatsRepository,
}, },
}; };
#[cfg(feature = "federation")]
use domain::testing::PanicRemoteWatchlistRepository;
use crate::{ use crate::{config::AppConfig, context::AppContext};
config::AppConfig,
context::AppContext,
};
pub struct TestContextBuilder { pub struct TestContextBuilder {
pub movie_repo: Arc<dyn MovieRepository>, pub movie_repo: Arc<dyn MovieRepository>,
@@ -147,6 +146,8 @@ impl TestContextBuilder {
config: self.config, config: self.config,
#[cfg(feature = "federation")] #[cfg(feature = "federation")]
remote_watchlist_repository: std::sync::Arc::new(PanicRemoteWatchlistRepository), remote_watchlist_repository: std::sync::Arc::new(PanicRemoteWatchlistRepository),
#[cfg(feature = "federation")]
social_query: std::sync::Arc::new(PanicSocialQueryPort),
} }
} }
} }

View File

@@ -56,6 +56,7 @@ impl EventHandler for RecordingHandler {
"watchlist" "watchlist"
} }
DomainEvent::FollowAccepted { .. } => "follow_accepted", DomainEvent::FollowAccepted { .. } => "follow_accepted",
DomainEvent::BackfillFollower { .. } => "backfill_follower",
}; };
self.calls.lock().unwrap().push(label); self.calls.lock().unwrap().push(label);
Ok(()) Ok(())

View File

@@ -68,8 +68,8 @@ mod tests {
use domain::{ use domain::{
models::Movie, models::Movie,
ports::MovieRepository, ports::MovieRepository,
value_objects::{MovieTitle, ReleaseYear},
testing::{InMemoryMovieRepository, InMemoryWatchlistRepository}, testing::{InMemoryMovieRepository, InMemoryWatchlistRepository},
value_objects::{MovieTitle, ReleaseYear},
}; };
use crate::{ use crate::{

View File

@@ -62,17 +62,15 @@ mod tests {
use domain::{ use domain::{
models::{Movie, Review}, models::{Movie, Review},
ports::{MovieRepository, ReviewRepository}, ports::{MovieRepository, ReviewRepository},
value_objects::{MovieId, MovieTitle, Rating, ReleaseYear, UserId},
testing::{ testing::{
FakeDiaryRepository, InMemoryMovieRepository, InMemoryReviewRepository, FakeDiaryRepository, InMemoryMovieRepository, InMemoryReviewRepository,
NoopEventPublisher, NoopEventPublisher,
}, },
value_objects::{MovieId, MovieTitle, Rating, ReleaseYear, UserId},
}; };
use crate::{ use crate::{
commands::DeleteReviewCommand, commands::DeleteReviewCommand, test_helpers::TestContextBuilder, use_cases::delete_review,
test_helpers::TestContextBuilder,
use_cases::delete_review,
}; };
fn make_movie() -> Movie { fn make_movie() -> Movie {
@@ -86,8 +84,14 @@ mod tests {
} }
fn make_review(movie_id: MovieId, user_id: UserId) -> Review { fn make_review(movie_id: MovieId, user_id: UserId) -> Review {
Review::new(movie_id, user_id, Rating::new(4).unwrap(), None, Utc::now().naive_utc()) Review::new(
.unwrap() movie_id,
user_id,
Rating::new(4).unwrap(),
None,
Utc::now().naive_utc(),
)
.unwrap()
} }
#[tokio::test] #[tokio::test]

View File

@@ -5,6 +5,7 @@ use domain::{
FeedEntry, FeedEntry,
collections::{PageParams, Paginated}, collections::{PageParams, Paginated},
}, },
ports::FollowingFilter,
}; };
pub async fn execute( pub async fn execute(
@@ -12,12 +13,57 @@ pub async fn execute(
query: GetActivityFeedQuery, query: GetActivityFeedQuery,
) -> Result<Paginated<FeedEntry>, DomainError> { ) -> Result<Paginated<FeedEntry>, DomainError> {
let page = PageParams::new(Some(query.limit), Some(query.offset))?; let page = PageParams::new(Some(query.limit), Some(query.offset))?;
let following = build_following_filter(ctx, &query).await;
ctx.diary_repository ctx.diary_repository
.query_activity_feed_filtered( .query_activity_feed_filtered(
&page, &page,
&query.sort_by, &query.sort_by,
query.search.as_deref(), query.search.as_deref(),
query.following.as_ref(), following.as_ref(),
) )
.await .await
} }
async fn build_following_filter(
_ctx: &AppContext,
query: &GetActivityFeedQuery,
) -> Option<FollowingFilter> {
#[cfg(not(feature = "federation"))]
{
let _ = query;
return None;
}
#[cfg(feature = "federation")]
{
if !query.filter_following {
return None;
}
let viewer_id = match query.viewer_user_id {
Some(id) => id,
None => return None,
};
let urls = _ctx
.social_query
.get_accepted_following_urls(viewer_id)
.await
.unwrap_or_default();
let base_url = &_ctx.config.base_url;
let mut local_ids = vec![viewer_id];
let mut remote_urls = Vec::new();
for url in urls {
if let Some(suffix) = url.strip_prefix(&format!("{}/users/", base_url))
&& let Ok(parsed_id) = uuid::Uuid::parse_str(suffix)
{
local_ids.push(parsed_id);
continue;
}
remote_urls.push(url);
}
Some(FollowingFilter {
local_user_ids: local_ids,
remote_actor_urls: remote_urls,
})
}
}

View File

@@ -0,0 +1,31 @@
use domain::errors::DomainError;
use crate::{context::AppContext, queries::GetCurrentProfileQuery};
pub struct CurrentProfileData {
pub username: String,
pub bio: Option<String>,
pub avatar_url: Option<String>,
}
pub async fn execute(
ctx: &AppContext,
query: GetCurrentProfileQuery,
) -> Result<CurrentProfileData, DomainError> {
let user_id = domain::value_objects::UserId::from_uuid(query.user_id);
let user = ctx
.user_repository
.find_by_id(&user_id)
.await?
.ok_or_else(|| DomainError::NotFound("User not found".into()))?;
let avatar_url = user
.avatar_path()
.map(|path| format!("{}/images/{}", ctx.config.base_url, path));
Ok(CurrentProfileData {
username: user.username().value().to_string(),
bio: user.bio().map(|s| s.to_string()),
avatar_url,
})
}

View File

@@ -13,11 +13,21 @@ use domain::{
value_objects::UserId, value_objects::UserId,
}; };
pub struct PendingFollowerView {
pub url: String,
pub handle: String,
pub display_name: Option<String>,
pub avatar_url: Option<String>,
}
pub struct UserProfileData { pub struct UserProfileData {
pub stats: UserStats, pub stats: UserStats,
pub entries: Option<Paginated<DiaryEntry>>, pub entries: Option<Paginated<DiaryEntry>>,
pub history: Option<Vec<MonthActivity>>, pub history: Option<Vec<MonthActivity>>,
pub trends: Option<UserTrends>, pub trends: Option<UserTrends>,
pub following_count: usize,
pub followers_count: usize,
pub pending_followers: Vec<PendingFollowerView>,
} }
pub async fn execute( pub async fn execute(
@@ -27,27 +37,30 @@ pub async fn execute(
let user_id = UserId::from_uuid(query.user_id); let user_id = UserId::from_uuid(query.user_id);
let stats = ctx.stats_repository.get_user_stats(&user_id).await?; let stats = ctx.stats_repository.get_user_stats(&user_id).await?;
let (following_count, followers_count, pending_followers) =
load_social_counts(ctx, query.user_id, query.is_own_profile).await;
let base = |entries, history, trends| UserProfileData {
stats,
entries,
history,
trends,
following_count,
followers_count,
pending_followers,
};
match query.view { match query.view {
ProfileView::History => { ProfileView::History => {
let all_entries = ctx.diary_repository.get_user_history(&user_id).await?; let all_entries = ctx.diary_repository.get_user_history(&user_id).await?;
let history = group_by_month(all_entries); let history = group_by_month(all_entries);
Ok(UserProfileData { Ok(base(None, Some(history), None))
stats,
entries: None,
history: Some(history),
trends: None,
})
} }
ProfileView::Trends => { ProfileView::Trends => {
let trends = ctx.stats_repository.get_user_trends(&user_id).await?; let trends = ctx.stats_repository.get_user_trends(&user_id).await?;
Ok(UserProfileData { Ok(base(None, None, Some(trends)))
stats,
entries: None,
history: None,
trends: Some(trends),
})
} }
ProfileView::Ratings => { ProfileView::Ratings | ProfileView::Recent => {
let sort_direction = feed_sort_to_direction(query.sort_by); let sort_direction = feed_sort_to_direction(query.sort_by);
let filter = paged_user_filter( let filter = paged_user_filter(
user_id, user_id,
@@ -57,30 +70,49 @@ pub async fn execute(
query.search.clone(), query.search.clone(),
)?; )?;
let entries = ctx.diary_repository.query_diary(&filter).await?; let entries = ctx.diary_repository.query_diary(&filter).await?;
Ok(UserProfileData { Ok(base(Some(entries), None, None))
stats,
entries: Some(entries),
history: None,
trends: None,
})
} }
ProfileView::Recent => { }
let sort_direction = feed_sort_to_direction(query.sort_by); }
let filter = paged_user_filter(
user_id, async fn load_social_counts(
sort_direction, _ctx: &AppContext,
query.limit, _user_id: uuid::Uuid,
query.offset, _is_own_profile: bool,
query.search.clone(), ) -> (usize, usize, Vec<PendingFollowerView>) {
)?; #[cfg(not(feature = "federation"))]
let entries = ctx.diary_repository.query_diary(&filter).await?; {
Ok(UserProfileData { (0, 0, vec![])
stats, }
entries: Some(entries), #[cfg(feature = "federation")]
history: None, {
trends: None, if !_is_own_profile {
}) return (0, 0, vec![]);
} }
let following = _ctx
.social_query
.count_following(_user_id)
.await
.unwrap_or(0);
let followers = _ctx
.social_query
.count_accepted_followers(_user_id)
.await
.unwrap_or(0);
let pending = _ctx
.social_query
.get_pending_followers(_user_id)
.await
.unwrap_or_default()
.into_iter()
.map(|p| PendingFollowerView {
url: p.url,
handle: p.handle,
display_name: p.display_name,
avatar_url: p.avatar_url,
})
.collect();
(following, followers, pending)
} }
} }

View File

@@ -1,9 +1,28 @@
use crate::{context::AppContext, queries::GetUsersQuery}; use crate::{context::AppContext, queries::GetUsersQuery};
use domain::{errors::DomainError, models::UserSummary}; use domain::{errors::DomainError, models::UserSummary, ports::RemoteActorInfo};
pub struct UsersListData {
pub users: Vec<UserSummary>,
pub remote_actors: Vec<RemoteActorInfo>,
}
pub async fn execute( pub async fn execute(
ctx: &AppContext, ctx: &AppContext,
_query: GetUsersQuery, _query: GetUsersQuery,
) -> Result<Vec<UserSummary>, DomainError> { ) -> Result<UsersListData, DomainError> {
ctx.user_repository.list_with_stats().await #[cfg(feature = "federation")]
let (users_result, actors_result) = tokio::join!(
ctx.user_repository.list_with_stats(),
ctx.social_query.list_all_followed_remote_actors()
);
#[cfg(not(feature = "federation"))]
let (users_result, actors_result) = (
ctx.user_repository.list_with_stats().await,
Ok::<Vec<RemoteActorInfo>, DomainError>(vec![]),
);
Ok(UsersListData {
users: users_result?,
remote_actors: actors_result?,
})
} }

View File

@@ -0,0 +1,95 @@
use domain::{errors::DomainError, value_objects::UserId};
use crate::{context::AppContext, ports::WatchlistDisplayEntry, queries::GetWatchlistQuery};
pub struct WatchlistPageResult {
pub display_entries: Vec<WatchlistDisplayEntry>,
pub has_more: bool,
pub current_offset: u32,
pub limit: u32,
}
pub async fn execute(
ctx: &AppContext,
query: GetWatchlistQuery,
is_owner: bool,
) -> Result<WatchlistPageResult, DomainError> {
let user_id = UserId::from_uuid(query.user_id);
let is_local = ctx.user_repository.find_by_id(&user_id).await?.is_some();
if is_local {
let page = super::get_watchlist::execute(ctx, query).await?;
let has_more = page.offset + page.limit < page.total_count as u32;
let display_entries = page
.items
.iter()
.map(|w| {
let remove_url = if is_owner {
Some(format!("/watchlist/{}/remove", w.movie.id().value()))
} else {
None
};
WatchlistDisplayEntry {
poster_url: w
.movie
.poster_path()
.map(|p| format!("/images/{}", p.value())),
movie_title: w.movie.title().value().to_string(),
release_year: w.movie.release_year().value(),
movie_url: Some(format!("/movies/{}", w.movie.id().value())),
added_at: w.entry.added_at.format("%b %-d, %Y").to_string(),
remove_url,
}
})
.collect();
Ok(WatchlistPageResult {
display_entries,
has_more,
current_offset: page.offset,
limit: page.limit,
})
} else {
load_remote_watchlist(ctx, query.user_id).await
}
}
#[cfg(not(feature = "federation"))]
async fn load_remote_watchlist(
_ctx: &AppContext,
_user_id: uuid::Uuid,
) -> Result<WatchlistPageResult, DomainError> {
Ok(WatchlistPageResult {
display_entries: vec![],
has_more: false,
current_offset: 0,
limit: 0,
})
}
#[cfg(feature = "federation")]
async fn load_remote_watchlist(
ctx: &AppContext,
user_id: uuid::Uuid,
) -> Result<WatchlistPageResult, DomainError> {
let remote_entries = super::get_remote_watchlist::execute(ctx, user_id)
.await
.unwrap_or_default();
let len = remote_entries.len() as u32;
let display_entries = remote_entries
.into_iter()
.map(|e| WatchlistDisplayEntry {
poster_url: e.poster_url,
movie_title: e.movie_title,
release_year: e.release_year,
movie_url: None,
added_at: e.added_at.format("%b %-d, %Y").to_string(),
remove_url: None,
})
.collect();
Ok(WatchlistPageResult {
display_entries,
has_more: false,
current_offset: 0,
limit: len,
})
}

View File

@@ -9,6 +9,7 @@ pub mod enrich_movie;
pub mod execute_import; pub mod execute_import;
pub mod export_diary; pub mod export_diary;
pub mod get_activity_feed; pub mod get_activity_feed;
pub mod get_current_profile;
pub mod get_diary; pub mod get_diary;
pub mod get_movie_social_page; pub mod get_movie_social_page;
pub mod get_movies; pub mod get_movies;
@@ -20,11 +21,13 @@ pub mod get_review_history;
pub mod get_user_profile; pub mod get_user_profile;
pub mod get_users; pub mod get_users;
pub mod get_watchlist; pub mod get_watchlist;
pub mod get_watchlist_page;
pub mod is_on_watchlist; pub mod is_on_watchlist;
pub mod list_import_profiles; pub mod list_import_profiles;
pub mod log_review; pub mod log_review;
pub mod login; pub mod login;
pub mod register; pub mod register;
pub mod register_and_login;
pub mod remove_from_watchlist; pub mod remove_from_watchlist;
pub mod save_import_profile; pub mod save_import_profile;
pub mod search; pub mod search;

View File

@@ -54,11 +54,7 @@ mod tests {
use domain::testing::InMemoryUserRepository; use domain::testing::InMemoryUserRepository;
use domain::value_objects::Email; use domain::value_objects::Email;
use crate::{ use crate::{commands::RegisterCommand, test_helpers::TestContextBuilder, use_cases::register};
commands::RegisterCommand,
test_helpers::TestContextBuilder,
use_cases::register,
};
fn cmd(email: &str) -> RegisterCommand { fn cmd(email: &str) -> RegisterCommand {
RegisterCommand { RegisterCommand {
@@ -76,7 +72,9 @@ mod tests {
.with_users(Arc::clone(&users) as _) .with_users(Arc::clone(&users) as _)
.build(); .build();
register::execute(&ctx, cmd("alice@example.com")).await.unwrap(); register::execute(&ctx, cmd("alice@example.com"))
.await
.unwrap();
let email = Email::new("alice@example.com".into()).unwrap(); let email = Email::new("alice@example.com".into()).unwrap();
let user = users.find_by_email(&email).await.unwrap().unwrap(); let user = users.find_by_email(&email).await.unwrap().unwrap();
@@ -91,7 +89,9 @@ mod tests {
.with_users(Arc::clone(&users) as _) .with_users(Arc::clone(&users) as _)
.build(); .build();
register::execute(&ctx, cmd("bob@example.com")).await.unwrap(); register::execute(&ctx, cmd("bob@example.com"))
.await
.unwrap();
let result = register::execute(&ctx, cmd("bob@example.com")).await; let result = register::execute(&ctx, cmd("bob@example.com")).await;
assert!(result.is_err(), "duplicate email should fail"); assert!(result.is_err(), "duplicate email should fail");
} }

View File

@@ -0,0 +1,32 @@
use domain::errors::DomainError;
use crate::{
commands::RegisterAndLoginCommand,
context::AppContext,
use_cases::{login, register},
};
pub async fn execute(
ctx: &AppContext,
cmd: RegisterAndLoginCommand,
) -> Result<login::LoginResult, DomainError> {
register::execute(
ctx,
crate::commands::RegisterCommand {
email: cmd.email.clone(),
username: cmd.username,
password: cmd.password.clone(),
role: domain::models::UserRole::Standard,
},
)
.await?;
login::execute(
ctx,
crate::queries::LoginQuery {
email: cmd.email,
password: cmd.password,
},
)
.await
}

View File

@@ -2,14 +2,13 @@ use domain::{
errors::DomainError, errors::DomainError,
events::DomainEvent, events::DomainEvent,
models::IndexableDocument, models::IndexableDocument,
value_objects::{ExternalMetadataId, MovieId, PosterPath}, value_objects::{MovieId, PosterPath},
}; };
use crate::{commands::SyncPosterCommand, context::AppContext}; use crate::{commands::SyncPosterCommand, context::AppContext};
pub async fn execute(ctx: &AppContext, cmd: SyncPosterCommand) -> Result<(), DomainError> { pub async fn execute(ctx: &AppContext, cmd: SyncPosterCommand) -> Result<(), DomainError> {
let movie_id = MovieId::from_uuid(cmd.movie_id); let movie_id = MovieId::from_uuid(cmd.movie_id);
let external_metadata_id = ExternalMetadataId::new(cmd.external_metadata_id)?;
let mut movie = match ctx.movie_repository.get_movie_by_id(&movie_id).await? { let mut movie = match ctx.movie_repository.get_movie_by_id(&movie_id).await? {
Some(m) => m, Some(m) => m,
@@ -22,6 +21,15 @@ pub async fn execute(ctx: &AppContext, cmd: SyncPosterCommand) -> Result<(), Dom
} }
}; };
let external_metadata_id = movie
.external_metadata_id()
.ok_or_else(|| {
DomainError::ValidationError(
"Movie has no external metadata ID, cannot sync poster".into(),
)
})?
.clone();
let poster_url = match ctx let poster_url = match ctx
.metadata_client .metadata_client
.get_poster_url(&external_metadata_id) .get_poster_url(&external_metadata_id)

View File

@@ -68,10 +68,14 @@ pub async fn execute(ctx: &AppContext, cmd: UpdateProfileCommand) -> Result<(),
ctx.user_repository ctx.user_repository
.update_profile( .update_profile(
&user_id, &user_id,
cmd.bio, &domain::models::UserProfile {
new_avatar_path, display_name: cmd.display_name,
new_banner_path, bio: cmd.bio,
cmd.also_known_as, avatar_path: new_avatar_path,
banner_path: new_banner_path,
also_known_as: cmd.also_known_as,
profile_fields: vec![],
},
) )
.await?; .await?;

View File

@@ -61,6 +61,10 @@ pub enum DomainEvent {
remote_actor_url: String, remote_actor_url: String,
outbox_url: String, outbox_url: String,
}, },
BackfillFollower {
owner_user_id: UserId,
follower_inbox_url: String,
},
} }
#[async_trait] #[async_trait]

View File

@@ -163,6 +163,17 @@ pub enum ReviewSource {
}, },
} }
pub struct PersistedReview {
pub id: ReviewId,
pub movie_id: MovieId,
pub user_id: UserId,
pub rating: Rating,
pub comment: Option<Comment>,
pub watched_at: NaiveDateTime,
pub created_at: NaiveDateTime,
pub source: ReviewSource,
}
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct Review { pub struct Review {
id: ReviewId, id: ReviewId,
@@ -195,25 +206,16 @@ impl Review {
}) })
} }
pub fn from_persistence( pub fn from_persistence(row: PersistedReview) -> Self {
id: ReviewId,
movie_id: MovieId,
user_id: UserId,
rating: Rating,
comment: Option<Comment>,
watched_at: NaiveDateTime,
created_at: NaiveDateTime,
source: ReviewSource,
) -> Self {
Self { Self {
id, id: row.id,
movie_id, movie_id: row.movie_id,
user_id, user_id: row.user_id,
rating, rating: row.rating,
comment, comment: row.comment,
watched_at, watched_at: row.watched_at,
created_at, created_at: row.created_at,
source, source: row.source,
} }
} }
@@ -314,6 +316,16 @@ pub struct ProfileField {
pub value: String, pub value: String,
} }
#[derive(Clone, Debug, Default)]
pub struct UserProfile {
pub display_name: Option<String>,
pub bio: Option<String>,
pub avatar_path: Option<String>,
pub banner_path: Option<String>,
pub also_known_as: Option<String>,
pub profile_fields: Vec<ProfileField>,
}
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct User { pub struct User {
id: UserId, id: UserId,
@@ -321,11 +333,7 @@ pub struct User {
username: Username, username: Username,
password_hash: PasswordHash, password_hash: PasswordHash,
role: UserRole, role: UserRole,
bio: Option<String>, profile: UserProfile,
avatar_path: Option<String>,
banner_path: Option<String>,
also_known_as: Option<String>,
profile_fields: Vec<ProfileField>,
} }
impl User { impl User {
@@ -341,11 +349,7 @@ impl User {
username, username,
password_hash, password_hash,
role, role,
bio: None, profile: UserProfile::default(),
avatar_path: None,
banner_path: None,
also_known_as: None,
profile_fields: vec![],
} }
} }
@@ -355,11 +359,7 @@ impl User {
username: Username, username: Username,
password_hash: PasswordHash, password_hash: PasswordHash,
role: UserRole, role: UserRole,
bio: Option<String>, profile: UserProfile,
avatar_path: Option<String>,
banner_path: Option<String>,
also_known_as: Option<String>,
profile_fields: Vec<ProfileField>,
) -> Self { ) -> Self {
Self { Self {
id, id,
@@ -367,11 +367,7 @@ impl User {
username, username,
password_hash, password_hash,
role, role,
bio, profile,
avatar_path,
banner_path,
also_known_as,
profile_fields,
} }
} }
@@ -379,17 +375,8 @@ impl User {
self.password_hash = new_hash; self.password_hash = new_hash;
} }
pub fn update_profile( pub fn update_profile(&mut self, profile: UserProfile) {
&mut self, self.profile = profile;
bio: Option<String>,
avatar_path: Option<String>,
banner_path: Option<String>,
also_known_as: Option<String>,
) {
self.bio = bio;
self.avatar_path = avatar_path;
self.banner_path = banner_path;
self.also_known_as = also_known_as;
} }
pub fn email(&self) -> &Email { pub fn email(&self) -> &Email {
@@ -407,24 +394,23 @@ impl User {
pub fn role(&self) -> &UserRole { pub fn role(&self) -> &UserRole {
&self.role &self.role
} }
pub fn display_name(&self) -> Option<&str> {
self.profile.display_name.as_deref()
}
pub fn bio(&self) -> Option<&str> { pub fn bio(&self) -> Option<&str> {
self.bio.as_deref() self.profile.bio.as_deref()
} }
pub fn avatar_path(&self) -> Option<&str> { pub fn avatar_path(&self) -> Option<&str> {
self.avatar_path.as_deref() self.profile.avatar_path.as_deref()
} }
pub fn banner_path(&self) -> Option<&str> { pub fn banner_path(&self) -> Option<&str> {
self.banner_path.as_deref() self.profile.banner_path.as_deref()
} }
pub fn also_known_as(&self) -> Option<&str> { pub fn also_known_as(&self) -> Option<&str> {
self.also_known_as.as_deref() self.profile.also_known_as.as_deref()
} }
pub fn profile_fields(&self) -> &[ProfileField] { pub fn profile_fields(&self) -> &[ProfileField] {
&self.profile_fields &self.profile.profile_fields
} }
} }

View File

@@ -8,23 +8,18 @@ fn make_user() -> User {
Username::new("alice".to_string()).unwrap(), Username::new("alice".to_string()).unwrap(),
PasswordHash::new("hash".to_string()).unwrap(), PasswordHash::new("hash".to_string()).unwrap(),
UserRole::Standard, UserRole::Standard,
None, UserProfile::default(),
None,
None,
None,
vec![],
) )
} }
#[test] #[test]
fn update_profile_sets_fields() { fn update_profile_sets_fields() {
let mut user = make_user(); let mut user = make_user();
user.update_profile( user.update_profile(UserProfile {
Some("My bio".to_string()), bio: Some("My bio".to_string()),
Some("avatars/abc".to_string()), avatar_path: Some("avatars/abc".to_string()),
None, ..Default::default()
None, });
);
assert_eq!(user.bio(), Some("My bio")); assert_eq!(user.bio(), Some("My bio"));
assert_eq!(user.avatar_path(), Some("avatars/abc")); assert_eq!(user.avatar_path(), Some("avatars/abc"));
} }
@@ -32,13 +27,12 @@ fn update_profile_sets_fields() {
#[test] #[test]
fn update_profile_clears_with_none() { fn update_profile_clears_with_none() {
let mut user = make_user(); let mut user = make_user();
user.update_profile( user.update_profile(UserProfile {
Some("bio".to_string()), bio: Some("bio".to_string()),
Some("path".to_string()), avatar_path: Some("path".to_string()),
None, ..Default::default()
None, });
); user.update_profile(UserProfile::default());
user.update_profile(None, None, None, None);
assert_eq!(user.bio(), None); assert_eq!(user.bio(), None);
assert_eq!(user.avatar_path(), None); assert_eq!(user.avatar_path(), None);
} }

View File

@@ -33,14 +33,15 @@ pub enum FeedSortBy {
RatingAsc, RatingAsc,
} }
impl FeedSortBy { impl std::str::FromStr for FeedSortBy {
pub fn from_str(s: &str) -> Self { type Err = std::convert::Infallible;
match s { fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(match s {
"date_asc" => Self::DateAsc, "date_asc" => Self::DateAsc,
"rating" => Self::Rating, "rating" => Self::Rating,
"rating_asc" => Self::RatingAsc, "rating_asc" => Self::RatingAsc,
_ => Self::Date, _ => Self::Date,
} })
} }
} }
@@ -57,17 +58,31 @@ pub struct RemoteActorInfo {
pub display_name: Option<String>, pub display_name: Option<String>,
} }
/// New trait for social/federation read queries #[derive(Debug, Clone)]
pub struct PendingFollowerInfo {
pub url: String,
pub handle: String,
pub display_name: Option<String>,
pub avatar_url: Option<String>,
}
#[async_trait] #[async_trait]
pub trait SocialQueryPort: Send + Sync { pub trait SocialQueryPort: Send + Sync {
/// Returns all accepted remote_actor_urls followed by `user_id`.
async fn get_accepted_following_urls( async fn get_accepted_following_urls(
&self, &self,
user_id: uuid::Uuid, user_id: uuid::Uuid,
) -> Result<Vec<String>, DomainError>; ) -> Result<Vec<String>, DomainError>;
/// Returns all distinct remote actors followed by any local user on this instance.
async fn list_all_followed_remote_actors(&self) -> Result<Vec<RemoteActorInfo>, DomainError>; async fn list_all_followed_remote_actors(&self) -> Result<Vec<RemoteActorInfo>, DomainError>;
async fn count_following(&self, user_id: uuid::Uuid) -> Result<usize, DomainError>;
async fn count_accepted_followers(&self, user_id: uuid::Uuid) -> Result<usize, DomainError>;
async fn get_pending_followers(
&self,
user_id: uuid::Uuid,
) -> Result<Vec<PendingFollowerInfo>, DomainError>;
} }
#[async_trait] #[async_trait]
@@ -185,10 +200,7 @@ pub trait UserRepository: Send + Sync {
async fn update_profile( async fn update_profile(
&self, &self,
user_id: &UserId, user_id: &UserId,
bio: Option<String>, profile: &crate::models::UserProfile,
avatar_path: Option<String>,
banner_path: Option<String>,
also_known_as: Option<String>,
) -> Result<(), DomainError>; ) -> Result<(), DomainError>;
} }
@@ -390,12 +402,16 @@ pub trait LocalApContentQuery: Send + Sync {
user_id: &UserId, user_id: &UserId,
) -> Result<Vec<WatchlistWithMovie>, DomainError>; ) -> Result<Vec<WatchlistWithMovie>, DomainError>;
async fn get_review_by_id( async fn get_review_by_id(&self, review_id: &ReviewId) -> Result<Option<Review>, DomainError>;
&self,
review_id: &ReviewId,
) -> Result<Option<Review>, DomainError>;
async fn get_movie_by_id(&self, movie_id: &MovieId) -> Result<Option<Movie>, DomainError>; async fn get_movie_by_id(&self, movie_id: &MovieId) -> Result<Option<Movie>, DomainError>;
async fn count_local_posts(&self) -> Result<u64, DomainError>; async fn count_local_posts(&self) -> Result<u64, DomainError>;
async fn get_local_reviews_page(
&self,
user_id: &UserId,
before: Option<chrono::NaiveDateTime>,
limit: usize,
) -> Result<Vec<DiaryEntry>, DomainError>;
} }

View File

@@ -11,16 +11,16 @@ use crate::{
errors::DomainError, errors::DomainError,
events::DomainEvent, events::DomainEvent,
models::{ models::{
DiaryEntry, DiaryFilter, ExportFormat, FeedEntry, FieldMapping, FileFormat, ImportError, AnnotatedRow, DiaryEntry, DiaryFilter, EntityType, ExportFormat, ExternalPersonId,
ImportProfile, ImportSession, IndexableDocument, Movie, MovieFilter, MovieProfile, FeedEntry, FieldMapping, FileFormat, ImportError, ImportProfile, ImportSession,
MovieStats, MovieSummary, ParsedFile, Person, PersonCredits, PersonId, ExternalPersonId, IndexableDocument, Movie, MovieFilter, MovieProfile, MovieStats, MovieSummary, ParsedFile,
Review, ReviewHistory, SearchQuery, SearchResults, User, UserStats, UserSummary, Person, PersonCredits, PersonId, Review, ReviewHistory, SearchQuery, SearchResults, User,
UserTrends, WatchlistEntry, WatchlistWithMovie, AnnotatedRow, EntityType, UserStats, UserSummary, UserTrends, WatchlistEntry, WatchlistWithMovie,
collections::{PageParams, Paginated}, collections::{PageParams, Paginated},
}, },
ports::{ ports::{
AuthService, DiaryExporter, DiaryRepository, DocumentParser, EventPublisher, AuthService, DiaryExporter, DiaryRepository, DocumentParser, EventPublisher, FeedSortBy,
FeedSortBy, FollowingFilter, GeneratedToken, ImageStorage, ImportProfileRepository, FollowingFilter, GeneratedToken, ImageStorage, ImportProfileRepository,
ImportSessionRepository, MetadataClient, MetadataSearchCriteria, MovieProfileRepository, ImportSessionRepository, MetadataClient, MetadataSearchCriteria, MovieProfileRepository,
MovieRepository, PasswordHasher, PersonCommand, PersonQuery, PosterFetcherClient, MovieRepository, PasswordHasher, PersonCommand, PersonQuery, PosterFetcherClient,
ReviewRepository, SearchCommand, SearchPort, StatsRepository, UserProfileFieldsRepository, ReviewRepository, SearchCommand, SearchPort, StatsRepository, UserProfileFieldsRepository,
@@ -40,7 +40,9 @@ pub struct InMemoryMovieRepository {
impl InMemoryMovieRepository { impl InMemoryMovieRepository {
pub fn new() -> Arc<Self> { pub fn new() -> Arc<Self> {
Arc::new(Self { store: Mutex::new(HashMap::new()) }) Arc::new(Self {
store: Mutex::new(HashMap::new()),
})
} }
pub fn count(&self) -> usize { pub fn count(&self) -> usize {
@@ -55,11 +57,14 @@ impl MovieRepository for InMemoryMovieRepository {
external_metadata_id: &ExternalMetadataId, external_metadata_id: &ExternalMetadataId,
) -> Result<Option<Movie>, DomainError> { ) -> Result<Option<Movie>, DomainError> {
let store = self.store.lock().unwrap(); let store = self.store.lock().unwrap();
Ok(store.values().find(|m| { Ok(store
m.external_metadata_id() .values()
.map(|e| e.value() == external_metadata_id.value()) .find(|m| {
.unwrap_or(false) m.external_metadata_id()
}).cloned()) .map(|e| e.value() == external_metadata_id.value())
.unwrap_or(false)
})
.cloned())
} }
async fn get_movie_by_id(&self, movie_id: &MovieId) -> Result<Option<Movie>, DomainError> { async fn get_movie_by_id(&self, movie_id: &MovieId) -> Result<Option<Movie>, DomainError> {
@@ -72,11 +77,18 @@ impl MovieRepository for InMemoryMovieRepository {
year: &ReleaseYear, year: &ReleaseYear,
) -> Result<Vec<Movie>, DomainError> { ) -> Result<Vec<Movie>, DomainError> {
let store = self.store.lock().unwrap(); let store = self.store.lock().unwrap();
Ok(store.values().filter(|m| m.title() == title && m.release_year() == year).cloned().collect()) Ok(store
.values()
.filter(|m| m.title() == title && m.release_year() == year)
.cloned()
.collect())
} }
async fn upsert_movie(&self, movie: &Movie) -> Result<(), DomainError> { async fn upsert_movie(&self, movie: &Movie) -> Result<(), DomainError> {
self.store.lock().unwrap().insert(movie.id().value(), movie.clone()); self.store
.lock()
.unwrap()
.insert(movie.id().value(), movie.clone());
Ok(()) Ok(())
} }
@@ -90,7 +102,12 @@ impl MovieRepository for InMemoryMovieRepository {
_page: &crate::models::collections::PageParams, _page: &crate::models::collections::PageParams,
_filter: &MovieFilter, _filter: &MovieFilter,
) -> Result<Paginated<MovieSummary>, DomainError> { ) -> Result<Paginated<MovieSummary>, DomainError> {
Ok(Paginated { items: vec![], total_count: 0, limit: 10, offset: 0 }) Ok(Paginated {
items: vec![],
total_count: 0,
limit: 10,
offset: 0,
})
} }
} }
@@ -102,7 +119,9 @@ pub struct InMemoryReviewRepository {
impl InMemoryReviewRepository { impl InMemoryReviewRepository {
pub fn new() -> Arc<Self> { pub fn new() -> Arc<Self> {
Arc::new(Self { store: Mutex::new(HashMap::new()) }) Arc::new(Self {
store: Mutex::new(HashMap::new()),
})
} }
pub fn count(&self) -> usize { pub fn count(&self) -> usize {
@@ -113,7 +132,10 @@ impl InMemoryReviewRepository {
#[async_trait] #[async_trait]
impl ReviewRepository for InMemoryReviewRepository { impl ReviewRepository for InMemoryReviewRepository {
async fn save_review(&self, review: &Review) -> Result<DomainEvent, DomainError> { async fn save_review(&self, review: &Review) -> Result<DomainEvent, DomainError> {
self.store.lock().unwrap().insert(review.id().value(), review.clone()); self.store
.lock()
.unwrap()
.insert(review.id().value(), review.clone());
Ok(DomainEvent::ReviewLogged { Ok(DomainEvent::ReviewLogged {
review_id: review.id().clone(), review_id: review.id().clone(),
movie_id: review.movie_id().clone(), movie_id: review.movie_id().clone(),
@@ -134,7 +156,11 @@ impl ReviewRepository for InMemoryReviewRepository {
async fn get_all_reviews_for_user(&self, user_id: &UserId) -> Result<Vec<Review>, DomainError> { async fn get_all_reviews_for_user(&self, user_id: &UserId) -> Result<Vec<Review>, DomainError> {
let store = self.store.lock().unwrap(); let store = self.store.lock().unwrap();
Ok(store.values().filter(|r| r.user_id() == user_id).cloned().collect()) Ok(store
.values()
.filter(|r| r.user_id() == user_id)
.cloned()
.collect())
} }
} }
@@ -146,7 +172,9 @@ pub struct InMemoryUserRepository {
impl InMemoryUserRepository { impl InMemoryUserRepository {
pub fn new() -> Arc<Self> { pub fn new() -> Arc<Self> {
Arc::new(Self { store: Mutex::new(HashMap::new()) }) Arc::new(Self {
store: Mutex::new(HashMap::new()),
})
} }
pub fn count(&self) -> usize { pub fn count(&self) -> usize {
@@ -158,16 +186,25 @@ impl InMemoryUserRepository {
impl UserRepository for InMemoryUserRepository { impl UserRepository for InMemoryUserRepository {
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError> { async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError> {
let store = self.store.lock().unwrap(); let store = self.store.lock().unwrap();
Ok(store.values().find(|u| u.email().value() == email.value()).cloned()) Ok(store
.values()
.find(|u| u.email().value() == email.value())
.cloned())
} }
async fn find_by_username(&self, username: &Username) -> Result<Option<User>, DomainError> { async fn find_by_username(&self, username: &Username) -> Result<Option<User>, DomainError> {
let store = self.store.lock().unwrap(); let store = self.store.lock().unwrap();
Ok(store.values().find(|u| u.username().value() == username.value()).cloned()) Ok(store
.values()
.find(|u| u.username().value() == username.value())
.cloned())
} }
async fn save(&self, user: &User) -> Result<(), DomainError> { async fn save(&self, user: &User) -> Result<(), DomainError> {
self.store.lock().unwrap().insert(user.id().value(), user.clone()); self.store
.lock()
.unwrap()
.insert(user.id().value(), user.clone());
Ok(()) Ok(())
} }
@@ -182,10 +219,7 @@ impl UserRepository for InMemoryUserRepository {
async fn update_profile( async fn update_profile(
&self, &self,
_user_id: &UserId, _user_id: &UserId,
_bio: Option<String>, _profile: &crate::models::UserProfile,
_avatar_path: Option<String>,
_banner_path: Option<String>,
_also_known_as: Option<String>,
) -> Result<(), DomainError> { ) -> Result<(), DomainError> {
Ok(()) Ok(())
} }
@@ -199,7 +233,9 @@ pub struct InMemoryWatchlistRepository {
impl InMemoryWatchlistRepository { impl InMemoryWatchlistRepository {
pub fn new() -> Arc<Self> { pub fn new() -> Arc<Self> {
Arc::new(Self { store: Mutex::new(HashMap::new()) }) Arc::new(Self {
store: Mutex::new(HashMap::new()),
})
} }
pub fn count(&self) -> usize { pub fn count(&self) -> usize {
@@ -211,13 +247,20 @@ impl InMemoryWatchlistRepository {
impl WatchlistRepository for InMemoryWatchlistRepository { impl WatchlistRepository for InMemoryWatchlistRepository {
async fn add(&self, entry: &WatchlistEntry) -> Result<(), DomainError> { async fn add(&self, entry: &WatchlistEntry) -> Result<(), DomainError> {
let key = (entry.user_id.value(), entry.movie_id.value()); let key = (entry.user_id.value(), entry.movie_id.value());
self.store.lock().unwrap().entry(key).or_insert_with(|| entry.clone()); self.store
.lock()
.unwrap()
.entry(key)
.or_insert_with(|| entry.clone());
Ok(()) Ok(())
} }
async fn remove(&self, user_id: &UserId, movie_id: &MovieId) -> Result<(), DomainError> { async fn remove(&self, user_id: &UserId, movie_id: &MovieId) -> Result<(), DomainError> {
let key = (user_id.value(), movie_id.value()); let key = (user_id.value(), movie_id.value());
self.store.lock().unwrap().remove(&key) self.store
.lock()
.unwrap()
.remove(&key)
.ok_or_else(|| DomainError::NotFound("watchlist entry".into()))?; .ok_or_else(|| DomainError::NotFound("watchlist entry".into()))?;
Ok(()) Ok(())
} }
@@ -236,7 +279,12 @@ impl WatchlistRepository for InMemoryWatchlistRepository {
_user_id: &UserId, _user_id: &UserId,
_page: &PageParams, _page: &PageParams,
) -> Result<Paginated<WatchlistWithMovie>, DomainError> { ) -> Result<Paginated<WatchlistWithMovie>, DomainError> {
Ok(Paginated { items: vec![], total_count: 0, limit: 10, offset: 0 }) Ok(Paginated {
items: vec![],
total_count: 0,
limit: 10,
offset: 0,
})
} }
async fn contains(&self, user_id: &UserId, movie_id: &MovieId) -> Result<bool, DomainError> { async fn contains(&self, user_id: &UserId, movie_id: &MovieId) -> Result<bool, DomainError> {
@@ -253,7 +301,9 @@ pub struct NoopEventPublisher {
impl NoopEventPublisher { impl NoopEventPublisher {
pub fn new() -> Arc<Self> { pub fn new() -> Arc<Self> {
Arc::new(Self { events: Mutex::new(vec![]) }) Arc::new(Self {
events: Mutex::new(vec![]),
})
} }
pub fn published(&self) -> Vec<DomainEvent> { pub fn published(&self) -> Vec<DomainEvent> {
@@ -333,7 +383,9 @@ impl MetadataClient for FakeMetadataClient {
&self, &self,
_criteria: &MetadataSearchCriteria, _criteria: &MetadataSearchCriteria,
) -> Result<Movie, DomainError> { ) -> Result<Movie, DomainError> {
Err(DomainError::InfrastructureError("fake metadata client".into())) Err(DomainError::InfrastructureError(
"fake metadata client".into(),
))
} }
async fn get_poster_url( async fn get_poster_url(
@@ -352,17 +404,25 @@ pub struct FakeDiaryRepository {
impl FakeDiaryRepository { impl FakeDiaryRepository {
pub fn new() -> Arc<Self> { pub fn new() -> Arc<Self> {
Arc::new(Self { histories: Mutex::new(HashMap::new()) }) Arc::new(Self {
histories: Mutex::new(HashMap::new()),
})
} }
pub fn seed_history(&self, movie: Movie, reviews: Vec<Review>) { pub fn seed_history(&self, movie: Movie, reviews: Vec<Review>) {
self.histories.lock().unwrap().insert(movie.id().value(), (movie, reviews)); self.histories
.lock()
.unwrap()
.insert(movie.id().value(), (movie, reviews));
} }
} }
#[async_trait] #[async_trait]
impl DiaryRepository for FakeDiaryRepository { impl DiaryRepository for FakeDiaryRepository {
async fn query_diary(&self, _filter: &DiaryFilter) -> Result<Paginated<DiaryEntry>, DomainError> { async fn query_diary(
&self,
_filter: &DiaryFilter,
) -> Result<Paginated<DiaryEntry>, DomainError> {
unimplemented!("FakeDiaryRepository::query_diary") unimplemented!("FakeDiaryRepository::query_diary")
} }
@@ -418,7 +478,10 @@ pub struct PanicDiaryRepository;
#[async_trait] #[async_trait]
impl DiaryRepository for PanicDiaryRepository { impl DiaryRepository for PanicDiaryRepository {
async fn query_diary(&self, _filter: &DiaryFilter) -> Result<Paginated<DiaryEntry>, DomainError> { async fn query_diary(
&self,
_filter: &DiaryFilter,
) -> Result<Paginated<DiaryEntry>, DomainError> {
panic!("PanicDiaryRepository called") panic!("PanicDiaryRepository called")
} }
@@ -605,8 +668,18 @@ pub struct PanicSearchPort;
impl SearchPort for PanicSearchPort { impl SearchPort for PanicSearchPort {
async fn search(&self, _query: &SearchQuery) -> Result<SearchResults, DomainError> { async fn search(&self, _query: &SearchQuery) -> Result<SearchResults, DomainError> {
Ok(SearchResults { Ok(SearchResults {
movies: Paginated { items: vec![], total_count: 0, limit: 10, offset: 0 }, movies: Paginated {
people: Paginated { items: vec![], total_count: 0, limit: 10, offset: 0 }, items: vec![],
total_count: 0,
limit: 10,
offset: 0,
},
people: Paginated {
items: vec![],
total_count: 0,
limit: 10,
offset: 0,
},
}) })
} }
} }
@@ -678,13 +751,19 @@ impl crate::ports::RemoteWatchlistRepository for PanicRemoteWatchlistRepository
async fn remove_by_ap_id(&self, _: &str, _: &str) -> Result<(), DomainError> { async fn remove_by_ap_id(&self, _: &str, _: &str) -> Result<(), DomainError> {
panic!("PanicRemoteWatchlistRepository called") panic!("PanicRemoteWatchlistRepository called")
} }
async fn get_by_actor_url(&self, _: &str) -> Result<Vec<crate::models::RemoteWatchlistEntry>, DomainError> { async fn get_by_actor_url(
&self,
_: &str,
) -> Result<Vec<crate::models::RemoteWatchlistEntry>, DomainError> {
panic!("PanicRemoteWatchlistRepository called") panic!("PanicRemoteWatchlistRepository called")
} }
async fn remove_all_by_actor(&self, _: &str) -> Result<(), DomainError> { async fn remove_all_by_actor(&self, _: &str) -> Result<(), DomainError> {
panic!("PanicRemoteWatchlistRepository called") panic!("PanicRemoteWatchlistRepository called")
} }
async fn get_by_derived_uuid(&self, _: uuid::Uuid) -> Result<Vec<crate::models::RemoteWatchlistEntry>, DomainError> { async fn get_by_derived_uuid(
&self,
_: uuid::Uuid,
) -> Result<Vec<crate::models::RemoteWatchlistEntry>, DomainError> {
panic!("PanicRemoteWatchlistRepository called") panic!("PanicRemoteWatchlistRepository called")
} }
} }
@@ -708,3 +787,55 @@ impl UserProfileFieldsRepository for PanicProfileFieldsRepo {
panic!("PanicProfileFieldsRepo called") panic!("PanicProfileFieldsRepo called")
} }
} }
pub struct PanicSocialQueryPort;
#[async_trait]
impl crate::ports::SocialQueryPort for PanicSocialQueryPort {
async fn get_accepted_following_urls(&self, _: uuid::Uuid) -> Result<Vec<String>, DomainError> {
panic!("PanicSocialQueryPort called")
}
async fn list_all_followed_remote_actors(
&self,
) -> Result<Vec<crate::ports::RemoteActorInfo>, DomainError> {
panic!("PanicSocialQueryPort called")
}
async fn count_following(&self, _: uuid::Uuid) -> Result<usize, DomainError> {
panic!("PanicSocialQueryPort called")
}
async fn count_accepted_followers(&self, _: uuid::Uuid) -> Result<usize, DomainError> {
panic!("PanicSocialQueryPort called")
}
async fn get_pending_followers(
&self,
_: uuid::Uuid,
) -> Result<Vec<crate::ports::PendingFollowerInfo>, DomainError> {
panic!("PanicSocialQueryPort called")
}
}
pub struct NoopSocialQueryPort;
#[async_trait]
impl crate::ports::SocialQueryPort for NoopSocialQueryPort {
async fn get_accepted_following_urls(&self, _: uuid::Uuid) -> Result<Vec<String>, DomainError> {
Ok(vec![])
}
async fn list_all_followed_remote_actors(
&self,
) -> Result<Vec<crate::ports::RemoteActorInfo>, DomainError> {
Ok(vec![])
}
async fn count_following(&self, _: uuid::Uuid) -> Result<usize, DomainError> {
Ok(0)
}
async fn count_accepted_followers(&self, _: uuid::Uuid) -> Result<usize, DomainError> {
Ok(0)
}
async fn get_pending_followers(
&self,
_: uuid::Uuid,
) -> Result<Vec<crate::ports::PendingFollowerInfo>, DomainError> {
Ok(vec![])
}
}

View File

@@ -3,11 +3,10 @@ use std::sync::Arc;
use anyhow::Context; use anyhow::Context;
use domain::ports::{ use domain::ports::{
AuthService, DiaryRepository, ImageStorage, ImportProfileRepository, AuthService, DiaryRepository, ImageStorage, ImportProfileRepository, ImportSessionRepository,
ImportSessionRepository, LocalApContentQuery, MetadataClient, MovieProfileRepository, LocalApContentQuery, MetadataClient, MovieProfileRepository, MovieRepository, PasswordHasher,
MovieRepository, PasswordHasher, PersonCommand, PersonQuery, PosterFetcherClient, PersonCommand, PersonQuery, PosterFetcherClient, ReviewRepository, SearchCommand, SearchPort,
ReviewRepository, SearchCommand, SearchPort, StatsRepository, UserProfileFieldsRepository, StatsRepository, UserProfileFieldsRepository, UserRepository, WatchlistRepository,
UserRepository, WatchlistRepository,
}; };
pub struct DatabaseAdapters { pub struct DatabaseAdapters {
@@ -36,10 +35,7 @@ pub enum DbPool {
Postgres(sqlx::PgPool), Postgres(sqlx::PgPool),
} }
pub async fn build_database_adapters( pub async fn build_database_adapters(backend: &str, url: &str) -> anyhow::Result<DatabaseAdapters> {
backend: &str,
url: &str,
) -> anyhow::Result<DatabaseAdapters> {
match backend { match backend {
#[cfg(feature = "postgres")] #[cfg(feature = "postgres")]
"postgres" => { "postgres" => {
@@ -118,7 +114,9 @@ pub fn build_image_storage() -> anyhow::Result<Arc<dyn ImageStorage>> {
image_storage::create() image_storage::create()
} }
pub fn build_profile_fields_repo(pool: &DbPool) -> anyhow::Result<Arc<dyn UserProfileFieldsRepository>> { pub fn build_profile_fields_repo(
pool: &DbPool,
) -> anyhow::Result<Arc<dyn UserProfileFieldsRepository>> {
match pool { match pool {
#[cfg(feature = "postgres")] #[cfg(feature = "postgres")]
DbPool::Postgres(pool) => Ok(postgres::create_profile_fields_repo(pool.clone())), DbPool::Postgres(pool) => Ok(postgres::create_profile_fields_repo(pool.clone())),

View File

@@ -33,7 +33,7 @@ use domain::{
DiaryEntry, ExportFormat, Movie, MovieSummary, PersonId, Review, collections::PageParams, DiaryEntry, ExportFormat, Movie, MovieSummary, PersonId, Review, collections::PageParams,
}, },
services::review_history::Trend, services::review_history::Trend,
value_objects::{MovieId, UserId}, value_objects::UserId,
}; };
use crate::{ use crate::{
@@ -178,32 +178,7 @@ pub async fn sync_poster(
_user: AuthenticatedUser, _user: AuthenticatedUser,
Path(movie_id): Path<Uuid>, Path(movie_id): Path<Uuid>,
) -> Result<impl IntoResponse, ApiError> { ) -> Result<impl IntoResponse, ApiError> {
let movie = state sync_poster::execute(&state.app_ctx, SyncPosterCommand { movie_id }).await?;
.app_ctx
.movie_repository
.get_movie_by_id(&MovieId::from_uuid(movie_id))
.await?
.ok_or_else(|| ApiError(DomainError::NotFound(format!("Movie {movie_id}"))))?;
let external_id = movie
.external_metadata_id()
.ok_or_else(|| {
ApiError(DomainError::ValidationError(
"Movie has no external metadata ID, cannot sync poster".into(),
))
})?
.value()
.to_string();
sync_poster::execute(
&state.app_ctx,
SyncPosterCommand {
movie_id,
external_metadata_id: external_id,
},
)
.await?;
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
@@ -438,26 +413,26 @@ pub async fn get_profile(
State(state): State<AppState>, State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser, AuthenticatedUser(user_id): AuthenticatedUser,
) -> impl IntoResponse { ) -> impl IntoResponse {
let user = match state.app_ctx.user_repository.find_by_id(&user_id).await { match application::use_cases::get_current_profile::execute(
Ok(Some(u)) => u, &state.app_ctx,
Ok(None) => return StatusCode::NOT_FOUND.into_response(), application::queries::GetCurrentProfileQuery {
user_id: user_id.value(),
},
)
.await
{
Ok(profile) => Json(ProfileResponse {
username: profile.username,
bio: profile.bio,
avatar_url: profile.avatar_url,
})
.into_response(),
Err(DomainError::NotFound(_)) => StatusCode::NOT_FOUND.into_response(),
Err(e) => { Err(e) => {
tracing::error!("get_profile user lookup: {:?}", e); tracing::error!("get_profile error: {:?}", e);
return StatusCode::INTERNAL_SERVER_ERROR.into_response(); 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( #[utoipa::path(
@@ -475,6 +450,7 @@ pub async fn update_profile_handler(
AuthenticatedUser(user_id): AuthenticatedUser, AuthenticatedUser(user_id): AuthenticatedUser,
mut multipart: Multipart, mut multipart: Multipart,
) -> impl IntoResponse { ) -> impl IntoResponse {
let mut display_name: Option<String> = None;
let mut bio: Option<String> = None; let mut bio: Option<String> = None;
let mut avatar_bytes: Option<Vec<u8>> = None; let mut avatar_bytes: Option<Vec<u8>> = None;
let mut avatar_content_type: Option<String> = None; let mut avatar_content_type: Option<String> = None;
@@ -485,6 +461,11 @@ pub async fn update_profile_handler(
while let Ok(Some(field)) = multipart.next_field().await { while let Ok(Some(field)) = multipart.next_field().await {
let name = field.name().unwrap_or("").to_string(); let name = field.name().unwrap_or("").to_string();
match name.as_str() { match name.as_str() {
"display_name" => {
if let Ok(text) = field.text().await {
display_name = Some(text).filter(|s| !s.is_empty());
}
}
"bio" => { "bio" => {
if let Ok(text) = field.text().await { if let Ok(text) = field.text().await {
bio = Some(text); bio = Some(text);
@@ -497,20 +478,20 @@ pub async fn update_profile_handler(
} }
"avatar" => { "avatar" => {
let ct = field.content_type().map(|s| s.to_string()); let ct = field.content_type().map(|s| s.to_string());
if let Ok(bytes) = field.bytes().await { if let Ok(bytes) = field.bytes().await
if !bytes.is_empty() { && !bytes.is_empty()
avatar_bytes = Some(bytes.to_vec()); {
avatar_content_type = ct; avatar_bytes = Some(bytes.to_vec());
} avatar_content_type = ct;
} }
} }
"banner" => { "banner" => {
let ct = field.content_type().map(|s| s.to_string()); let ct = field.content_type().map(|s| s.to_string());
if let Ok(bytes) = field.bytes().await { if let Ok(bytes) = field.bytes().await
if !bytes.is_empty() { && !bytes.is_empty()
banner_bytes = Some(bytes.to_vec()); {
banner_content_type = ct; banner_bytes = Some(bytes.to_vec());
} banner_content_type = ct;
} }
} }
_ => {} _ => {}
@@ -519,6 +500,7 @@ pub async fn update_profile_handler(
let cmd = application::commands::UpdateProfileCommand { let cmd = application::commands::UpdateProfileCommand {
user_id: user_id.value(), user_id: user_id.value(),
display_name,
bio, bio,
avatar_bytes, avatar_bytes,
avatar_content_type, avatar_content_type,
@@ -1025,7 +1007,8 @@ pub async fn get_activity_feed(
offset: params.offset.unwrap_or(0), offset: params.offset.unwrap_or(0),
sort_by: domain::ports::FeedSortBy::Date, sort_by: domain::ports::FeedSortBy::Date,
search: None, search: None,
following: None, viewer_user_id: None,
filter_following: false,
}, },
) )
.await?; .await?;
@@ -1051,9 +1034,10 @@ pub async fn get_activity_feed(
responses((status = 200, body = UsersResponse)), responses((status = 200, body = UsersResponse)),
)] )]
pub async fn list_users(State(state): State<AppState>) -> Result<Json<UsersResponse>, ApiError> { pub async fn list_users(State(state): State<AppState>) -> Result<Json<UsersResponse>, ApiError> {
let users = get_users::execute(&state.app_ctx, GetUsersQuery).await?; let result = get_users::execute(&state.app_ctx, GetUsersQuery).await?;
Ok(Json(UsersResponse { Ok(Json(UsersResponse {
users: users users: result
.users
.iter() .iter()
.map(|u| UserSummaryDto { .map(|u| UserSummaryDto {
id: u.user_id.value(), id: u.user_id.value(),
@@ -1110,6 +1094,7 @@ pub async fn get_user_profile(
offset: params.offset, offset: params.offset,
sort_by: domain::ports::FeedSortBy::Date, sort_by: domain::ports::FeedSortBy::Date,
search: None, search: None,
is_own_profile: false,
}, },
) )
.await .await
@@ -1121,20 +1106,6 @@ pub async fn get_user_profile(
} }
}; };
#[cfg(feature = "federation")]
let following_count = state.ap_service.count_following(user_id).await.unwrap_or(0);
#[cfg(not(feature = "federation"))]
let following_count = 0usize;
#[cfg(feature = "federation")]
let followers_count = state
.ap_service
.count_accepted_followers(user_id)
.await
.unwrap_or(0);
#[cfg(not(feature = "federation"))]
let followers_count = 0usize;
let entries = profile.entries.map(|p| DiaryResponse { let entries = profile.entries.map(|p| DiaryResponse {
items: p.items.iter().map(entry_to_dto).collect(), items: p.items.iter().map(entry_to_dto).collect(),
total_count: p.total_count, total_count: p.total_count,
@@ -1185,8 +1156,8 @@ pub async fn get_user_profile(
favorite_director: profile.stats.favorite_director, favorite_director: profile.stats.favorite_director,
most_active_month: profile.stats.most_active_month, most_active_month: profile.stats.most_active_month,
}, },
following_count, following_count: profile.following_count,
followers_count, followers_count: profile.followers_count,
entries, entries,
history, history,
trends, trends,

View File

@@ -9,33 +9,26 @@ use axum::{
use chrono::Utc; use chrono::Utc;
use uuid::Uuid; use uuid::Uuid;
#[cfg(feature = "federation")]
use application::ports::{
BlockedActorEntry, BlockedActorsPageData, BlockedDomainEntry, BlockedDomainsPageData,
FollowersPageData, FollowingPageData,
};
use application::{ use application::{
commands::{ commands::{
AddToWatchlistCommand, DeleteReviewCommand, MovieInput, RegisterCommand, AddToWatchlistCommand, DeleteReviewCommand, MovieInput, RemoveFromWatchlistCommand,
RemoveFromWatchlistCommand,
}, },
ports::{ ports::{
HtmlPageContext, LoginPageData, MovieDetailPageData, NewReviewPageData, HtmlPageContext, LoginPageData, MovieDetailPageData, NewReviewPageData,
ProfileSettingsPageData, RegisterPageData, RemoteActorView, WatchlistDisplayEntry, ProfileSettingsPageData, RegisterPageData, RemoteActorView, WatchlistPageData,
WatchlistPageData,
},
queries::{
ExportQuery, GetMovieSocialPageQuery, GetWatchlistQuery, IsOnWatchlistQuery, LoginQuery,
}, },
queries::{ExportQuery, GetMovieSocialPageQuery, IsOnWatchlistQuery, LoginQuery},
use_cases::{ use_cases::{
add_to_watchlist, delete_review, export_diary as export_diary_uc, get_movie_social_page, add_to_watchlist, delete_review, export_diary as export_diary_uc, get_movie_social_page,
get_watchlist, is_on_watchlist, log_review, login as login_uc, register as register_uc, is_on_watchlist, log_review, login as login_uc, remove_from_watchlist, update_profile,
remove_from_watchlist, update_profile, update_profile_fields, update_profile_fields,
}, },
}; };
#[cfg(feature = "federation")]
use application::{
ports::{
BlockedActorEntry, BlockedActorsPageData, BlockedDomainEntry, BlockedDomainsPageData,
FollowersPageData, FollowingPageData,
},
use_cases::get_remote_watchlist,
};
use domain::models::ExportFormat; use domain::models::ExportFormat;
use domain::{errors::DomainError, value_objects::UserId}; use domain::{errors::DomainError, value_objects::UserId};
@@ -216,27 +209,21 @@ pub async fn post_register(
if crate::csrf::mismatch(&csrf, &form.csrf_token) { if crate::csrf::mismatch(&csrf, &form.csrf_token) {
return StatusCode::FORBIDDEN.into_response(); return StatusCode::FORBIDDEN.into_response();
} }
let email = form.email.clone(); match application::use_cases::register_and_login::execute(
let password = form.password.clone();
match register_uc::execute(
&state.app_ctx, &state.app_ctx,
RegisterCommand { application::commands::RegisterAndLoginCommand {
email: form.email, email: form.email,
username: form.username, username: form.username,
password: form.password, password: form.password,
role: domain::models::UserRole::Standard,
}, },
) )
.await .await
{ {
Ok(_) => match login_uc::execute(&state.app_ctx, LoginQuery { email, password }).await { Ok(result) => {
Ok(result) => { let max_age = (result.expires_at - Utc::now()).num_seconds().max(0);
let max_age = (result.expires_at - Utc::now()).num_seconds().max(0); let cookie = set_cookie_header(&result.token, max_age);
let cookie = set_cookie_header(&result.token, max_age); ([cookie], Redirect::to("/")).into_response()
([cookie], Redirect::to("/")).into_response() }
}
Err(_) => Redirect::to("/login").into_response(),
},
Err(_) => { Err(_) => {
Redirect::to("/register?error=Registration+failed.+Please+try+again.").into_response() Redirect::to("/register?error=Registration+failed.+Please+try+again.").into_response()
} }
@@ -369,14 +356,9 @@ pub async fn get_activity_feed(
let limit = params.limit.unwrap_or(20); let limit = params.limit.unwrap_or(20);
let offset = params.offset.unwrap_or(0); let offset = params.offset.unwrap_or(0);
#[cfg(feature = "federation")] let filter_following =
let filter_str = if params.filter == "following" && user_id.is_some() { cfg!(feature = "federation") && params.filter == "following" && user_id.is_some();
"following" let filter_str = if filter_following { "following" } else { "all" };
} else {
"all"
};
#[cfg(not(feature = "federation"))]
let filter_str = "all";
let sort_by_str = match params.sort_by.as_str() { let sort_by_str = match params.sort_by.as_str() {
"date_asc" => "date_asc", "date_asc" => "date_asc",
@@ -385,52 +367,17 @@ pub async fn get_activity_feed(
_ => "date", _ => "date",
}; };
#[cfg(feature = "federation")]
let following = if filter_str == "following" {
if let Some(uid) = user_id {
let urls = state
.social_query
.get_accepted_following_urls(uid.value())
.await
.unwrap_or_default();
let base_url = &state.app_ctx.config.base_url;
let mut local_ids = vec![uid.value()];
let mut remote_urls = Vec::new();
for url in urls {
if let Some(suffix) = url.strip_prefix(&format!("{}/users/", base_url))
&& let Ok(parsed_id) = uuid::Uuid::parse_str(suffix)
{
local_ids.push(parsed_id);
continue;
}
remote_urls.push(url);
}
Some(domain::ports::FollowingFilter {
local_user_ids: local_ids,
remote_actor_urls: remote_urls,
})
} else {
None
}
} else {
None
};
#[cfg(not(feature = "federation"))]
let following: Option<domain::ports::FollowingFilter> = None;
let search_opt = if params.search.is_empty() {
None
} else {
Some(params.search.clone())
};
let query = application::queries::GetActivityFeedQuery { let query = application::queries::GetActivityFeedQuery {
limit, limit,
offset, offset,
sort_by: domain::ports::FeedSortBy::from_str(sort_by_str), sort_by: sort_by_str.parse().unwrap_or_default(),
search: search_opt, search: if params.search.is_empty() {
following, None
} else {
Some(params.search.clone())
},
viewer_user_id: user_id.map(|u| u.value()),
filter_following,
}; };
match application::use_cases::get_activity_feed::execute(&state.app_ctx, query).await { match application::use_cases::get_activity_feed::execute(&state.app_ctx, query).await {
@@ -467,27 +414,15 @@ pub async fn get_users_list(
ctx.page_title = "Members — Movies Diary".to_string(); ctx.page_title = "Members — Movies Diary".to_string();
ctx.canonical_url = format!("{}/users", state.app_ctx.config.base_url); ctx.canonical_url = format!("{}/users", state.app_ctx.config.base_url);
#[cfg(feature = "federation")] match application::use_cases::get_users::execute(
let (users_result, actors_result) = tokio::join!( &state.app_ctx,
application::use_cases::get_users::execute( application::queries::GetUsersQuery,
&state.app_ctx, )
application::queries::GetUsersQuery, .await
), {
state.social_query.list_all_followed_remote_actors() Ok(result) => {
); let actor_views = result
#[cfg(not(feature = "federation"))] .remote_actors
let (users_result, actors_result) = (
application::use_cases::get_users::execute(
&state.app_ctx,
application::queries::GetUsersQuery,
)
.await,
Ok::<Vec<domain::ports::RemoteActorInfo>, domain::errors::DomainError>(vec![]),
);
match (users_result, actors_result) {
(Ok(users), Ok(remote_actors)) => {
let actor_views = remote_actors
.into_iter() .into_iter()
.map(|a| application::ports::RemoteActorView { .map(|a| application::ports::RemoteActorView {
handle: a.handle, handle: a.handle,
@@ -498,7 +433,7 @@ pub async fn get_users_list(
.collect(); .collect();
let data = application::ports::UsersPageData { let data = application::ports::UsersPageData {
ctx, ctx,
users, users: result.users,
remote_actors: actor_views, remote_actors: actor_views,
}; };
match state.html_renderer.render_users_page(data) { match state.html_renderer.render_users_page(data) {
@@ -506,8 +441,7 @@ pub async fn get_users_list(
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(), Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
} }
} }
(Err(e), _) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
(_, Err(e)) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
} }
} }
@@ -605,68 +539,18 @@ pub async fn get_user_profile(
.map(|u| u.value() == profile_user_uuid) .map(|u| u.value() == profile_user_uuid)
.unwrap_or(false); .unwrap_or(false);
#[cfg(feature = "federation")]
let following_count = if is_own_profile {
if let Some(ref uid) = user_id {
state
.ap_service
.count_following(uid.value())
.await
.unwrap_or(0)
} else {
0
}
} else {
0
};
#[cfg(not(feature = "federation"))]
let following_count = 0usize;
#[cfg(feature = "federation")]
let followers_count = if is_own_profile {
state
.ap_service
.count_accepted_followers(profile_user_uuid)
.await
.unwrap_or(0)
} else {
0
};
#[cfg(not(feature = "federation"))]
let followers_count = 0usize;
#[cfg(feature = "federation")]
let pending_followers: Vec<application::ports::RemoteActorView> = if is_own_profile {
state
.ap_service
.get_pending_followers(profile_user_uuid)
.await
.unwrap_or_default()
.into_iter()
.map(|a| application::ports::RemoteActorView {
handle: a.handle,
url: a.url,
display_name: a.display_name,
avatar_url: a.avatar_url.clone(),
})
.collect()
} else {
vec![]
};
#[cfg(not(feature = "federation"))]
let pending_followers: Vec<application::ports::RemoteActorView> = vec![];
let query = application::queries::GetUserProfileQuery { let query = application::queries::GetUserProfileQuery {
user_id: profile_user_uuid, user_id: profile_user_uuid,
view: profile_view, view: profile_view,
limit: params.limit, limit: params.limit,
offset: params.offset, offset: params.offset,
sort_by: domain::ports::FeedSortBy::from_str(sort_by_str), sort_by: sort_by_str.parse().unwrap_or_default(),
search: if params.search.is_empty() { search: if params.search.is_empty() {
None None
} else { } else {
Some(params.search.clone()) Some(params.search.clone())
}, },
is_own_profile,
}; };
match application::use_cases::get_user_profile::execute(&state.app_ctx, query).await { match application::use_cases::get_user_profile::execute(&state.app_ctx, query).await {
@@ -682,6 +566,16 @@ pub async fn get_user_profile(
if !is_own_profile { if !is_own_profile {
ctx.page_rss_url = Some(format!("/users/{}/feed.rss", profile_user_uuid)); ctx.page_rss_url = Some(format!("/users/{}/feed.rss", profile_user_uuid));
} }
let pending_followers: Vec<application::ports::RemoteActorView> = profile
.pending_followers
.into_iter()
.map(|p| application::ports::RemoteActorView {
handle: p.handle,
url: p.url,
display_name: p.display_name,
avatar_url: p.avatar_url,
})
.collect();
let data = application::ports::ProfilePageData { let data = application::ports::ProfilePageData {
ctx, ctx,
profile_user_id: profile_user_uuid, profile_user_id: profile_user_uuid,
@@ -696,8 +590,8 @@ pub async fn get_user_profile(
trends: profile.trends, trends: profile.trends,
is_own_profile, is_own_profile,
error: params.error, error: params.error,
following_count, following_count: profile.following_count,
followers_count, followers_count: profile.followers_count,
pending_followers, pending_followers,
sort_by: sort_by_str.to_string(), sort_by: sort_by_str.to_string(),
search: params.search.clone(), search: params.search.clone(),
@@ -834,6 +728,70 @@ pub async fn reject_follower(
} }
} }
#[cfg(feature = "federation")]
pub async fn get_followers_collection(
State(state): State<AppState>,
Path(user_id): Path<Uuid>,
headers: axum::http::HeaderMap,
Query(params): Query<std::collections::HashMap<String, String>>,
) -> impl IntoResponse {
let accept = headers
.get(axum::http::header::ACCEPT)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
if accept.contains("application/activity+json") || accept.contains("application/ld+json") {
let page = params.get("page").and_then(|p| p.parse::<u32>().ok());
return match state
.ap_service
.followers_collection_json(user_id, page)
.await
{
Ok(json) => (
[(
axum::http::header::CONTENT_TYPE,
"application/activity+json",
)],
json,
)
.into_response(),
Err(_) => StatusCode::NOT_FOUND.into_response(),
};
}
axum::response::Redirect::to(&format!("/users/{}/followers-list", user_id)).into_response()
}
#[cfg(feature = "federation")]
pub async fn get_following_collection(
State(state): State<AppState>,
Path(user_id): Path<Uuid>,
headers: axum::http::HeaderMap,
Query(params): Query<std::collections::HashMap<String, String>>,
) -> impl IntoResponse {
let accept = headers
.get(axum::http::header::ACCEPT)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
if accept.contains("application/activity+json") || accept.contains("application/ld+json") {
let page = params.get("page").and_then(|p| p.parse::<u32>().ok());
return match state
.ap_service
.following_collection_json(user_id, page)
.await
{
Ok(json) => (
[(
axum::http::header::CONTENT_TYPE,
"application/activity+json",
)],
json,
)
.into_response(),
Err(_) => StatusCode::NOT_FOUND.into_response(),
};
}
axum::response::Redirect::to(&format!("/users/{}/following-list", user_id)).into_response()
}
#[cfg(feature = "federation")] #[cfg(feature = "federation")]
pub async fn get_following_page( pub async fn get_following_page(
RequiredCookieUser(user_id): RequiredCookieUser, RequiredCookieUser(user_id): RequiredCookieUser,
@@ -1051,94 +1009,33 @@ pub async fn get_watchlist_page(
Extension(csrf): Extension<CsrfToken>, Extension(csrf): Extension<CsrfToken>,
) -> impl IntoResponse { ) -> impl IntoResponse {
let ctx = build_page_context(&state, viewer_id.clone(), csrf.0).await; let ctx = build_page_context(&state, viewer_id.clone(), csrf.0).await;
let limit = params.limit.unwrap_or(20);
let offset = params.offset.unwrap_or(0);
let is_owner = viewer_id.map(|u| u.value() == owner_id).unwrap_or(false); let is_owner = viewer_id.map(|u| u.value() == owner_id).unwrap_or(false);
// Try local user first let result = match application::use_cases::get_watchlist_page::execute(
let local_user = state &state.app_ctx,
.app_ctx application::queries::GetWatchlistQuery {
.user_repository user_id: owner_id,
.find_by_id(&domain::value_objects::UserId::from_uuid(owner_id)) limit: params.limit.or(Some(20)),
.await offset: params.offset.or(Some(0)),
.ok() },
.flatten(); is_owner,
)
let (display_entries, has_more, current_offset, page_limit) = if local_user.is_some() { .await
match get_watchlist::execute( {
&state.app_ctx, Ok(r) => r,
GetWatchlistQuery { Err(e) => {
user_id: owner_id, tracing::error!("watchlist error: {:?}", e);
limit: Some(limit), return StatusCode::INTERNAL_SERVER_ERROR.into_response();
offset: Some(offset),
},
)
.await
{
Err(e) => {
tracing::error!("watchlist error: {:?}", e);
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
}
Ok(entries) => {
let has_more = entries.offset + entries.limit < entries.total_count as u32;
let display: Vec<WatchlistDisplayEntry> = entries
.items
.iter()
.map(|w| {
let remove_url = if is_owner {
Some(format!("/watchlist/{}/remove", w.movie.id().value()))
} else {
None
};
WatchlistDisplayEntry {
poster_url: w
.movie
.poster_path()
.map(|p| format!("/images/{}", p.value())),
movie_title: w.movie.title().value().to_string(),
release_year: w.movie.release_year().value(),
movie_url: Some(format!("/movies/{}", w.movie.id().value())),
added_at: w.entry.added_at.format("%b %-d, %Y").to_string(),
remove_url,
}
})
.collect();
(display, has_more, entries.offset, entries.limit)
}
}
} else {
#[cfg(feature = "federation")]
{
let remote_entries = get_remote_watchlist::execute(&state.app_ctx, owner_id)
.await
.unwrap_or_default();
let display: Vec<WatchlistDisplayEntry> = remote_entries
.into_iter()
.map(|e| WatchlistDisplayEntry {
poster_url: e.poster_url,
movie_title: e.movie_title,
release_year: e.release_year,
movie_url: None,
added_at: e.added_at.format("%b %-d, %Y").to_string(),
remove_url: None,
})
.collect();
let len = display.len() as u32;
(display, false, 0u32, len)
}
#[cfg(not(feature = "federation"))]
{
(vec![], false, 0u32, 0u32)
} }
}; };
let data = WatchlistPageData { let data = WatchlistPageData {
ctx, ctx,
owner_id, owner_id,
display_entries, display_entries: result.display_entries,
current_offset, current_offset: result.current_offset,
has_more, has_more: result.has_more,
limit: page_limit, limit: result.limit,
is_owner, is_owner,
error: params.error, error: params.error,
}; };
@@ -1503,6 +1400,7 @@ pub async fn post_profile_settings(
State(state): State<AppState>, State(state): State<AppState>,
mut multipart: Multipart, mut multipart: Multipart,
) -> impl IntoResponse { ) -> impl IntoResponse {
let mut display_name: Option<String> = None;
let mut bio: Option<String> = None; let mut bio: Option<String> = None;
let mut avatar_bytes: Option<Vec<u8>> = None; let mut avatar_bytes: Option<Vec<u8>> = None;
let mut avatar_content_type: Option<String> = None; let mut avatar_content_type: Option<String> = None;
@@ -1517,6 +1415,11 @@ pub async fn post_profile_settings(
while let Ok(Some(field)) = multipart.next_field().await { while let Ok(Some(field)) = multipart.next_field().await {
let name = field.name().unwrap_or("").to_string(); let name = field.name().unwrap_or("").to_string();
match name.as_str() { match name.as_str() {
"display_name" => {
if let Ok(text) = field.text().await {
display_name = Some(text).filter(|s| !s.is_empty());
}
}
"bio" => { "bio" => {
if let Ok(text) = field.text().await { if let Ok(text) = field.text().await {
bio = Some(text); bio = Some(text);
@@ -1529,38 +1432,36 @@ pub async fn post_profile_settings(
} }
"avatar" => { "avatar" => {
let ct = field.content_type().map(|s| s.to_string()); let ct = field.content_type().map(|s| s.to_string());
if let Ok(bytes) = field.bytes().await { if let Ok(bytes) = field.bytes().await
if !bytes.is_empty() { && !bytes.is_empty()
avatar_bytes = Some(bytes.to_vec()); {
avatar_content_type = ct; avatar_bytes = Some(bytes.to_vec());
} avatar_content_type = ct;
} }
} }
"banner" => { "banner" => {
let ct = field.content_type().map(|s| s.to_string()); let ct = field.content_type().map(|s| s.to_string());
if let Ok(bytes) = field.bytes().await { if let Ok(bytes) = field.bytes().await
if !bytes.is_empty() { && !bytes.is_empty()
banner_bytes = Some(bytes.to_vec()); {
banner_content_type = ct; banner_bytes = Some(bytes.to_vec());
} banner_content_type = ct;
} }
} }
n if n.starts_with("field_name_") => { n if n.starts_with("field_name_") => {
if let Ok(idx) = n["field_name_".len()..].parse::<usize>() { if let Ok(idx) = n["field_name_".len()..].parse::<usize>()
if let Ok(text) = field.text().await { && let Ok(text) = field.text().await
if !text.is_empty() { && !text.is_empty()
field_names.insert(idx, text); {
} field_names.insert(idx, text);
}
} }
} }
n if n.starts_with("field_value_") => { n if n.starts_with("field_value_") => {
if let Ok(idx) = n["field_value_".len()..].parse::<usize>() { if let Ok(idx) = n["field_value_".len()..].parse::<usize>()
if let Ok(text) = field.text().await { && let Ok(text) = field.text().await
if !text.is_empty() { && !text.is_empty()
field_values.insert(idx, text); {
} field_values.insert(idx, text);
}
} }
} }
_ => {} _ => {}
@@ -1569,6 +1470,7 @@ pub async fn post_profile_settings(
let cmd = application::commands::UpdateProfileCommand { let cmd = application::commands::UpdateProfileCommand {
user_id: user_id.value(), user_id: user_id.value(),
display_name,
bio, bio,
avatar_bytes, avatar_bytes,
avatar_content_type, avatar_content_type,

View File

@@ -82,17 +82,24 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
#[cfg(feature = "federation")] #[cfg(feature = "federation")]
let (event_publisher_arc, ap_router, ap_service, social_query, remote_watchlist_repo) = { let (event_publisher_arc, ap_router, ap_service, social_query, remote_watchlist_repo) = {
let (federation_repo, social_query_arc, review_store, remote_watchlist_repo) = let (
match &db_pool { activity_repo,
#[cfg(feature = "postgres-federation")] follow_repo,
factory::DbPool::Postgres(pool) => postgres_federation::wire(pool.clone()), actor_repo,
#[cfg(feature = "sqlite-federation")] blocklist_repo,
factory::DbPool::Sqlite(pool) => sqlite_federation::wire(pool.clone()), social_query_arc,
#[cfg(not(feature = "sqlite-federation"))] review_store,
_ => anyhow::bail!( remote_watchlist_repo,
"DATABASE_BACKEND={backend} federation is not supported by this build" ) = match &db_pool {
), #[cfg(feature = "postgres-federation")]
}; factory::DbPool::Postgres(pool) => postgres_federation::wire(pool.clone()),
#[cfg(feature = "sqlite-federation")]
factory::DbPool::Sqlite(pool) => sqlite_federation::wire(pool.clone()),
#[cfg(not(feature = "sqlite-federation"))]
_ => anyhow::bail!(
"DATABASE_BACKEND={backend} federation is not supported by this build"
),
};
let ep: Arc<dyn EventPublisher> = match event_bus { let ep: Arc<dyn EventPublisher> = match event_bus {
EventBusBackend::Db => { EventBusBackend::Db => {
@@ -118,16 +125,19 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
} }
}; };
let ap = activitypub::wire( let ap = activitypub::wire(activitypub::ActivityPubDeps {
federation_repo, activity_repo,
follow_repo,
actor_repo,
blocklist_repo,
review_store, review_store,
remote_watchlist_repo.clone(), remote_watchlist_repo: remote_watchlist_repo.clone(),
Arc::clone(&ap_content_repo), local_ap_content: Arc::clone(&ap_content_repo),
Arc::clone(&user_repository), user_repo: Arc::clone(&user_repository),
app_config.base_url.clone(), base_url: app_config.base_url.clone(),
app_config.allow_registration, allow_registration: app_config.allow_registration,
Arc::clone(&ep), event_publisher: Arc::clone(&ep),
) })
.await?; .await?;
let ap_router = ap.router; let ap_router = ap.router;
let ap_service_arc = ap.service; let ap_service_arc = ap.service;
@@ -192,6 +202,8 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
profile_fields_repository: profile_fields_repo, profile_fields_repository: profile_fields_repo,
#[cfg(feature = "federation")] #[cfg(feature = "federation")]
remote_watchlist_repository: remote_watchlist_repo, remote_watchlist_repository: remote_watchlist_repo,
#[cfg(feature = "federation")]
social_query: social_query.clone(),
person_command, person_command,
person_query, person_query,
search_port, search_port,

View File

@@ -166,6 +166,14 @@ fn federation_html_routes() -> Router<AppState> {
"/users/{id}/followers/reject", "/users/{id}/followers/reject",
routing::post(handlers::html::reject_follower), routing::post(handlers::html::reject_follower),
) )
.route(
"/users/{id}/followers",
routing::get(handlers::html::get_followers_collection),
)
.route(
"/users/{id}/following",
routing::get(handlers::html::get_following_collection),
)
.route( .route(
"/users/{id}/following-list", "/users/{id}/following-list",
routing::get(handlers::html::get_following_page), routing::get(handlers::html::get_following_page),

View File

@@ -135,6 +135,18 @@ impl domain::ports::SocialQueryPort for Panic {
) -> Result<Vec<domain::ports::RemoteActorInfo>, DomainError> { ) -> Result<Vec<domain::ports::RemoteActorInfo>, DomainError> {
panic!() panic!()
} }
async fn count_following(&self, _: uuid::Uuid) -> Result<usize, DomainError> {
panic!()
}
async fn count_accepted_followers(&self, _: uuid::Uuid) -> Result<usize, DomainError> {
panic!()
}
async fn get_pending_followers(
&self,
_: uuid::Uuid,
) -> Result<Vec<domain::ports::PendingFollowerInfo>, DomainError> {
panic!()
}
} }
#[async_trait::async_trait] #[async_trait::async_trait]
impl StatsRepository for Panic { impl StatsRepository for Panic {
@@ -219,10 +231,7 @@ impl UserRepository for Panic {
async fn update_profile( async fn update_profile(
&self, &self,
_: &UserId, _: &UserId,
_: Option<String>, _: &domain::models::UserProfile,
_: Option<String>,
_: Option<String>,
_: Option<String>,
) -> Result<(), DomainError> { ) -> Result<(), DomainError> {
panic!() panic!()
} }
@@ -587,6 +596,8 @@ pub fn make_test_state(auth_service: Arc<dyn AuthService>) -> crate::state::AppS
profile_fields_repository: Arc::clone(&repo) as _, profile_fields_repository: Arc::clone(&repo) as _,
#[cfg(feature = "federation")] #[cfg(feature = "federation")]
remote_watchlist_repository: Arc::clone(&repo) as _, remote_watchlist_repository: Arc::clone(&repo) as _,
#[cfg(feature = "federation")]
social_query: Arc::clone(&repo) as _,
person_command: Arc::clone(&repo) as _, person_command: Arc::clone(&repo) as _,
person_query: Arc::clone(&repo) as _, person_query: Arc::clone(&repo) as _,
search_port: Arc::clone(&repo) as _, search_port: Arc::clone(&repo) as _,

View File

@@ -119,10 +119,7 @@ impl UserRepository for NobodyUserRepo {
async fn update_profile( async fn update_profile(
&self, &self,
_: &UserId, _: &UserId,
_: Option<String>, _: &domain::models::UserProfile,
_: Option<String>,
_: Option<String>,
_: Option<String>,
) -> Result<(), DomainError> { ) -> Result<(), DomainError> {
Ok(()) Ok(())
} }
@@ -373,6 +370,18 @@ impl domain::ports::SocialQueryPort for PanicSocialQuery {
) -> Result<Vec<domain::ports::RemoteActorInfo>, DomainError> { ) -> Result<Vec<domain::ports::RemoteActorInfo>, DomainError> {
panic!() panic!()
} }
async fn count_following(&self, _: uuid::Uuid) -> Result<usize, DomainError> {
panic!()
}
async fn count_accepted_followers(&self, _: uuid::Uuid) -> Result<usize, DomainError> {
panic!()
}
async fn get_pending_followers(
&self,
_: uuid::Uuid,
) -> Result<Vec<domain::ports::PendingFollowerInfo>, DomainError> {
panic!()
}
} }
async fn test_app() -> Router { async fn test_app() -> Router {
@@ -405,6 +414,8 @@ async fn test_app() -> Router {
profile_fields_repository: Arc::new(PanicProfileFields), profile_fields_repository: Arc::new(PanicProfileFields),
#[cfg(feature = "federation")] #[cfg(feature = "federation")]
remote_watchlist_repository: Arc::new(PanicRemoteWatchlist), remote_watchlist_repository: Arc::new(PanicRemoteWatchlist),
#[cfg(feature = "federation")]
social_query: Arc::new(PanicSocialQuery),
person_command: Arc::new(PanicPersonCommand), person_command: Arc::new(PanicPersonCommand),
person_query: Arc::new(PanicPersonQuery), person_query: Arc::new(PanicPersonQuery),
search_port: Arc::new(PanicSearchPort), search_port: Arc::new(PanicSearchPort),

View File

@@ -1,3 +1,4 @@
#![allow(clippy::large_enum_variant)]
pub mod app; pub mod app;
pub mod client; pub mod client;
pub mod config; pub mod config;

View File

@@ -10,18 +10,29 @@ pub struct FollowBackfillHandler {
#[async_trait] #[async_trait]
impl EventHandler for FollowBackfillHandler { impl EventHandler for FollowBackfillHandler {
async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError> { async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError> {
let DomainEvent::FollowAccepted { match event {
remote_actor_url, DomainEvent::FollowAccepted {
outbox_url, remote_actor_url,
.. outbox_url,
} = event ..
else { } => {
return Ok(()); tracing::info!(actor = %remote_actor_url, outbox = %outbox_url, "importing remote outbox");
}; self.ap_service
tracing::info!(actor = %remote_actor_url, outbox = %outbox_url, "starting outbox backfill"); .import_remote_outbox(outbox_url, remote_actor_url)
self.ap_service .await
.backfill_outbox(outbox_url, remote_actor_url) .map_err(|e| DomainError::InfrastructureError(e.to_string()))
.await }
.map_err(|e| DomainError::InfrastructureError(e.to_string())) DomainEvent::BackfillFollower {
owner_user_id,
follower_inbox_url,
} => {
tracing::info!(owner = %owner_user_id.value(), inbox = %follower_inbox_url, "backfilling local content to new follower");
self.ap_service
.run_backfill_for_follower(owner_user_id.value(), follower_inbox_url.clone())
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))
}
_ => Ok(()),
}
} }
} }

View File

@@ -47,12 +47,7 @@ async fn main() -> anyhow::Result<()> {
// Clone refs federation handler needs before ctx consumes them. // Clone refs federation handler needs before ctx consumes them.
#[cfg(feature = "federation")] #[cfg(feature = "federation")]
let ( let (fed_ap_content, fed_user_repo, base_url, allow_registration) = (
fed_ap_content,
fed_user_repo,
base_url,
allow_registration,
) = (
Arc::clone(&repos.ap_content), Arc::clone(&repos.ap_content),
Arc::clone(&repos.user), Arc::clone(&repos.user),
app_config.base_url.clone(), app_config.base_url.clone(),
@@ -60,13 +55,20 @@ async fn main() -> anyhow::Result<()> {
); );
// Wire federation repos early to get remote_watchlist_repo for AppContext. // Wire federation repos early to get remote_watchlist_repo for AppContext.
#[cfg(feature = "federation")] #[cfg(feature = "federation")]
let (fed_federation_repo, _fed_social_query, fed_review_store, fed_remote_watchlist_repo) = let (
match &db_pool { fed_activity_repo,
#[cfg(feature = "sqlite-federation")] fed_follow_repo,
db::DbPool::Sqlite(pool) => sqlite_federation::wire(pool.clone()), fed_actor_repo,
#[cfg(feature = "postgres-federation")] fed_blocklist_repo,
db::DbPool::Postgres(pool) => postgres_federation::wire(pool.clone()), fed_social_query,
}; fed_review_store,
fed_remote_watchlist_repo,
) = match &db_pool {
#[cfg(feature = "sqlite-federation")]
db::DbPool::Sqlite(pool) => sqlite_federation::wire(pool.clone()),
#[cfg(feature = "postgres-federation")]
db::DbPool::Postgres(pool) => postgres_federation::wire(pool.clone()),
};
let ctx = AppContext { let ctx = AppContext {
movie_repository: repos.movie, movie_repository: repos.movie,
@@ -89,6 +91,8 @@ async fn main() -> anyhow::Result<()> {
profile_fields_repository: Arc::clone(&profile_fields_repo), profile_fields_repository: Arc::clone(&profile_fields_repo),
#[cfg(feature = "federation")] #[cfg(feature = "federation")]
remote_watchlist_repository: fed_remote_watchlist_repo.clone(), remote_watchlist_repository: fed_remote_watchlist_repo.clone(),
#[cfg(feature = "federation")]
social_query: fed_social_query,
person_command: Arc::clone(&person_command), person_command: Arc::clone(&person_command),
person_query: Arc::clone(&person_query), person_query: Arc::clone(&person_query),
search_port: Arc::clone(&search_port), search_port: Arc::clone(&search_port),
@@ -100,28 +104,27 @@ async fn main() -> anyhow::Result<()> {
// Both the event handler and the staleness job are gated on TMDB_API_KEY. // Both the event handler and the staleness job are gated on TMDB_API_KEY.
// Without a key, no MovieEnrichmentRequested events are produced or handled. // Without a key, no MovieEnrichmentRequested events are produced or handled.
let (enrichment_handler, enrichment_job): ( type OptionalPair = (Option<Arc<dyn EventHandler>>, Option<Arc<dyn PeriodicJob>>);
Option<Arc<dyn EventHandler>>, let (enrichment_handler, enrichment_job): OptionalPair =
Option<Arc<dyn PeriodicJob>>, match tmdb_enrichment::TmdbEnrichmentClient::from_env() {
) = match tmdb_enrichment::TmdbEnrichmentClient::from_env() { Ok(client) => {
Ok(client) => { tracing::info!("TMDb enrichment enabled");
tracing::info!("TMDb enrichment enabled"); let handler = Arc::new(tmdb_enrichment::EnrichmentHandler {
let handler = Arc::new(tmdb_enrichment::EnrichmentHandler { enrichment_client: Arc::new(client),
enrichment_client: Arc::new(client), movie_repository: Arc::clone(&ctx.movie_repository),
movie_repository: Arc::clone(&ctx.movie_repository), profile_repo: Arc::clone(&ctx.movie_profile_repository),
profile_repo: Arc::clone(&ctx.movie_profile_repository), person_command: Arc::clone(&ctx.person_command),
person_command: Arc::clone(&ctx.person_command), search_command: Arc::clone(&ctx.search_command),
search_command: Arc::clone(&ctx.search_command), }) as Arc<dyn EventHandler>;
}) as Arc<dyn EventHandler>; let job = Arc::new(application::jobs::EnrichmentStalenessJob::new(ctx.clone()))
let job = Arc::new(application::jobs::EnrichmentStalenessJob::new(ctx.clone())) as Arc<dyn PeriodicJob>;
as Arc<dyn PeriodicJob>; (Some(handler), Some(job))
(Some(handler), Some(job)) }
} Err(e) => {
Err(e) => { tracing::warn!("TMDb enrichment disabled: {e}");
tracing::warn!("TMDb enrichment disabled: {e}"); (None, None)
(None, None) }
} };
};
// ── Image conversion ────────────────────────────────────────────────────── // ── Image conversion ──────────────────────────────────────────────────────
@@ -194,16 +197,19 @@ async fn main() -> anyhow::Result<()> {
#[cfg(feature = "federation")] #[cfg(feature = "federation")]
{ {
let ap_wire = activitypub::wire( let ap_wire = activitypub::wire(activitypub::ActivityPubDeps {
fed_federation_repo, activity_repo: fed_activity_repo,
fed_review_store, follow_repo: fed_follow_repo,
fed_remote_watchlist_repo, actor_repo: fed_actor_repo,
fed_ap_content, blocklist_repo: fed_blocklist_repo,
fed_user_repo, review_store: fed_review_store,
remote_watchlist_repo: fed_remote_watchlist_repo,
local_ap_content: fed_ap_content,
user_repo: fed_user_repo,
base_url, base_url,
allow_registration, allow_registration,
Arc::clone(&ctx.event_publisher), event_publisher: Arc::clone(&ctx.event_publisher),
) })
.await?; .await?;
let ap_event_handler = ap_wire.event_handler; let ap_event_handler = ap_wire.event_handler;