Compare commits
6 Commits
bace54c552
...
c3b89f6dc6
| Author | SHA1 | Date | |
|---|---|---|---|
| c3b89f6dc6 | |||
| 2355f89bed | |||
| 68a939f6c4 | |||
| 412ab12695 | |||
| 36d15e1344 | |||
| 624cfe5799 |
22
Cargo.lock
generated
22
Cargo.lock
generated
@@ -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
28
Makefile
Normal 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
|
||||
@@ -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 }
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
38
crates/adapters/activitypub/src/federation_event_bridge.rs
Normal file
38
crates/adapters/activitypub/src/federation_event_bridge.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,13 +92,23 @@ 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,
|
||||
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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"]);
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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}")
|
||||
}
|
||||
|
||||
@@ -12,11 +12,11 @@ sqlx = { version = "0.8.6", features = [
|
||||
"chrono",
|
||||
] }
|
||||
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 }
|
||||
uuid = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
|
||||
serde_json = { workspace = true }
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
ORDER BY f.created_at ASC LIMIT $2 OFFSET $3"
|
||||
);
|
||||
let rows = sqlx::query(&q)
|
||||
.bind(&uid)
|
||||
.bind(limit_i64)
|
||||
.bind(offset_i64)
|
||||
.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",
|
||||
)
|
||||
ORDER BY f.created_at ASC LIMIT $2 OFFSET $3"
|
||||
);
|
||||
let rows = sqlx::query(&q)
|
||||
.bind(&uid)
|
||||
.bind(limit_i64)
|
||||
.bind(offset_i64)
|
||||
.bind(limit as i64)
|
||||
.bind(offset as 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())
|
||||
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)
|
||||
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.flatten())
|
||||
Ok(row.as_ref().map(|r| pg_remote_actor(r, "url")))
|
||||
}
|
||||
|
||||
async fn add_announce(
|
||||
@@ -510,13 +495,15 @@ 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",
|
||||
)
|
||||
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(object_url)
|
||||
.bind(actor_url)
|
||||
.bind(&ts)
|
||||
.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)
|
||||
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_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 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 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 _,
|
||||
|
||||
12
crates/adapters/postgres/migrations/0020_kap_v03.sql
Normal file
12
crates/adapters/postgres/migrations/0020_kap_v03.sql
Normal 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()
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS display_name TEXT;
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
use sqlx::PgPool;
|
||||
use sqlx::{PgPool, Row};
|
||||
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
@@ -33,115 +33,66 @@ 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,
|
||||
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![],
|
||||
)
|
||||
})
|
||||
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![],
|
||||
)
|
||||
})
|
||||
row.as_ref()
|
||||
.map(|r| Self::row_to_user(r, vec![]))
|
||||
.transpose()
|
||||
}
|
||||
|
||||
@@ -185,21 +136,7 @@ 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",
|
||||
)
|
||||
let row = sqlx::query(&format!("SELECT {PG_USER_COLS} FROM users WHERE id = $1"))
|
||||
.bind(&id_str)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
@@ -207,12 +144,7 @@ impl UserRepository for PostgresUserRepository {
|
||||
|
||||
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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)
|
||||
#[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?;
|
||||
|
||||
if candidates.is_empty() {
|
||||
return Ok(vec![]);
|
||||
Ok(count > 0)
|
||||
}
|
||||
|
||||
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)
|
||||
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?;
|
||||
|
||||
candidates
|
||||
.into_iter()
|
||||
.map(|s| uuid::Uuid::parse_str(&s).map_err(|e| anyhow::anyhow!(e)))
|
||||
.collect()
|
||||
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
|
||||
|
||||
12
crates/adapters/sqlite/migrations/0020_kap_v03.sql
Normal file
12
crates/adapters/sqlite/migrations/0020_kap_v03.sql
Normal 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
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE users ADD COLUMN display_name TEXT;
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
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
|
||||
)
|
||||
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![],
|
||||
)
|
||||
})
|
||||
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
|
||||
)
|
||||
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![],
|
||||
)
|
||||
})
|
||||
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
|
||||
)
|
||||
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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -117,12 +117,11 @@ 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?
|
||||
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)))
|
||||
}
|
||||
Err(e) => {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ impl EventHandler for RecordingHandler {
|
||||
"watchlist"
|
||||
}
|
||||
DomainEvent::FollowAccepted { .. } => "follow_accepted",
|
||||
DomainEvent::BackfillFollower { .. } => "backfill_follower",
|
||||
};
|
||||
self.calls.lock().unwrap().push(label);
|
||||
Ok(())
|
||||
|
||||
@@ -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::{
|
||||
|
||||
@@ -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,7 +84,13 @@ 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())
|
||||
Review::new(
|
||||
movie_id,
|
||||
user_id,
|
||||
Rating::new(4).unwrap(),
|
||||
None,
|
||||
Utc::now().naive_utc(),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
31
crates/application/src/use_cases/get_current_profile.rs
Normal file
31
crates/application/src/use_cases/get_current_profile.rs
Normal 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,
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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?,
|
||||
})
|
||||
}
|
||||
|
||||
95
crates/application/src/use_cases/get_watchlist_page.rs
Normal file
95
crates/application/src/use_cases/get_watchlist_page.rs
Normal 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,
|
||||
})
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
32
crates/application/src/use_cases/register_and_login.rs
Normal file
32
crates/application/src/use_cases/register_and_login.rs
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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?;
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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| {
|
||||
Ok(store
|
||||
.values()
|
||||
.find(|m| {
|
||||
m.external_metadata_id()
|
||||
.map(|e| e.value() == external_metadata_id.value())
|
||||
.unwrap_or(false)
|
||||
}).cloned())
|
||||
})
|
||||
.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![])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())),
|
||||
|
||||
@@ -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(),
|
||||
Err(e) => {
|
||||
tracing::error!("get_profile user lookup: {:?}", e);
|
||||
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let base_url = &state.app_ctx.config.base_url;
|
||||
let avatar_url = user
|
||||
.avatar_path()
|
||||
.map(|path| format!("{}/images/{}", base_url, path));
|
||||
|
||||
Json(ProfileResponse {
|
||||
username: user.username().value().to_string(),
|
||||
bio: user.bio().map(|s| s.to_string()),
|
||||
avatar_url,
|
||||
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()
|
||||
.into_response(),
|
||||
Err(DomainError::NotFound(_)) => StatusCode::NOT_FOUND.into_response(),
|
||||
Err(e) => {
|
||||
tracing::error!("get_profile error: {:?}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR.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,28 +478,29 @@ 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() {
|
||||
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() {
|
||||
if let Ok(bytes) = field.bytes().await
|
||||
&& !bytes.is_empty()
|
||||
{
|
||||
banner_bytes = Some(bytes.to_vec());
|
||||
banner_content_type = ct;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
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(
|
||||
match 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
|
||||
.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(
|
||||
let result = match application::use_cases::get_watchlist_page::execute(
|
||||
&state.app_ctx,
|
||||
GetWatchlistQuery {
|
||||
application::queries::GetWatchlistQuery {
|
||||
user_id: owner_id,
|
||||
limit: Some(limit),
|
||||
offset: Some(offset),
|
||||
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();
|
||||
}
|
||||
Ok(entries) => {
|
||||
let has_more = entries.offset + entries.limit < entries.total_count as u32;
|
||||
let display: Vec<WatchlistDisplayEntry> = entries
|
||||
.items
|
||||
.iter()
|
||||
.map(|w| {
|
||||
let remove_url = if is_owner {
|
||||
Some(format!("/watchlist/{}/remove", w.movie.id().value()))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
WatchlistDisplayEntry {
|
||||
poster_url: w
|
||||
.movie
|
||||
.poster_path()
|
||||
.map(|p| format!("/images/{}", p.value())),
|
||||
movie_title: w.movie.title().value().to_string(),
|
||||
release_year: w.movie.release_year().value(),
|
||||
movie_url: Some(format!("/movies/{}", w.movie.id().value())),
|
||||
added_at: w.entry.added_at.format("%b %-d, %Y").to_string(),
|
||||
remove_url,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
(display, has_more, entries.offset, entries.limit)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
#[cfg(feature = "federation")]
|
||||
{
|
||||
let remote_entries = get_remote_watchlist::execute(&state.app_ctx, owner_id)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let display: Vec<WatchlistDisplayEntry> = remote_entries
|
||||
.into_iter()
|
||||
.map(|e| WatchlistDisplayEntry {
|
||||
poster_url: e.poster_url,
|
||||
movie_title: e.movie_title,
|
||||
release_year: e.release_year,
|
||||
movie_url: None,
|
||||
added_at: e.added_at.format("%b %-d, %Y").to_string(),
|
||||
remove_url: None,
|
||||
})
|
||||
.collect();
|
||||
let len = display.len() as u32;
|
||||
(display, false, 0u32, len)
|
||||
}
|
||||
#[cfg(not(feature = "federation"))]
|
||||
{
|
||||
(vec![], false, 0u32, 0u32)
|
||||
}
|
||||
};
|
||||
|
||||
let data = WatchlistPageData {
|
||||
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,46 +1432,45 @@ 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() {
|
||||
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() {
|
||||
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() {
|
||||
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() {
|
||||
if let Ok(idx) = n["field_value_".len()..].parse::<usize>()
|
||||
&& let Ok(text) = field.text().await
|
||||
&& !text.is_empty()
|
||||
{
|
||||
field_values.insert(idx, text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let cmd = application::commands::UpdateProfileCommand {
|
||||
user_id: user_id.value(),
|
||||
display_name,
|
||||
bio,
|
||||
avatar_bytes,
|
||||
avatar_content_type,
|
||||
|
||||
@@ -82,8 +82,15 @@ 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 {
|
||||
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")]
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 _,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
#![allow(clippy::large_enum_variant)]
|
||||
pub mod app;
|
||||
pub mod client;
|
||||
pub mod config;
|
||||
|
||||
@@ -10,18 +10,29 @@ pub struct FollowBackfillHandler {
|
||||
#[async_trait]
|
||||
impl EventHandler for FollowBackfillHandler {
|
||||
async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError> {
|
||||
let DomainEvent::FollowAccepted {
|
||||
match event {
|
||||
DomainEvent::FollowAccepted {
|
||||
remote_actor_url,
|
||||
outbox_url,
|
||||
..
|
||||
} = event
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
tracing::info!(actor = %remote_actor_url, outbox = %outbox_url, "starting outbox backfill");
|
||||
} => {
|
||||
tracing::info!(actor = %remote_actor_url, outbox = %outbox_url, "importing remote outbox");
|
||||
self.ap_service
|
||||
.backfill_outbox(outbox_url, remote_actor_url)
|
||||
.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(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,8 +55,15 @@ 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 {
|
||||
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")]
|
||||
@@ -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,10 +104,9 @@ 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() {
|
||||
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 {
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user