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]]
name = "k-ap"
version = "0.1.10"
source = "git+https://git.gabrielkaszewski.dev/GKaszewski/k-ap.git?tag=v0.1.10#d80cfd0431205498161db8665fd884710866ca95"
version = "0.3.1"
source = "sparse+https://git.gabrielkaszewski.dev/api/packages/GKaszewski/cargo/"
checksum = "f73de37ac4feab6d7b78e73c60acbb07933c2be58dcbb12e8a34201f66e0480d"
dependencies = [
"activitypub_federation",
"anyhow",
@@ -2787,6 +2788,7 @@ dependencies = [
"tracing",
"url",
"uuid",
"zeroize",
]
[[package]]
@@ -3749,6 +3751,7 @@ dependencies = [
"chrono",
"domain",
"k-ap",
"serde_json",
"sqlx",
"tracing",
"uuid",
@@ -5014,6 +5017,7 @@ dependencies = [
"chrono",
"domain",
"k-ap",
"serde_json",
"sqlx",
"tokio",
"tracing",
@@ -7060,6 +7064,20 @@ name = "zeroize"
version = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
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"
[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 }
axum = { workspace = true }
serde = { workspace = true }

View File

@@ -1,8 +1,8 @@
use std::sync::Arc;
use k_ap::ApObjectHandler;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use k_ap::{ApContentReader, ApObjectHandler};
use url::Url;
use crate::{review_handler::ReviewObjectHandler, watchlist_handler::WatchlistObjectHandler};
@@ -13,16 +13,7 @@ pub struct CompositeObjectHandler {
}
#[async_trait]
impl ApObjectHandler 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)
}
impl ApContentReader for CompositeObjectHandler {
async fn get_local_objects_page(
&self,
user_id: uuid::Uuid,
@@ -34,6 +25,13 @@ impl ApObjectHandler for CompositeObjectHandler {
.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(
&self,
ap_id: &Url,
@@ -77,15 +75,23 @@ impl ApObjectHandler for CompositeObjectHandler {
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<()> {
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(())
}
@@ -93,7 +99,12 @@ impl ApObjectHandler for CompositeObjectHandler {
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(())
}
}

View File

@@ -8,7 +8,7 @@ use domain::{
};
use std::sync::Arc;
use k_ap::ActivityPubService;
use k_ap::{ActivityPubService, ApVisibility};
use crate::objects::review_to_ap_object;
use crate::urls::{actor_url, review_url};
@@ -105,7 +105,10 @@ impl ActivityPubEventHandler {
.as_ref()
.map(|m| m.title().value().to_string())
.unwrap_or_else(|| "Unknown".to_string());
let release_year = movie.as_ref().map(|m| m.release_year().value()).unwrap_or(0);
let release_year = movie
.as_ref()
.map(|m| m.release_year().value())
.unwrap_or(0);
let poster_url = movie
.as_ref()
.and_then(|m| m.poster_path())
@@ -123,7 +126,7 @@ impl ActivityPubEventHandler {
let json = serde_json::to_value(obj)?;
self.ap_service
.broadcast_add_to_followers(user_id.value(), ap_id, json)
.broadcast_create_note(user_id.value(), json, ApVisibility::Public, vec![])
.await?;
Ok(())
@@ -152,7 +155,10 @@ impl ActivityPubEventHandler {
.as_ref()
.map(|m| m.title().value().to_string())
.unwrap_or_else(|| "Unknown".to_string());
let release_year = movie.as_ref().map(|m| m.release_year().value()).unwrap_or(0);
let release_year = movie
.as_ref()
.map(|m| m.release_year().value())
.unwrap_or(0);
let poster_url = movie
.as_ref()
.and_then(|m| m.poster_path())
@@ -170,7 +176,7 @@ impl ActivityPubEventHandler {
let json = serde_json::to_value(obj)?;
self.ap_service
.broadcast_update_note(user_id.value(), json)
.broadcast_update_note(user_id.value(), json, ApVisibility::Public, vec![])
.await?;
Ok(())
@@ -214,20 +220,20 @@ impl ActivityPubEventHandler {
let added_at_utc =
chrono::DateTime::<chrono::Utc>::from_naive_utc_and_offset(*added_at, chrono::Utc);
let obj = crate::objects::watchlist_to_ap_object(
ap_id.clone(),
actor,
movie_title.to_string(),
let obj = crate::objects::watchlist_to_ap_object(crate::objects::WatchlistApInput {
ap_id: ap_id.clone(),
actor_url: actor,
movie_title: movie_title.to_string(),
release_year,
external_metadata_id.clone(),
external_metadata_id: external_metadata_id.clone(),
poster_url,
added_at_utc,
&self.base_url,
);
added_at: added_at_utc,
base_url: self.base_url.clone(),
});
let json = serde_json::to_value(obj)?;
self.ap_service
.broadcast_create_note(user_id.value(), json)
.broadcast_create_note(user_id.value(), json, ApVisibility::Public, vec![])
.await?;
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 event_handler;
pub mod federation_event_bridge;
pub mod objects;
pub mod port;
pub mod remote_review_repository;
@@ -10,8 +11,9 @@ pub mod watchlist_handler;
// Re-export the generic base types that callers need
pub use k_ap::{
ActivityPubService, ApFederationConfig, ApObjectHandler, ApUser, ApUserRepository,
FederationData, FederationRepository, Follower, FollowerStatus, FollowingStatus, RemoteActor,
ActivityPubService, ActivityRepository, ActorRepository, ApContentReader, ApFederationConfig,
ApObjectHandler, ApUser, ApUserRepository, BlocklistRepository, FederationData,
FollowRepository, Follower, FollowerStatus, FollowingStatus, RemoteActor,
};
pub use event_handler::ActivityPubEventHandler;
@@ -20,22 +22,50 @@ pub use remote_review_repository::RemoteReviewRepository;
pub use review_handler::ReviewObjectHandler;
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 service: std::sync::Arc<dyn ActivityPubPort>,
pub router: axum::Router,
pub event_handler: std::sync::Arc<dyn domain::ports::EventHandler>,
}
pub async fn wire(
federation_repo: std::sync::Arc<dyn FederationRepository>,
review_store: std::sync::Arc<dyn RemoteReviewRepository>,
remote_watchlist_repo: std::sync::Arc<dyn domain::ports::RemoteWatchlistRepository>,
local_ap_content: std::sync::Arc<dyn domain::ports::LocalApContentQuery>,
user_repo: std::sync::Arc<dyn domain::ports::UserRepository>,
base_url: String,
allow_registration: bool,
_event_publisher: std::sync::Arc<dyn domain::ports::EventPublisher>,
) -> anyhow::Result<ActivityPubWire> {
pub struct ActivityPubDeps {
pub activity_repo: std::sync::Arc<dyn ActivityRepository>,
pub follow_repo: std::sync::Arc<dyn FollowRepository>,
pub actor_repo: std::sync::Arc<dyn ActorRepository>,
pub blocklist_repo: std::sync::Arc<dyn BlocklistRepository>,
pub review_store: std::sync::Arc<dyn RemoteReviewRepository>,
pub remote_watchlist_repo: std::sync::Arc<dyn domain::ports::RemoteWatchlistRepository>,
pub local_ap_content: std::sync::Arc<dyn domain::ports::LocalApContentQuery>,
pub user_repo: std::sync::Arc<dyn domain::ports::UserRepository>,
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 {
content_query: std::sync::Arc::clone(&local_ap_content),
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(
ActivityPubService::builder(
federation_repo,
std::sync::Arc::new(DomainUserRepoAdapter::new(user_repo, base_url.clone())),
composite,
base_url.clone(),
)
.allow_registration(allow_registration)
.software_name("movies-diary")
.debug(federation_debug)
.build()
.await?,
ActivityPubService::builder(base_url.clone())
.activity_repo(activity_repo)
.follow_repo(follow_repo)
.actor_repo(actor_repo)
.blocklist_repo(blocklist_repo)
.user_repo(std::sync::Arc::new(DomainUserRepoAdapter::new(
user_repo,
base_url.clone(),
)))
.content_reader(composite.clone() as std::sync::Arc<dyn ApContentReader>)
.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();

View File

@@ -1,6 +1,6 @@
use chrono::{DateTime, Utc};
use k_ap::AS_PUBLIC;
use k_ap::NoteType;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use url::Url;
@@ -131,16 +131,28 @@ pub struct WatchlistObject {
pub(crate) cc: Vec<String>,
}
pub fn watchlist_to_ap_object(
ap_id: Url,
actor_url: Url,
movie_title: String,
release_year: u16,
external_metadata_id: Option<String>,
poster_url: Option<String>,
added_at: chrono::DateTime<chrono::Utc>,
base_url: &str,
) -> WatchlistObject {
pub struct WatchlistApInput {
pub ap_id: Url,
pub actor_url: Url,
pub movie_title: String,
pub release_year: u16,
pub external_metadata_id: Option<String>,
pub poster_url: Option<String>,
pub added_at: chrono::DateTime<chrono::Utc>,
pub base_url: String,
}
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 {
format!(" ({})", release_year)
} 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 remove_blocked_domain(&self, domain: &str) -> anyhow::Result<()>;
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]
@@ -98,8 +113,30 @@ impl ActivityPubPort for ActivityPubService {
async fn get_blocked_domains(&self) -> anyhow::Result<Vec<BlockedDomain>> {
self.get_blocked_domains().await
}
async fn backfill_outbox(&self, outbox_url: &str, actor_url: &str) -> anyhow::Result<()> {
self.backfill_outbox(outbox_url, actor_url).await
async fn import_remote_outbox(&self, outbox_url: &str, actor_url: &str) -> anyhow::Result<()> {
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>> {
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(())
}
}

View File

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

View File

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

View File

@@ -1,8 +1,8 @@
use std::sync::Arc;
use k_ap::{ApProfileField, ApUser, ApUserRepository};
use async_trait::async_trait;
use domain::{ports::UserRepository, value_objects::UserId};
use k_ap::{ApProfileField, ApUser, ApUserRepository};
use url::Url;
pub struct DomainUserRepoAdapter {
@@ -26,10 +26,14 @@ impl DomainUserRepoAdapter {
ApUser {
id: u.id().value(),
username: u.username().value().to_string(),
display_name: u.display_name().map(|s| s.to_string()),
bio: u.bio().map(|s| s.to_string()),
avatar_url,
banner_url,
also_known_as: u.also_known_as().map(|s| s.to_string()),
also_known_as: u
.also_known_as()
.map(|s| vec![s.to_string()])
.unwrap_or_default(),
profile_url,
attachment: u
.profile_fields()
@@ -39,6 +43,15 @@ impl DomainUserRepoAdapter {
value: f.value.clone(),
})
.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 k_ap::ApObjectHandler;
use async_trait::async_trait;
use chrono::Utc;
use domain::{
models::RemoteWatchlistEntry,
ports::{LocalApContentQuery, RemoteWatchlistRepository},
value_objects::UserId,
};
use k_ap::ApObjectHandler;
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 remote_watchlist_repo: Arc<dyn RemoteWatchlistRepository>,
@@ -20,55 +18,6 @@ pub struct WatchlistObjectHandler {
#[async_trait]
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(
&self,
ap_id: &Url,
@@ -115,15 +64,23 @@ impl ApObjectHandler for WatchlistObjectHandler {
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<()> {
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(())
}
@@ -131,7 +88,12 @@ impl ApObjectHandler for WatchlistObjectHandler {
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(())
}
}

View File

@@ -63,6 +63,10 @@ pub enum EventPayload {
remote_actor_url: String,
outbox_url: String,
},
BackfillFollower {
owner_user_id: String,
follower_inbox_url: String,
},
}
impl EventPayload {
@@ -79,6 +83,7 @@ impl EventPayload {
EventPayload::WatchlistEntryAdded { .. } => "WatchlistEntryAdded",
EventPayload::WatchlistEntryRemoved { .. } => "WatchlistEntryRemoved",
EventPayload::FollowAccepted { .. } => "FollowAccepted",
EventPayload::BackfillFollower { .. } => "BackfillFollower",
}
}
}
@@ -181,6 +186,13 @@ impl From<&DomainEvent> for EventPayload {
remote_actor_url: remote_actor_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,
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;
type ConversionPair = (Arc<dyn EventHandler>, Arc<dyn PeriodicJob>);
pub fn build(
image_storage: Arc<dyn ImageStorage>,
image_ref_command: Arc<dyn ImageRefCommand>,
image_ref_query: Arc<dyn ImageRefQuery>,
event_publisher: Arc<dyn EventPublisher>,
) -> anyhow::Result<Option<(Arc<dyn EventHandler>, Arc<dyn PeriodicJob>)>> {
) -> anyhow::Result<Option<ConversionPair>> {
let config = match ConversionConfig::from_env()? {
Some(c) => c,
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::WatchlistEntryRemoved { .. } => "watchlist.entry.removed",
DomainEvent::FollowAccepted { .. } => "follow.accepted",
DomainEvent::BackfillFollower { .. } => "backfill.follower",
};
format!("{prefix}.{suffix}")
}

View File

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

View File

@@ -4,11 +4,12 @@ use chrono::{NaiveDateTime, Utc};
use sqlx::{PgPool, Row};
use activitypub::RemoteReviewRepository;
use k_ap::{
BlockedDomain, FederationRepository, Follower, FollowerStatus, FollowingStatus, RemoteActor,
};
use domain::models::{RemoteWatchlistEntry, Review, ReviewSource};
use domain::ports::RemoteWatchlistRepository;
use k_ap::{
ActivityRepository, ActorRepository, BlockedDomain, BlocklistRepository, FollowRepository,
Follower, FollowerStatus, FollowingStatus, RemoteActor,
};
fn datetime_to_str(dt: &NaiveDateTime) -> 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]
impl FederationRepository for PostgresFederationRepository {
impl FollowRepository for PostgresFederationRepository {
async fn add_follower(
&self,
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>> {
let uid = local_user_id.to_string();
let rows = sqlx::query(
"SELECT f.remote_actor_url, f.status,
a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.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",
)
.bind(&uid)
.fetch_all(&self.pool)
.await?;
let q = format!(
"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
WHERE f.local_user_id = $1"
);
let rows = sqlx::query(&q).bind(&uid).fetch_all(&self.pool).await?;
Ok(rows
.into_iter()
.iter()
.map(|row| {
let url: String = row.get("remote_actor_url");
let status_str: String = row.get("status");
let handle: String = row.try_get("handle").unwrap_or_default();
let inbox_url: String = row.try_get("inbox_url").unwrap_or_default();
let shared_inbox_url: Option<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 {
actor: RemoteActor {
url,
handle,
inbox_url,
shared_inbox_url,
display_name,
avatar_url,
outbox_url: row.try_get("outbox_url").ok().flatten(),
},
actor: pg_remote_actor(row, "remote_actor_url"),
status: str_to_status(&status_str),
}
})
@@ -146,45 +157,24 @@ impl FederationRepository for PostgresFederationRepository {
limit: usize,
) -> Result<Vec<Follower>> {
let uid = local_user_id.to_string();
let limit_i64 = limit as i64;
let offset_i64 = offset as i64;
let rows = sqlx::query(
"SELECT f.remote_actor_url, f.status,
a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.avatar_url
FROM ap_followers f
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
let q = format!(
"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
WHERE f.local_user_id = $1 AND f.status = 'accepted'
ORDER BY f.created_at ASC
LIMIT $2 OFFSET $3",
)
.bind(&uid)
.bind(limit_i64)
.bind(offset_i64)
.fetch_all(&self.pool)
.await?;
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
.into_iter()
.iter()
.map(|row| {
let url: String = row.get("remote_actor_url");
let status_str: String = row.get("status");
let handle: String = row.try_get("handle").unwrap_or_default();
let inbox_url: String = row.try_get("inbox_url").unwrap_or_default();
let shared_inbox_url: Option<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 {
actor: RemoteActor {
url,
handle,
inbox_url,
shared_inbox_url,
display_name,
avatar_url,
outbox_url: row.try_get("outbox_url").ok().flatten(),
},
actor: pg_remote_actor(row, "remote_actor_url"),
status: str_to_status(&status_str),
}
})
@@ -212,18 +202,86 @@ impl FederationRepository for PostgresFederationRepository {
let status_str = status_to_str(&status);
let result = sqlx::query(
"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 {
tracing::warn!(local_user_id = %local_user_id, remote_actor_url, "update_follower_status: no row found");
}
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(
&self,
local_user_id: uuid::Uuid,
@@ -233,18 +291,11 @@ impl FederationRepository for PostgresFederationRepository {
let uid = local_user_id.to_string();
let now = Utc::now().naive_utc();
let created_at = datetime_to_str(&now);
self.upsert_remote_actor(actor.clone()).await?;
ActorRepository::upsert_remote_actor(self, actor.clone()).await?;
sqlx::query(
"INSERT INTO ap_following (local_user_id, remote_actor_url, follow_activity_id, created_at)
VALUES ($1, $2, $3, $4::timestamptz)
ON CONFLICT DO NOTHING",
)
.bind(&uid)
.bind(&actor.url)
.bind(follow_activity_id)
.bind(&created_at)
.execute(&self.pool)
.await?;
VALUES ($1, $2, $3, $4::timestamptz) ON CONFLICT DO NOTHING",
).bind(&uid).bind(&actor.url).bind(follow_activity_id).bind(&created_at).execute(&self.pool).await?;
Ok(())
}
@@ -256,11 +307,7 @@ impl FederationRepository for PostgresFederationRepository {
let uid = local_user_id.to_string();
let row: Option<String> = sqlx::query_scalar(
"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)
}
@@ -276,27 +323,13 @@ impl FederationRepository for PostgresFederationRepository {
async fn get_following(&self, local_user_id: uuid::Uuid) -> Result<Vec<RemoteActor>> {
let uid = local_user_id.to_string();
let rows = sqlx::query(
"SELECT a.url, a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, 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'",
)
.bind(&uid)
.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())
let q = format!(
"SELECT a.url, {PG_ACTOR_COLS}
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'"
);
let rows = sqlx::query(&q).bind(&uid).fetch_all(&self.pool).await?;
Ok(rows.iter().map(|row| pg_remote_actor(row, "url")).collect())
}
async fn count_following(&self, local_user_id: uuid::Uuid) -> Result<usize> {
@@ -317,84 +350,82 @@ impl FederationRepository for PostgresFederationRepository {
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 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
let q = format!(
"SELECT a.url, {PG_ACTOR_COLS}
FROM ap_following f INNER JOIN ap_remote_actors a ON a.url = f.remote_actor_url
WHERE f.local_user_id = $1 AND f.status = 'accepted'
ORDER BY f.created_at ASC
LIMIT $2 OFFSET $3",
)
.bind(&uid)
.bind(limit_i64)
.bind(offset_i64)
.fetch_all(&self.pool)
.await?;
Ok(rows
.into_iter()
.map(|row| RemoteActor {
url: row.get("url"),
handle: row.get("handle"),
inbox_url: row.get("inbox_url"),
shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(),
display_name: row.try_get("display_name").ok().flatten(),
avatar_url: row.try_get("avatar_url").ok().flatten(),
outbox_url: row.try_get("outbox_url").ok().flatten(),
})
.collect())
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, "url")).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 ($1, $2, $3, $4, $5, $6, $7, $8::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),
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?;
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(())
}
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 = $1",
)
.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_following_outbox_url(
&self,
local_user_id: uuid::Uuid,
remote_actor_url: &str,
) -> Result<Option<String>> {
let uid = local_user_id.to_string();
let row: Option<Option<String>> = sqlx::query_scalar(
"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 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(
&self,
user_id: uuid::Uuid,
@@ -420,86 +451,40 @@ impl FederationRepository for PostgresFederationRepository {
sqlx::query(
"INSERT INTO ap_local_actors (user_id, public_key, private_key, created_at)
VALUES ($1, $2, $3, $4::timestamptz)
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?;
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 = $1 AND f.status = 'pending'",
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 ($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)
.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())
}
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");
}
.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_following_outbox_url(
&self,
local_user_id: uuid::Uuid,
remote_actor_url: &str,
) -> Result<Option<String>> {
let uid = local_user_id.to_string();
let row: Option<Option<String>> = sqlx::query_scalar(
"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 get_remote_actor(&self, actor_url: &str) -> Result<Option<RemoteActor>> {
let q = format!("SELECT url, {PG_ACTOR_COLS} FROM ap_remote_actors a WHERE url = $1");
let row = sqlx::query(&q)
.bind(actor_url)
.fetch_optional(&self.pool)
.await?;
Ok(row.as_ref().map(|r| pg_remote_actor(r, "url")))
}
async fn add_announce(
@@ -510,15 +495,17 @@ impl FederationRepository for PostgresFederationRepository {
announced_at: chrono::DateTime<chrono::Utc>,
) -> Result<()> {
let ts = announced_at.format("%Y-%m-%d %H:%M:%S").to_string();
sqlx::query(
"INSERT INTO ap_announces (id, object_url, actor_url, announced_at) VALUES ($1, $2, $3, $4) ON CONFLICT (id) DO NOTHING",
)
.bind(activity_id)
.bind(object_url)
.bind(actor_url)
.bind(&ts)
.execute(&self.pool)
.await?;
sqlx::query("INSERT INTO ap_announces (id, object_url, actor_url, announced_at) VALUES ($1, $2, $3, $4) ON CONFLICT (id) DO NOTHING")
.bind(activity_id).bind(object_url).bind(actor_url).bind(&ts).execute(&self.pool).await?;
Ok(())
}
async fn 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(())
}
@@ -529,22 +516,16 @@ impl FederationRepository for PostgresFederationRepository {
.await?;
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<()> {
let now = 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",
)
.bind(domain)
.bind(reason)
.bind(&ts)
.execute(&self.pool)
.await?;
let ts = datetime_to_str(&Utc::now().naive_utc());
sqlx::query("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(())
}
async fn remove_blocked_domain(&self, domain: &str) -> Result<()> {
sqlx::query("DELETE FROM blocked_domains WHERE domain = $1")
.bind(domain)
@@ -552,7 +533,6 @@ impl FederationRepository for PostgresFederationRepository {
.await?;
Ok(())
}
async fn get_blocked_domains(&self) -> Result<Vec<BlockedDomain>> {
let rows = sqlx::query(
"SELECT domain, reason, blocked_at FROM blocked_domains ORDER BY blocked_at DESC",
@@ -568,7 +548,6 @@ impl FederationRepository for PostgresFederationRepository {
})
.collect())
}
async fn is_domain_blocked(&self, domain: &str) -> Result<bool> {
let count: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM blocked_domains WHERE domain = $1")
@@ -577,23 +556,13 @@ impl FederationRepository for PostgresFederationRepository {
.await?;
Ok(count > 0)
}
async fn add_blocked_actor(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()> {
let uid = local_user_id.to_string();
let ts = datetime_to_str(&Utc::now().naive_utc());
sqlx::query(
"INSERT INTO blocked_actors (local_user_id, remote_actor_url, blocked_at)
VALUES ($1, $2, $3)
ON CONFLICT DO NOTHING",
)
.bind(&uid)
.bind(actor_url)
.bind(&ts)
.execute(&self.pool)
.await?;
sqlx::query("INSERT INTO blocked_actors (local_user_id, remote_actor_url, blocked_at) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING")
.bind(&uid).bind(actor_url).bind(&ts).execute(&self.pool).await?;
Ok(())
}
async fn remove_blocked_actor(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()> {
let uid = local_user_id.to_string();
sqlx::query(
@@ -605,70 +574,42 @@ impl FederationRepository for PostgresFederationRepository {
.await?;
Ok(())
}
async fn get_blocked_actors(&self, local_user_id: uuid::Uuid) -> Result<Vec<String>> {
let uid = local_user_id.to_string();
let rows = sqlx::query(
"SELECT remote_actor_url FROM blocked_actors WHERE local_user_id = $1 ORDER BY blocked_at DESC",
)
.bind(&uid)
.fetch_all(&self.pool)
.await?;
let rows = sqlx::query("SELECT remote_actor_url FROM blocked_actors WHERE local_user_id = $1 ORDER BY blocked_at DESC")
.bind(&uid).fetch_all(&self.pool).await?;
Ok(rows
.iter()
.map(|r| r.get::<String, _>("remote_actor_url"))
.collect())
}
async fn is_actor_blocked(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<bool> {
let uid = local_user_id.to_string();
let count: i64 = sqlx::query_scalar(
"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?;
let count: i64 = sqlx::query_scalar("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?;
Ok(count > 0)
}
}
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![]);
}
#[async_trait]
impl ActivityRepository for PostgresFederationRepository {
async fn is_activity_processed(&self, activity_id: &str) -> Result<bool> {
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM ap_activities WHERE id = $1")
.bind(activity_id)
.fetch_one(&self.pool)
.await?;
Ok(count > 0)
}
async fn mark_activity_processed(&self, activity_id: &str) -> Result<()> {
let ts = datetime_to_str(&Utc::now().naive_utc());
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
)",
"INSERT INTO ap_activities (id, processed_at) VALUES ($1, $2) ON CONFLICT DO NOTHING",
)
.bind(new_actor_url)
.bind(old_actor_url)
.bind(activity_id)
.bind(&ts)
.execute(&self.pool)
.await?;
candidates
.into_iter()
.map(|s| uuid::Uuid::parse_str(&s).map_err(|e| anyhow::anyhow!(e)))
.collect()
Ok(())
}
}
@@ -807,6 +748,64 @@ impl domain::ports::SocialQueryPort for PostgresFederationRepository {
)
.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]
@@ -913,16 +912,12 @@ impl RemoteWatchlistRepository for PostgresFederationRepository {
}
}
pub fn wire(
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>,
) {
pub fn wire(pool: sqlx::PgPool) -> activitypub::FederationRepos {
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 _,

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 {
id: row.try_get("m_id").map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
external_metadata_id: row.try_get("external_metadata_id").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()))?,
id: row
.try_get("m_id")
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
external_metadata_id: row
.try_get("external_metadata_id")
.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()?;
Ok(WatchlistWithMovie { entry, movie })
@@ -103,10 +115,7 @@ impl LocalApContentQuery for PostgresApContentQuery {
.collect()
}
async fn get_review_by_id(
&self,
review_id: &ReviewId,
) -> Result<Option<Review>, DomainError> {
async fn get_review_by_id(&self, review_id: &ReviewId) -> Result<Option<Review>, DomainError> {
let id = review_id.value().to_string();
sqlx::query_as::<_, ReviewRow>(
"SELECT id, movie_id, user_id, rating, comment,
@@ -145,4 +154,55 @@ impl LocalApContentQuery for PostgresApContentQuery {
.map_err(Self::map_err)?;
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 domain::{
errors::DomainError,
models::{DiaryEntry, FeedEntry, Movie, MovieSummary, Review, ReviewSource, UserSummary},
models::{
DiaryEntry, FeedEntry, Movie, MovieSummary, PersistedReview, Review, ReviewSource,
UserSummary,
},
value_objects::{
Comment, Email, ExternalMetadataId, MovieId, MovieTitle, PosterPath, Rating, ReleaseYear,
ReviewId, UserId,
@@ -102,9 +105,16 @@ impl ReviewRow {
None => ReviewSource::Local,
Some(url) => ReviewSource::Remote { actor_url: url },
};
Ok(Review::from_persistence(
id, movie_id, user_id, rating, comment, watched_at, created_at, source,
))
Ok(Review::from_persistence(PersistedReview {
id,
movie_id,
user_id,
rating,
comment,
watched_at,
created_at,
source,
}))
}
}

View File

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

View File

@@ -6,9 +6,10 @@ edition = "2024"
[dependencies]
sqlx = { 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 }
anyhow = { workspace = true }
serde_json = { workspace = true }
uuid = { workspace = true }
chrono = { workspace = true }
tracing = { workspace = true }

View File

@@ -4,11 +4,12 @@ use chrono::{NaiveDateTime, Utc};
use sqlx::{Row, SqlitePool};
use activitypub::RemoteReviewRepository;
use k_ap::{
BlockedDomain, FederationRepository, Follower, FollowerStatus, FollowingStatus, RemoteActor,
};
use domain::models::{RemoteWatchlistEntry, Review, ReviewSource};
use domain::ports::RemoteWatchlistRepository;
use k_ap::{
ActivityRepository, ActorRepository, BlockedDomain, BlocklistRepository, FollowRepository,
Follower, FollowerStatus, FollowingStatus, RemoteActor,
};
fn datetime_to_str(dt: &NaiveDateTime) -> 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]
impl FederationRepository for SqliteFederationRepository {
impl FollowRepository for SqliteFederationRepository {
async fn add_follower(
&self,
local_user_id: uuid::Uuid,
@@ -107,7 +135,8 @@ impl FederationRepository for SqliteFederationRepository {
let rows = sqlx::query(
"SELECT f.remote_actor_url, f.status,
a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.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
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
WHERE f.local_user_id = ?",
@@ -116,34 +145,16 @@ impl FederationRepository for SqliteFederationRepository {
.fetch_all(&self.pool)
.await?;
let followers = rows
.into_iter()
Ok(rows
.iter()
.map(|row| {
let url: String = row.get("remote_actor_url");
let status_str: String = row.get("status");
let handle: String = row.try_get("handle").unwrap_or_default();
let inbox_url: String = row.try_get("inbox_url").unwrap_or_default();
let shared_inbox_url: Option<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 {
actor: RemoteActor {
url,
handle,
inbox_url,
shared_inbox_url,
display_name,
avatar_url,
outbox_url: row.try_get("outbox_url").ok().flatten(),
},
actor: remote_actor_from_row(row, "remote_actor_url"),
status: str_to_status(&status_str),
}
})
.collect();
Ok(followers)
.collect())
}
async fn get_followers_page(
@@ -158,7 +169,8 @@ impl FederationRepository for SqliteFederationRepository {
let rows = sqlx::query(
"SELECT f.remote_actor_url, f.status,
a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.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
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
WHERE f.local_user_id = ? AND f.status = 'accepted'
@@ -172,26 +184,11 @@ impl FederationRepository for SqliteFederationRepository {
.await?;
Ok(rows
.into_iter()
.iter()
.map(|row| {
let url: String = row.get("remote_actor_url");
let status_str: String = row.get("status");
let handle: String = row.try_get("handle").unwrap_or_default();
let inbox_url: String = row.try_get("inbox_url").unwrap_or_default();
let shared_inbox_url: Option<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 {
actor: RemoteActor {
url,
handle,
inbox_url,
shared_inbox_url,
display_name,
avatar_url,
outbox_url: row.try_get("outbox_url").ok().flatten(),
},
actor: remote_actor_from_row(row, "remote_actor_url"),
status: str_to_status(&status_str),
}
})
@@ -234,6 +231,95 @@ impl FederationRepository for SqliteFederationRepository {
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(
&self,
local_user_id: uuid::Uuid,
@@ -244,7 +330,7 @@ impl FederationRepository for SqliteFederationRepository {
let now = Utc::now().naive_utc();
let created_at = datetime_to_str(&now);
self.upsert_remote_actor(actor.clone()).await?;
ActorRepository::upsert_remote_actor(self, actor.clone()).await?;
sqlx::query(
"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 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
INNER JOIN ap_remote_actors a ON a.url = f.remote_actor_url
WHERE f.local_user_id = ? AND f.status = 'accepted'",
@@ -300,16 +387,8 @@ impl FederationRepository for SqliteFederationRepository {
.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(),
})
.iter()
.map(|row| remote_actor_from_row(row, "url"))
.collect())
}
@@ -335,7 +414,8 @@ impl FederationRepository for SqliteFederationRepository {
let offset_i64 = offset as i64;
let rows = sqlx::query(
"SELECT a.url, a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.avatar_url
"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
INNER JOIN ap_remote_actors a ON a.url = f.remote_actor_url
WHERE f.local_user_id = ? AND f.status = 'accepted'
@@ -349,134 +429,8 @@ impl FederationRepository for SqliteFederationRepository {
.await?;
Ok(rows
.into_iter()
.map(|row| RemoteActor {
url: row.get("url"),
handle: row.get("handle"),
inbox_url: row.get("inbox_url"),
shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(),
display_name: row.try_get("display_name").ok().flatten(),
avatar_url: row.try_get("avatar_url").ok().flatten(),
outbox_url: row.try_get("outbox_url").ok().flatten(),
})
.collect())
}
async fn upsert_remote_actor(&self, actor: RemoteActor) -> Result<()> {
let now = Utc::now().naive_utc();
let fetched_at = datetime_to_str(&now);
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(),
})
.iter()
.map(|row| remote_actor_from_row(row, "url"))
.collect())
}
@@ -527,6 +481,142 @@ impl FederationRepository for SqliteFederationRepository {
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(
&self,
activity_id: &str,
@@ -548,6 +638,15 @@ impl FederationRepository for SqliteFederationRepository {
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> {
let row = sqlx::query("SELECT COUNT(*) as cnt FROM ap_announces WHERE object_url = ?1")
.bind(object_url)
@@ -555,7 +654,10 @@ impl FederationRepository for SqliteFederationRepository {
.await?;
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<()> {
let now = Utc::now().naive_utc();
let ts = datetime_to_str(&now);
@@ -656,44 +758,26 @@ impl FederationRepository for SqliteFederationRepository {
.await?;
Ok(count > 0)
}
}
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?;
#[async_trait]
impl ActivityRepository for SqliteFederationRepository {
async fn is_activity_processed(&self, activity_id: &str) -> Result<bool> {
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM ap_activities WHERE id = ?1")
.bind(activity_id)
.fetch_one(&self.pool)
.await?;
Ok(count > 0)
}
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 fn mark_activity_processed(&self, activity_id: &str) -> Result<()> {
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)
.bind(&ts)
.execute(&self.pool)
.await?;
Ok(())
}
}
@@ -840,6 +924,64 @@ impl domain::ports::SocialQueryPort for SqliteFederationRepository {
)
.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]
@@ -954,16 +1096,12 @@ impl RemoteWatchlistRepository for SqliteFederationRepository {
}
}
pub fn wire(
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>,
) {
pub fn wire(pool: sqlx::SqlitePool) -> activitypub::FederationRepos {
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 _,
@@ -974,7 +1112,7 @@ pub fn wire(
#[cfg(test)]
mod outbox_url_tests {
use super::*;
use k_ap::{FederationRepository, FollowingStatus, RemoteActor};
use k_ap::{FollowRepository, FollowingStatus, RemoteActor};
async fn setup_pool() -> SqlitePool {
let pool = SqlitePool::connect(":memory:").await.unwrap();
@@ -982,7 +1120,8 @@ mod outbox_url_tests {
"CREATE TABLE ap_remote_actors (
url TEXT PRIMARY KEY, handle TEXT NOT NULL, inbox_url TEXT NOT NULL,
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 (
local_user_id TEXT NOT NULL, remote_actor_url TEXT NOT NULL,
@@ -1010,6 +1149,11 @@ mod outbox_url_tests {
display_name: None,
avatar_url: None,
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")
.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()
}
async fn get_review_by_id(
&self,
review_id: &ReviewId,
) -> Result<Option<Review>, DomainError> {
async fn get_review_by_id(&self, review_id: &ReviewId) -> Result<Option<Review>, DomainError> {
let id = review_id.value().to_string();
sqlx::query_as::<_, ReviewRow>(
"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)?;
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::{
errors::DomainError,
models::{
DiaryEntry, FeedEntry, Movie, MovieSummary, Review, ReviewSource, UserSummary,
WatchlistEntry, WatchlistWithMovie,
DiaryEntry, FeedEntry, Movie, MovieSummary, PersistedReview, Review, ReviewSource,
UserSummary, WatchlistEntry, WatchlistWithMovie,
},
value_objects::{
Comment, Email, ExternalMetadataId, MovieId, MovieTitle, PosterPath, Rating, ReleaseYear,
@@ -109,9 +109,16 @@ impl ReviewRow {
None => ReviewSource::Local,
Some(url) => ReviewSource::Remote { actor_url: url },
};
Ok(Review::from_persistence(
id, movie_id, user_id, rating, comment, watched_at, created_at, source,
))
Ok(Review::from_persistence(PersistedReview {
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) {
let pool = SqlitePool::connect(":memory:").await.unwrap();
sqlx::query(
"CREATE TABLE users (id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, created_at TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'standard', bio TEXT, avatar_path TEXT, 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)
.await
@@ -65,10 +65,11 @@ async fn update_profile_persists_bio_and_avatar() {
repo.update_profile(
user.id(),
Some("My biography".to_string()),
Some("avatars/user1".to_string()),
None,
None,
&domain::models::UserProfile {
bio: Some("My biography".to_string()),
avatar_path: Some("avatars/user1".to_string()),
..Default::default()
},
)
.await
.unwrap();
@@ -90,14 +91,15 @@ async fn update_profile_clears_fields_with_none() {
repo.save(&user).await.unwrap();
repo.update_profile(
user.id(),
Some("bio".to_string()),
Some("path".to_string()),
None,
None,
&domain::models::UserProfile {
bio: Some("bio".to_string()),
avatar_path: Some("path".to_string()),
..Default::default()
},
)
.await
.unwrap();
repo.update_profile(user.id(), None, None, None, None)
repo.update_profile(user.id(), &domain::models::UserProfile::default())
.await
.unwrap();

View File

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

View File

@@ -21,7 +21,6 @@ pub struct LogReviewCommand {
#[derive(Clone)]
pub struct SyncPosterCommand {
pub movie_id: Uuid,
pub external_metadata_id: String,
}
pub struct RegisterCommand {
@@ -75,6 +74,7 @@ pub struct DeleteImportProfileCommand {
pub struct UpdateProfileCommand {
pub user_id: Uuid,
pub display_name: Option<String>,
pub bio: Option<String>,
pub avatar_bytes: Option<Vec<u8>>,
pub avatar_content_type: Option<String>,
@@ -102,3 +102,9 @@ pub struct RemoveFromWatchlistCommand {
pub user_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,
ImportProfileRepository, ImportSessionRepository, MetadataClient, MovieProfileRepository,
MovieRepository, PasswordHasher, PersonCommand, PersonQuery, PosterFetcherClient,
ReviewRepository, SearchCommand, SearchPort, StatsRepository, UserProfileFieldsRepository,
UserRepository, WatchlistRepository,
ReviewRepository, SearchCommand, SearchPort, SocialQueryPort, StatsRepository,
UserProfileFieldsRepository, UserRepository, WatchlistRepository,
};
use crate::config::AppConfig;
@@ -38,5 +38,7 @@ pub struct AppContext {
pub profile_fields_repository: Arc<dyn UserProfileFieldsRepository>,
#[cfg(feature = "federation")]
pub remote_watchlist_repository: Arc<dyn RemoteWatchlistRepository>,
#[cfg(feature = "federation")]
pub social_query: Arc<dyn SocialQueryPort>,
pub config: AppConfig,
}

View File

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

View File

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

View File

@@ -1,5 +1,9 @@
use std::sync::Arc;
#[cfg(feature = "federation")]
use domain::testing::PanicRemoteWatchlistRepository;
#[cfg(feature = "federation")]
use domain::testing::PanicSocialQueryPort;
use domain::{
ports::{
AuthService, DiaryExporter, DiaryRepository, DocumentParser, EventPublisher, ImageStorage,
@@ -18,13 +22,8 @@ use domain::{
PanicSearchPort, PanicStatsRepository,
},
};
#[cfg(feature = "federation")]
use domain::testing::PanicRemoteWatchlistRepository;
use crate::{
config::AppConfig,
context::AppContext,
};
use crate::{config::AppConfig, context::AppContext};
pub struct TestContextBuilder {
pub movie_repo: Arc<dyn MovieRepository>,
@@ -147,6 +146,8 @@ impl TestContextBuilder {
config: self.config,
#[cfg(feature = "federation")]
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"
}
DomainEvent::FollowAccepted { .. } => "follow_accepted",
DomainEvent::BackfillFollower { .. } => "backfill_follower",
};
self.calls.lock().unwrap().push(label);
Ok(())

View File

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

View File

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

View File

@@ -5,6 +5,7 @@ use domain::{
FeedEntry,
collections::{PageParams, Paginated},
},
ports::FollowingFilter,
};
pub async fn execute(
@@ -12,12 +13,57 @@ pub async fn execute(
query: GetActivityFeedQuery,
) -> Result<Paginated<FeedEntry>, DomainError> {
let page = PageParams::new(Some(query.limit), Some(query.offset))?;
let following = build_following_filter(ctx, &query).await;
ctx.diary_repository
.query_activity_feed_filtered(
&page,
&query.sort_by,
query.search.as_deref(),
query.following.as_ref(),
following.as_ref(),
)
.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,
};
pub struct PendingFollowerView {
pub url: String,
pub handle: String,
pub display_name: Option<String>,
pub avatar_url: Option<String>,
}
pub struct UserProfileData {
pub stats: UserStats,
pub entries: Option<Paginated<DiaryEntry>>,
pub history: Option<Vec<MonthActivity>>,
pub trends: Option<UserTrends>,
pub following_count: usize,
pub followers_count: usize,
pub pending_followers: Vec<PendingFollowerView>,
}
pub async fn execute(
@@ -27,27 +37,30 @@ pub async fn execute(
let user_id = UserId::from_uuid(query.user_id);
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 {
ProfileView::History => {
let all_entries = ctx.diary_repository.get_user_history(&user_id).await?;
let history = group_by_month(all_entries);
Ok(UserProfileData {
stats,
entries: None,
history: Some(history),
trends: None,
})
Ok(base(None, Some(history), None))
}
ProfileView::Trends => {
let trends = ctx.stats_repository.get_user_trends(&user_id).await?;
Ok(UserProfileData {
stats,
entries: None,
history: None,
trends: Some(trends),
})
Ok(base(None, None, Some(trends)))
}
ProfileView::Ratings => {
ProfileView::Ratings | ProfileView::Recent => {
let sort_direction = feed_sort_to_direction(query.sort_by);
let filter = paged_user_filter(
user_id,
@@ -57,30 +70,49 @@ pub async fn execute(
query.search.clone(),
)?;
let entries = ctx.diary_repository.query_diary(&filter).await?;
Ok(UserProfileData {
stats,
entries: Some(entries),
history: None,
trends: None,
})
Ok(base(Some(entries), None, None))
}
ProfileView::Recent => {
let sort_direction = feed_sort_to_direction(query.sort_by);
let filter = paged_user_filter(
user_id,
sort_direction,
query.limit,
query.offset,
query.search.clone(),
)?;
let entries = ctx.diary_repository.query_diary(&filter).await?;
Ok(UserProfileData {
stats,
entries: Some(entries),
history: None,
trends: None,
})
}
}
async fn load_social_counts(
_ctx: &AppContext,
_user_id: uuid::Uuid,
_is_own_profile: bool,
) -> (usize, usize, Vec<PendingFollowerView>) {
#[cfg(not(feature = "federation"))]
{
(0, 0, vec![])
}
#[cfg(feature = "federation")]
{
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 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(
ctx: &AppContext,
_query: GetUsersQuery,
) -> Result<Vec<UserSummary>, DomainError> {
ctx.user_repository.list_with_stats().await
) -> Result<UsersListData, DomainError> {
#[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 export_diary;
pub mod get_activity_feed;
pub mod get_current_profile;
pub mod get_diary;
pub mod get_movie_social_page;
pub mod get_movies;
@@ -20,11 +21,13 @@ pub mod get_review_history;
pub mod get_user_profile;
pub mod get_users;
pub mod get_watchlist;
pub mod get_watchlist_page;
pub mod is_on_watchlist;
pub mod list_import_profiles;
pub mod log_review;
pub mod login;
pub mod register;
pub mod register_and_login;
pub mod remove_from_watchlist;
pub mod save_import_profile;
pub mod search;

View File

@@ -54,11 +54,7 @@ mod tests {
use domain::testing::InMemoryUserRepository;
use domain::value_objects::Email;
use crate::{
commands::RegisterCommand,
test_helpers::TestContextBuilder,
use_cases::register,
};
use crate::{commands::RegisterCommand, test_helpers::TestContextBuilder, use_cases::register};
fn cmd(email: &str) -> RegisterCommand {
RegisterCommand {
@@ -76,7 +72,9 @@ mod tests {
.with_users(Arc::clone(&users) as _)
.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 user = users.find_by_email(&email).await.unwrap().unwrap();
@@ -91,7 +89,9 @@ mod tests {
.with_users(Arc::clone(&users) as _)
.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;
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,
events::DomainEvent,
models::IndexableDocument,
value_objects::{ExternalMetadataId, MovieId, PosterPath},
value_objects::{MovieId, PosterPath},
};
use crate::{commands::SyncPosterCommand, context::AppContext};
pub async fn execute(ctx: &AppContext, cmd: SyncPosterCommand) -> Result<(), DomainError> {
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? {
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
.metadata_client
.get_poster_url(&external_metadata_id)

View File

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

View File

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

View File

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

View File

@@ -33,14 +33,15 @@ pub enum FeedSortBy {
RatingAsc,
}
impl FeedSortBy {
pub fn from_str(s: &str) -> Self {
match s {
impl std::str::FromStr for FeedSortBy {
type Err = std::convert::Infallible;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(match s {
"date_asc" => Self::DateAsc,
"rating" => Self::Rating,
"rating_asc" => Self::RatingAsc,
_ => Self::Date,
}
})
}
}
@@ -57,17 +58,31 @@ pub struct RemoteActorInfo {
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]
pub trait SocialQueryPort: Send + Sync {
/// Returns all accepted remote_actor_urls followed by `user_id`.
async fn get_accepted_following_urls(
&self,
user_id: uuid::Uuid,
) -> 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 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]
@@ -185,10 +200,7 @@ pub trait UserRepository: Send + Sync {
async fn update_profile(
&self,
user_id: &UserId,
bio: Option<String>,
avatar_path: Option<String>,
banner_path: Option<String>,
also_known_as: Option<String>,
profile: &crate::models::UserProfile,
) -> Result<(), DomainError>;
}
@@ -390,12 +402,16 @@ pub trait LocalApContentQuery: Send + Sync {
user_id: &UserId,
) -> Result<Vec<WatchlistWithMovie>, DomainError>;
async fn get_review_by_id(
&self,
review_id: &ReviewId,
) -> Result<Option<Review>, DomainError>;
async fn get_review_by_id(&self, review_id: &ReviewId) -> Result<Option<Review>, 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 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,
events::DomainEvent,
models::{
DiaryEntry, DiaryFilter, ExportFormat, FeedEntry, FieldMapping, FileFormat, ImportError,
ImportProfile, ImportSession, IndexableDocument, Movie, MovieFilter, MovieProfile,
MovieStats, MovieSummary, ParsedFile, Person, PersonCredits, PersonId, ExternalPersonId,
Review, ReviewHistory, SearchQuery, SearchResults, User, UserStats, UserSummary,
UserTrends, WatchlistEntry, WatchlistWithMovie, AnnotatedRow, EntityType,
AnnotatedRow, DiaryEntry, DiaryFilter, EntityType, ExportFormat, ExternalPersonId,
FeedEntry, FieldMapping, FileFormat, ImportError, ImportProfile, ImportSession,
IndexableDocument, Movie, MovieFilter, MovieProfile, MovieStats, MovieSummary, ParsedFile,
Person, PersonCredits, PersonId, Review, ReviewHistory, SearchQuery, SearchResults, User,
UserStats, UserSummary, UserTrends, WatchlistEntry, WatchlistWithMovie,
collections::{PageParams, Paginated},
},
ports::{
AuthService, DiaryExporter, DiaryRepository, DocumentParser, EventPublisher,
FeedSortBy, FollowingFilter, GeneratedToken, ImageStorage, ImportProfileRepository,
AuthService, DiaryExporter, DiaryRepository, DocumentParser, EventPublisher, FeedSortBy,
FollowingFilter, GeneratedToken, ImageStorage, ImportProfileRepository,
ImportSessionRepository, MetadataClient, MetadataSearchCriteria, MovieProfileRepository,
MovieRepository, PasswordHasher, PersonCommand, PersonQuery, PosterFetcherClient,
ReviewRepository, SearchCommand, SearchPort, StatsRepository, UserProfileFieldsRepository,
@@ -40,7 +40,9 @@ pub struct InMemoryMovieRepository {
impl InMemoryMovieRepository {
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 {
@@ -55,11 +57,14 @@ impl MovieRepository for InMemoryMovieRepository {
external_metadata_id: &ExternalMetadataId,
) -> Result<Option<Movie>, DomainError> {
let store = self.store.lock().unwrap();
Ok(store.values().find(|m| {
m.external_metadata_id()
.map(|e| e.value() == external_metadata_id.value())
.unwrap_or(false)
}).cloned())
Ok(store
.values()
.find(|m| {
m.external_metadata_id()
.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> {
@@ -72,11 +77,18 @@ impl MovieRepository for InMemoryMovieRepository {
year: &ReleaseYear,
) -> Result<Vec<Movie>, DomainError> {
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> {
self.store.lock().unwrap().insert(movie.id().value(), movie.clone());
self.store
.lock()
.unwrap()
.insert(movie.id().value(), movie.clone());
Ok(())
}
@@ -90,7 +102,12 @@ impl MovieRepository for InMemoryMovieRepository {
_page: &crate::models::collections::PageParams,
_filter: &MovieFilter,
) -> 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 {
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 {
@@ -113,7 +132,10 @@ impl InMemoryReviewRepository {
#[async_trait]
impl ReviewRepository for InMemoryReviewRepository {
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 {
review_id: review.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> {
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 {
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 {
@@ -158,16 +186,25 @@ impl InMemoryUserRepository {
impl UserRepository for InMemoryUserRepository {
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError> {
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> {
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> {
self.store.lock().unwrap().insert(user.id().value(), user.clone());
self.store
.lock()
.unwrap()
.insert(user.id().value(), user.clone());
Ok(())
}
@@ -182,10 +219,7 @@ impl UserRepository for InMemoryUserRepository {
async fn update_profile(
&self,
_user_id: &UserId,
_bio: Option<String>,
_avatar_path: Option<String>,
_banner_path: Option<String>,
_also_known_as: Option<String>,
_profile: &crate::models::UserProfile,
) -> Result<(), DomainError> {
Ok(())
}
@@ -199,7 +233,9 @@ pub struct InMemoryWatchlistRepository {
impl InMemoryWatchlistRepository {
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 {
@@ -211,13 +247,20 @@ impl InMemoryWatchlistRepository {
impl WatchlistRepository for InMemoryWatchlistRepository {
async fn add(&self, entry: &WatchlistEntry) -> Result<(), DomainError> {
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(())
}
async fn remove(&self, user_id: &UserId, movie_id: &MovieId) -> Result<(), DomainError> {
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(())
}
@@ -236,7 +279,12 @@ impl WatchlistRepository for InMemoryWatchlistRepository {
_user_id: &UserId,
_page: &PageParams,
) -> 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> {
@@ -253,7 +301,9 @@ pub struct NoopEventPublisher {
impl NoopEventPublisher {
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> {
@@ -333,7 +383,9 @@ impl MetadataClient for FakeMetadataClient {
&self,
_criteria: &MetadataSearchCriteria,
) -> Result<Movie, DomainError> {
Err(DomainError::InfrastructureError("fake metadata client".into()))
Err(DomainError::InfrastructureError(
"fake metadata client".into(),
))
}
async fn get_poster_url(
@@ -352,17 +404,25 @@ pub struct FakeDiaryRepository {
impl FakeDiaryRepository {
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>) {
self.histories.lock().unwrap().insert(movie.id().value(), (movie, reviews));
self.histories
.lock()
.unwrap()
.insert(movie.id().value(), (movie, reviews));
}
}
#[async_trait]
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")
}
@@ -418,7 +478,10 @@ pub struct PanicDiaryRepository;
#[async_trait]
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")
}
@@ -605,8 +668,18 @@ pub struct PanicSearchPort;
impl SearchPort for PanicSearchPort {
async fn search(&self, _query: &SearchQuery) -> Result<SearchResults, DomainError> {
Ok(SearchResults {
movies: Paginated { items: vec![], total_count: 0, limit: 10, offset: 0 },
people: Paginated { items: vec![], total_count: 0, limit: 10, offset: 0 },
movies: Paginated {
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> {
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")
}
async fn remove_all_by_actor(&self, _: &str) -> Result<(), DomainError> {
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")
}
}
@@ -708,3 +787,55 @@ impl UserProfileFieldsRepository for PanicProfileFieldsRepo {
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 domain::ports::{
AuthService, DiaryRepository, ImageStorage, ImportProfileRepository,
ImportSessionRepository, LocalApContentQuery, MetadataClient, MovieProfileRepository,
MovieRepository, PasswordHasher, PersonCommand, PersonQuery, PosterFetcherClient,
ReviewRepository, SearchCommand, SearchPort, StatsRepository, UserProfileFieldsRepository,
UserRepository, WatchlistRepository,
AuthService, DiaryRepository, ImageStorage, ImportProfileRepository, ImportSessionRepository,
LocalApContentQuery, MetadataClient, MovieProfileRepository, MovieRepository, PasswordHasher,
PersonCommand, PersonQuery, PosterFetcherClient, ReviewRepository, SearchCommand, SearchPort,
StatsRepository, UserProfileFieldsRepository, UserRepository, WatchlistRepository,
};
pub struct DatabaseAdapters {
@@ -36,10 +35,7 @@ pub enum DbPool {
Postgres(sqlx::PgPool),
}
pub async fn build_database_adapters(
backend: &str,
url: &str,
) -> anyhow::Result<DatabaseAdapters> {
pub async fn build_database_adapters(backend: &str, url: &str) -> anyhow::Result<DatabaseAdapters> {
match backend {
#[cfg(feature = "postgres")]
"postgres" => {
@@ -118,7 +114,9 @@ pub fn build_image_storage() -> anyhow::Result<Arc<dyn ImageStorage>> {
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 {
#[cfg(feature = "postgres")]
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,
},
services::review_history::Trend,
value_objects::{MovieId, UserId},
value_objects::UserId,
};
use crate::{
@@ -178,32 +178,7 @@ pub async fn sync_poster(
_user: AuthenticatedUser,
Path(movie_id): Path<Uuid>,
) -> Result<impl IntoResponse, ApiError> {
let movie = state
.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?;
sync_poster::execute(&state.app_ctx, SyncPosterCommand { movie_id }).await?;
Ok(StatusCode::NO_CONTENT)
}
@@ -438,26 +413,26 @@ pub async fn get_profile(
State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser,
) -> impl IntoResponse {
let user = match state.app_ctx.user_repository.find_by_id(&user_id).await {
Ok(Some(u)) => u,
Ok(None) => return StatusCode::NOT_FOUND.into_response(),
match application::use_cases::get_current_profile::execute(
&state.app_ctx,
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) => {
tracing::error!("get_profile user lookup: {:?}", e);
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
tracing::error!("get_profile error: {:?}", e);
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(
@@ -475,6 +450,7 @@ pub async fn update_profile_handler(
AuthenticatedUser(user_id): AuthenticatedUser,
mut multipart: Multipart,
) -> impl IntoResponse {
let mut display_name: Option<String> = None;
let mut bio: Option<String> = None;
let mut avatar_bytes: Option<Vec<u8>> = 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 {
let name = field.name().unwrap_or("").to_string();
match name.as_str() {
"display_name" => {
if let Ok(text) = field.text().await {
display_name = Some(text).filter(|s| !s.is_empty());
}
}
"bio" => {
if let Ok(text) = field.text().await {
bio = Some(text);
@@ -497,20 +478,20 @@ pub async fn update_profile_handler(
}
"avatar" => {
let ct = field.content_type().map(|s| s.to_string());
if let Ok(bytes) = field.bytes().await {
if !bytes.is_empty() {
avatar_bytes = Some(bytes.to_vec());
avatar_content_type = ct;
}
if let Ok(bytes) = field.bytes().await
&& !bytes.is_empty()
{
avatar_bytes = Some(bytes.to_vec());
avatar_content_type = ct;
}
}
"banner" => {
let ct = field.content_type().map(|s| s.to_string());
if let Ok(bytes) = field.bytes().await {
if !bytes.is_empty() {
banner_bytes = Some(bytes.to_vec());
banner_content_type = ct;
}
if let Ok(bytes) = field.bytes().await
&& !bytes.is_empty()
{
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 {
user_id: user_id.value(),
display_name,
bio,
avatar_bytes,
avatar_content_type,
@@ -1025,7 +1007,8 @@ pub async fn get_activity_feed(
offset: params.offset.unwrap_or(0),
sort_by: domain::ports::FeedSortBy::Date,
search: None,
following: None,
viewer_user_id: None,
filter_following: false,
},
)
.await?;
@@ -1051,9 +1034,10 @@ pub async fn get_activity_feed(
responses((status = 200, body = UsersResponse)),
)]
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 {
users: users
users: result
.users
.iter()
.map(|u| UserSummaryDto {
id: u.user_id.value(),
@@ -1110,6 +1094,7 @@ pub async fn get_user_profile(
offset: params.offset,
sort_by: domain::ports::FeedSortBy::Date,
search: None,
is_own_profile: false,
},
)
.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 {
items: p.items.iter().map(entry_to_dto).collect(),
total_count: p.total_count,
@@ -1185,8 +1156,8 @@ pub async fn get_user_profile(
favorite_director: profile.stats.favorite_director,
most_active_month: profile.stats.most_active_month,
},
following_count,
followers_count,
following_count: profile.following_count,
followers_count: profile.followers_count,
entries,
history,
trends,

View File

@@ -9,33 +9,26 @@ use axum::{
use chrono::Utc;
use uuid::Uuid;
#[cfg(feature = "federation")]
use application::ports::{
BlockedActorEntry, BlockedActorsPageData, BlockedDomainEntry, BlockedDomainsPageData,
FollowersPageData, FollowingPageData,
};
use application::{
commands::{
AddToWatchlistCommand, DeleteReviewCommand, MovieInput, RegisterCommand,
RemoveFromWatchlistCommand,
AddToWatchlistCommand, DeleteReviewCommand, MovieInput, RemoveFromWatchlistCommand,
},
ports::{
HtmlPageContext, LoginPageData, MovieDetailPageData, NewReviewPageData,
ProfileSettingsPageData, RegisterPageData, RemoteActorView, WatchlistDisplayEntry,
WatchlistPageData,
},
queries::{
ExportQuery, GetMovieSocialPageQuery, GetWatchlistQuery, IsOnWatchlistQuery, LoginQuery,
ProfileSettingsPageData, RegisterPageData, RemoteActorView, WatchlistPageData,
},
queries::{ExportQuery, GetMovieSocialPageQuery, IsOnWatchlistQuery, LoginQuery},
use_cases::{
add_to_watchlist, delete_review, export_diary as export_diary_uc, get_movie_social_page,
get_watchlist, is_on_watchlist, log_review, login as login_uc, register as register_uc,
remove_from_watchlist, update_profile, update_profile_fields,
is_on_watchlist, log_review, login as login_uc, remove_from_watchlist, update_profile,
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::{errors::DomainError, value_objects::UserId};
@@ -216,27 +209,21 @@ pub async fn post_register(
if crate::csrf::mismatch(&csrf, &form.csrf_token) {
return StatusCode::FORBIDDEN.into_response();
}
let email = form.email.clone();
let password = form.password.clone();
match register_uc::execute(
match application::use_cases::register_and_login::execute(
&state.app_ctx,
RegisterCommand {
application::commands::RegisterAndLoginCommand {
email: form.email,
username: form.username,
password: form.password,
role: domain::models::UserRole::Standard,
},
)
.await
{
Ok(_) => match login_uc::execute(&state.app_ctx, LoginQuery { email, password }).await {
Ok(result) => {
let max_age = (result.expires_at - Utc::now()).num_seconds().max(0);
let cookie = set_cookie_header(&result.token, max_age);
([cookie], Redirect::to("/")).into_response()
}
Err(_) => Redirect::to("/login").into_response(),
},
Ok(result) => {
let max_age = (result.expires_at - Utc::now()).num_seconds().max(0);
let cookie = set_cookie_header(&result.token, max_age);
([cookie], Redirect::to("/")).into_response()
}
Err(_) => {
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 offset = params.offset.unwrap_or(0);
#[cfg(feature = "federation")]
let filter_str = if params.filter == "following" && user_id.is_some() {
"following"
} else {
"all"
};
#[cfg(not(feature = "federation"))]
let filter_str = "all";
let filter_following =
cfg!(feature = "federation") && params.filter == "following" && user_id.is_some();
let filter_str = if filter_following { "following" } else { "all" };
let sort_by_str = match params.sort_by.as_str() {
"date_asc" => "date_asc",
@@ -385,52 +367,17 @@ pub async fn get_activity_feed(
_ => "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 {
limit,
offset,
sort_by: domain::ports::FeedSortBy::from_str(sort_by_str),
search: search_opt,
following,
sort_by: sort_by_str.parse().unwrap_or_default(),
search: if params.search.is_empty() {
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 {
@@ -467,27 +414,15 @@ pub async fn get_users_list(
ctx.page_title = "Members — Movies Diary".to_string();
ctx.canonical_url = format!("{}/users", state.app_ctx.config.base_url);
#[cfg(feature = "federation")]
let (users_result, actors_result) = tokio::join!(
application::use_cases::get_users::execute(
&state.app_ctx,
application::queries::GetUsersQuery,
),
state.social_query.list_all_followed_remote_actors()
);
#[cfg(not(feature = "federation"))]
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
match application::use_cases::get_users::execute(
&state.app_ctx,
application::queries::GetUsersQuery,
)
.await
{
Ok(result) => {
let actor_views = result
.remote_actors
.into_iter()
.map(|a| application::ports::RemoteActorView {
handle: a.handle,
@@ -498,7 +433,7 @@ pub async fn get_users_list(
.collect();
let data = application::ports::UsersPageData {
ctx,
users,
users: result.users,
remote_actors: actor_views,
};
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.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)
.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 {
user_id: profile_user_uuid,
view: profile_view,
limit: params.limit,
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() {
None
} else {
Some(params.search.clone())
},
is_own_profile,
};
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 {
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 {
ctx,
profile_user_id: profile_user_uuid,
@@ -696,8 +590,8 @@ pub async fn get_user_profile(
trends: profile.trends,
is_own_profile,
error: params.error,
following_count,
followers_count,
following_count: profile.following_count,
followers_count: profile.followers_count,
pending_followers,
sort_by: sort_by_str.to_string(),
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")]
pub async fn get_following_page(
RequiredCookieUser(user_id): RequiredCookieUser,
@@ -1051,94 +1009,33 @@ pub async fn get_watchlist_page(
Extension(csrf): Extension<CsrfToken>,
) -> impl IntoResponse {
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);
// Try local user first
let local_user = state
.app_ctx
.user_repository
.find_by_id(&domain::value_objects::UserId::from_uuid(owner_id))
.await
.ok()
.flatten();
let (display_entries, has_more, current_offset, page_limit) = if local_user.is_some() {
match get_watchlist::execute(
&state.app_ctx,
GetWatchlistQuery {
user_id: owner_id,
limit: Some(limit),
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 result = match application::use_cases::get_watchlist_page::execute(
&state.app_ctx,
application::queries::GetWatchlistQuery {
user_id: owner_id,
limit: params.limit.or(Some(20)),
offset: params.offset.or(Some(0)),
},
is_owner,
)
.await
{
Ok(r) => r,
Err(e) => {
tracing::error!("watchlist error: {:?}", e);
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
}
};
let data = WatchlistPageData {
ctx,
owner_id,
display_entries,
current_offset,
has_more,
limit: page_limit,
display_entries: result.display_entries,
current_offset: result.current_offset,
has_more: result.has_more,
limit: result.limit,
is_owner,
error: params.error,
};
@@ -1503,6 +1400,7 @@ pub async fn post_profile_settings(
State(state): State<AppState>,
mut multipart: Multipart,
) -> impl IntoResponse {
let mut display_name: Option<String> = None;
let mut bio: Option<String> = None;
let mut avatar_bytes: Option<Vec<u8>> = 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 {
let name = field.name().unwrap_or("").to_string();
match name.as_str() {
"display_name" => {
if let Ok(text) = field.text().await {
display_name = Some(text).filter(|s| !s.is_empty());
}
}
"bio" => {
if let Ok(text) = field.text().await {
bio = Some(text);
@@ -1529,38 +1432,36 @@ pub async fn post_profile_settings(
}
"avatar" => {
let ct = field.content_type().map(|s| s.to_string());
if let Ok(bytes) = field.bytes().await {
if !bytes.is_empty() {
avatar_bytes = Some(bytes.to_vec());
avatar_content_type = ct;
}
if let Ok(bytes) = field.bytes().await
&& !bytes.is_empty()
{
avatar_bytes = Some(bytes.to_vec());
avatar_content_type = ct;
}
}
"banner" => {
let ct = field.content_type().map(|s| s.to_string());
if let Ok(bytes) = field.bytes().await {
if !bytes.is_empty() {
banner_bytes = Some(bytes.to_vec());
banner_content_type = ct;
}
if let Ok(bytes) = field.bytes().await
&& !bytes.is_empty()
{
banner_bytes = Some(bytes.to_vec());
banner_content_type = ct;
}
}
n if n.starts_with("field_name_") => {
if let Ok(idx) = n["field_name_".len()..].parse::<usize>() {
if let Ok(text) = field.text().await {
if !text.is_empty() {
field_names.insert(idx, text);
}
}
if let Ok(idx) = n["field_name_".len()..].parse::<usize>()
&& let Ok(text) = field.text().await
&& !text.is_empty()
{
field_names.insert(idx, text);
}
}
n if n.starts_with("field_value_") => {
if let Ok(idx) = n["field_value_".len()..].parse::<usize>() {
if let Ok(text) = field.text().await {
if !text.is_empty() {
field_values.insert(idx, text);
}
}
if let Ok(idx) = n["field_value_".len()..].parse::<usize>()
&& let Ok(text) = field.text().await
&& !text.is_empty()
{
field_values.insert(idx, text);
}
}
_ => {}
@@ -1569,6 +1470,7 @@ pub async fn post_profile_settings(
let cmd = application::commands::UpdateProfileCommand {
user_id: user_id.value(),
display_name,
bio,
avatar_bytes,
avatar_content_type,

View File

@@ -82,17 +82,24 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
#[cfg(feature = "federation")]
let (event_publisher_arc, ap_router, ap_service, social_query, remote_watchlist_repo) = {
let (federation_repo, social_query_arc, review_store, remote_watchlist_repo) =
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 (
activity_repo,
follow_repo,
actor_repo,
blocklist_repo,
social_query_arc,
review_store,
remote_watchlist_repo,
) = 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 {
EventBusBackend::Db => {
@@ -118,16 +125,19 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
}
};
let ap = activitypub::wire(
federation_repo,
let ap = activitypub::wire(activitypub::ActivityPubDeps {
activity_repo,
follow_repo,
actor_repo,
blocklist_repo,
review_store,
remote_watchlist_repo.clone(),
Arc::clone(&ap_content_repo),
Arc::clone(&user_repository),
app_config.base_url.clone(),
app_config.allow_registration,
Arc::clone(&ep),
)
remote_watchlist_repo: remote_watchlist_repo.clone(),
local_ap_content: Arc::clone(&ap_content_repo),
user_repo: Arc::clone(&user_repository),
base_url: app_config.base_url.clone(),
allow_registration: app_config.allow_registration,
event_publisher: Arc::clone(&ep),
})
.await?;
let ap_router = ap.router;
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,
#[cfg(feature = "federation")]
remote_watchlist_repository: remote_watchlist_repo,
#[cfg(feature = "federation")]
social_query: social_query.clone(),
person_command,
person_query,
search_port,

View File

@@ -166,6 +166,14 @@ fn federation_html_routes() -> Router<AppState> {
"/users/{id}/followers/reject",
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(
"/users/{id}/following-list",
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> {
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]
impl StatsRepository for Panic {
@@ -219,10 +231,7 @@ impl UserRepository for Panic {
async fn update_profile(
&self,
_: &UserId,
_: Option<String>,
_: Option<String>,
_: Option<String>,
_: Option<String>,
_: &domain::models::UserProfile,
) -> Result<(), DomainError> {
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 _,
#[cfg(feature = "federation")]
remote_watchlist_repository: Arc::clone(&repo) as _,
#[cfg(feature = "federation")]
social_query: Arc::clone(&repo) as _,
person_command: Arc::clone(&repo) as _,
person_query: Arc::clone(&repo) as _,
search_port: Arc::clone(&repo) as _,

View File

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

View File

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

View File

@@ -10,18 +10,29 @@ pub struct FollowBackfillHandler {
#[async_trait]
impl EventHandler for FollowBackfillHandler {
async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError> {
let DomainEvent::FollowAccepted {
remote_actor_url,
outbox_url,
..
} = event
else {
return Ok(());
};
tracing::info!(actor = %remote_actor_url, outbox = %outbox_url, "starting outbox backfill");
self.ap_service
.backfill_outbox(outbox_url, remote_actor_url)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))
match event {
DomainEvent::FollowAccepted {
remote_actor_url,
outbox_url,
..
} => {
tracing::info!(actor = %remote_actor_url, outbox = %outbox_url, "importing remote outbox");
self.ap_service
.import_remote_outbox(outbox_url, remote_actor_url)
.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.
#[cfg(feature = "federation")]
let (
fed_ap_content,
fed_user_repo,
base_url,
allow_registration,
) = (
let (fed_ap_content, fed_user_repo, base_url, allow_registration) = (
Arc::clone(&repos.ap_content),
Arc::clone(&repos.user),
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.
#[cfg(feature = "federation")]
let (fed_federation_repo, _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 (
fed_activity_repo,
fed_follow_repo,
fed_actor_repo,
fed_blocklist_repo,
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 {
movie_repository: repos.movie,
@@ -89,6 +91,8 @@ async fn main() -> anyhow::Result<()> {
profile_fields_repository: Arc::clone(&profile_fields_repo),
#[cfg(feature = "federation")]
remote_watchlist_repository: fed_remote_watchlist_repo.clone(),
#[cfg(feature = "federation")]
social_query: fed_social_query,
person_command: Arc::clone(&person_command),
person_query: Arc::clone(&person_query),
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.
// Without a key, no MovieEnrichmentRequested events are produced or handled.
let (enrichment_handler, enrichment_job): (
Option<Arc<dyn EventHandler>>,
Option<Arc<dyn PeriodicJob>>,
) = match tmdb_enrichment::TmdbEnrichmentClient::from_env() {
Ok(client) => {
tracing::info!("TMDb enrichment enabled");
let handler = Arc::new(tmdb_enrichment::EnrichmentHandler {
enrichment_client: Arc::new(client),
movie_repository: Arc::clone(&ctx.movie_repository),
profile_repo: Arc::clone(&ctx.movie_profile_repository),
person_command: Arc::clone(&ctx.person_command),
search_command: Arc::clone(&ctx.search_command),
}) as Arc<dyn EventHandler>;
let job = Arc::new(application::jobs::EnrichmentStalenessJob::new(ctx.clone()))
as Arc<dyn PeriodicJob>;
(Some(handler), Some(job))
}
Err(e) => {
tracing::warn!("TMDb enrichment disabled: {e}");
(None, None)
}
};
type OptionalPair = (Option<Arc<dyn EventHandler>>, Option<Arc<dyn PeriodicJob>>);
let (enrichment_handler, enrichment_job): OptionalPair =
match tmdb_enrichment::TmdbEnrichmentClient::from_env() {
Ok(client) => {
tracing::info!("TMDb enrichment enabled");
let handler = Arc::new(tmdb_enrichment::EnrichmentHandler {
enrichment_client: Arc::new(client),
movie_repository: Arc::clone(&ctx.movie_repository),
profile_repo: Arc::clone(&ctx.movie_profile_repository),
person_command: Arc::clone(&ctx.person_command),
search_command: Arc::clone(&ctx.search_command),
}) as Arc<dyn EventHandler>;
let job = Arc::new(application::jobs::EnrichmentStalenessJob::new(ctx.clone()))
as Arc<dyn PeriodicJob>;
(Some(handler), Some(job))
}
Err(e) => {
tracing::warn!("TMDb enrichment disabled: {e}");
(None, None)
}
};
// ── Image conversion ──────────────────────────────────────────────────────
@@ -194,16 +197,19 @@ async fn main() -> anyhow::Result<()> {
#[cfg(feature = "federation")]
{
let ap_wire = activitypub::wire(
fed_federation_repo,
fed_review_store,
fed_remote_watchlist_repo,
fed_ap_content,
fed_user_repo,
let ap_wire = activitypub::wire(activitypub::ActivityPubDeps {
activity_repo: fed_activity_repo,
follow_repo: fed_follow_repo,
actor_repo: fed_actor_repo,
blocklist_repo: fed_blocklist_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,
allow_registration,
Arc::clone(&ctx.event_publisher),
)
event_publisher: Arc::clone(&ctx.event_publisher),
})
.await?;
let ap_event_handler = ap_wire.event_handler;