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]]
|
[[package]]
|
||||||
name = "k-ap"
|
name = "k-ap"
|
||||||
version = "0.1.10"
|
version = "0.3.1"
|
||||||
source = "git+https://git.gabrielkaszewski.dev/GKaszewski/k-ap.git?tag=v0.1.10#d80cfd0431205498161db8665fd884710866ca95"
|
source = "sparse+https://git.gabrielkaszewski.dev/api/packages/GKaszewski/cargo/"
|
||||||
|
checksum = "f73de37ac4feab6d7b78e73c60acbb07933c2be58dcbb12e8a34201f66e0480d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"activitypub_federation",
|
"activitypub_federation",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
@@ -2787,6 +2788,7 @@ dependencies = [
|
|||||||
"tracing",
|
"tracing",
|
||||||
"url",
|
"url",
|
||||||
"uuid",
|
"uuid",
|
||||||
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3749,6 +3751,7 @@ dependencies = [
|
|||||||
"chrono",
|
"chrono",
|
||||||
"domain",
|
"domain",
|
||||||
"k-ap",
|
"k-ap",
|
||||||
|
"serde_json",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
"tracing",
|
"tracing",
|
||||||
"uuid",
|
"uuid",
|
||||||
@@ -5014,6 +5017,7 @@ dependencies = [
|
|||||||
"chrono",
|
"chrono",
|
||||||
"domain",
|
"domain",
|
||||||
"k-ap",
|
"k-ap",
|
||||||
|
"serde_json",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
@@ -7060,6 +7064,20 @@ name = "zeroize"
|
|||||||
version = "1.8.2"
|
version = "1.8.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
|
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
|
||||||
|
dependencies = [
|
||||||
|
"zeroize_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zeroize_derive"
|
||||||
|
version = "1.4.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.117",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerotrie"
|
name = "zerotrie"
|
||||||
|
|||||||
28
Makefile
Normal file
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"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
k-ap = { git = "https://git.gabrielkaszewski.dev/GKaszewski/k-ap.git", tag = "v0.1.10" }
|
k-ap = { version = "0.3.1", registry = "gitea" }
|
||||||
domain = { workspace = true }
|
domain = { workspace = true }
|
||||||
axum = { workspace = true }
|
axum = { workspace = true }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use k_ap::ApObjectHandler;
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
|
use k_ap::{ApContentReader, ApObjectHandler};
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
use crate::{review_handler::ReviewObjectHandler, watchlist_handler::WatchlistObjectHandler};
|
use crate::{review_handler::ReviewObjectHandler, watchlist_handler::WatchlistObjectHandler};
|
||||||
@@ -13,16 +13,7 @@ pub struct CompositeObjectHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl ApObjectHandler for CompositeObjectHandler {
|
impl ApContentReader for CompositeObjectHandler {
|
||||||
async fn get_local_objects_for_user(
|
|
||||||
&self,
|
|
||||||
user_id: uuid::Uuid,
|
|
||||||
) -> anyhow::Result<Vec<(Url, serde_json::Value)>> {
|
|
||||||
let mut results = self.review.get_local_objects_for_user(user_id).await?;
|
|
||||||
results.extend(self.watchlist.get_local_objects_for_user(user_id).await?);
|
|
||||||
Ok(results)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn get_local_objects_page(
|
async fn get_local_objects_page(
|
||||||
&self,
|
&self,
|
||||||
user_id: uuid::Uuid,
|
user_id: uuid::Uuid,
|
||||||
@@ -34,6 +25,13 @@ impl ApObjectHandler for CompositeObjectHandler {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn count_local_posts(&self) -> anyhow::Result<u64> {
|
||||||
|
self.review.count_local_posts().await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl ApObjectHandler for CompositeObjectHandler {
|
||||||
async fn on_create(
|
async fn on_create(
|
||||||
&self,
|
&self,
|
||||||
ap_id: &Url,
|
ap_id: &Url,
|
||||||
@@ -77,15 +75,23 @@ impl ApObjectHandler for CompositeObjectHandler {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn count_local_posts(&self) -> anyhow::Result<u64> {
|
|
||||||
self.review.count_local_posts().await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn on_like(&self, _object_url: &Url, _actor_url: &Url) -> anyhow::Result<()> {
|
async fn on_like(&self, _object_url: &Url, _actor_url: &Url) -> anyhow::Result<()> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn on_announce_received(&self, _object_url: &Url, _actor_url: &Url) -> anyhow::Result<()> {
|
async fn on_announce_received(
|
||||||
|
&self,
|
||||||
|
_object_url: &Url,
|
||||||
|
_actor_url: &Url,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn on_announce_of_remote(
|
||||||
|
&self,
|
||||||
|
_object_url: &Url,
|
||||||
|
_actor_url: &Url,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,7 +99,12 @@ impl ApObjectHandler for CompositeObjectHandler {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn on_mention(&self, _thought_ap_id: &Url, _mentioned_user_uuid: uuid::Uuid, _actor_url: &Url) -> anyhow::Result<()> {
|
async fn on_mention(
|
||||||
|
&self,
|
||||||
|
_thought_ap_id: &Url,
|
||||||
|
_mentioned_user_uuid: uuid::Uuid,
|
||||||
|
_actor_url: &Url,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use domain::{
|
|||||||
};
|
};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use k_ap::ActivityPubService;
|
use k_ap::{ActivityPubService, ApVisibility};
|
||||||
|
|
||||||
use crate::objects::review_to_ap_object;
|
use crate::objects::review_to_ap_object;
|
||||||
use crate::urls::{actor_url, review_url};
|
use crate::urls::{actor_url, review_url};
|
||||||
@@ -105,7 +105,10 @@ impl ActivityPubEventHandler {
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|m| m.title().value().to_string())
|
.map(|m| m.title().value().to_string())
|
||||||
.unwrap_or_else(|| "Unknown".to_string());
|
.unwrap_or_else(|| "Unknown".to_string());
|
||||||
let release_year = movie.as_ref().map(|m| m.release_year().value()).unwrap_or(0);
|
let release_year = movie
|
||||||
|
.as_ref()
|
||||||
|
.map(|m| m.release_year().value())
|
||||||
|
.unwrap_or(0);
|
||||||
let poster_url = movie
|
let poster_url = movie
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|m| m.poster_path())
|
.and_then(|m| m.poster_path())
|
||||||
@@ -123,7 +126,7 @@ impl ActivityPubEventHandler {
|
|||||||
let json = serde_json::to_value(obj)?;
|
let json = serde_json::to_value(obj)?;
|
||||||
|
|
||||||
self.ap_service
|
self.ap_service
|
||||||
.broadcast_add_to_followers(user_id.value(), ap_id, json)
|
.broadcast_create_note(user_id.value(), json, ApVisibility::Public, vec![])
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -152,7 +155,10 @@ impl ActivityPubEventHandler {
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|m| m.title().value().to_string())
|
.map(|m| m.title().value().to_string())
|
||||||
.unwrap_or_else(|| "Unknown".to_string());
|
.unwrap_or_else(|| "Unknown".to_string());
|
||||||
let release_year = movie.as_ref().map(|m| m.release_year().value()).unwrap_or(0);
|
let release_year = movie
|
||||||
|
.as_ref()
|
||||||
|
.map(|m| m.release_year().value())
|
||||||
|
.unwrap_or(0);
|
||||||
let poster_url = movie
|
let poster_url = movie
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|m| m.poster_path())
|
.and_then(|m| m.poster_path())
|
||||||
@@ -170,7 +176,7 @@ impl ActivityPubEventHandler {
|
|||||||
let json = serde_json::to_value(obj)?;
|
let json = serde_json::to_value(obj)?;
|
||||||
|
|
||||||
self.ap_service
|
self.ap_service
|
||||||
.broadcast_update_note(user_id.value(), json)
|
.broadcast_update_note(user_id.value(), json, ApVisibility::Public, vec![])
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -214,20 +220,20 @@ impl ActivityPubEventHandler {
|
|||||||
|
|
||||||
let added_at_utc =
|
let added_at_utc =
|
||||||
chrono::DateTime::<chrono::Utc>::from_naive_utc_and_offset(*added_at, chrono::Utc);
|
chrono::DateTime::<chrono::Utc>::from_naive_utc_and_offset(*added_at, chrono::Utc);
|
||||||
let obj = crate::objects::watchlist_to_ap_object(
|
let obj = crate::objects::watchlist_to_ap_object(crate::objects::WatchlistApInput {
|
||||||
ap_id.clone(),
|
ap_id: ap_id.clone(),
|
||||||
actor,
|
actor_url: actor,
|
||||||
movie_title.to_string(),
|
movie_title: movie_title.to_string(),
|
||||||
release_year,
|
release_year,
|
||||||
external_metadata_id.clone(),
|
external_metadata_id: external_metadata_id.clone(),
|
||||||
poster_url,
|
poster_url,
|
||||||
added_at_utc,
|
added_at: added_at_utc,
|
||||||
&self.base_url,
|
base_url: self.base_url.clone(),
|
||||||
);
|
});
|
||||||
let json = serde_json::to_value(obj)?;
|
let json = serde_json::to_value(obj)?;
|
||||||
|
|
||||||
self.ap_service
|
self.ap_service
|
||||||
.broadcast_create_note(user_id.value(), json)
|
.broadcast_create_note(user_id.value(), json, ApVisibility::Public, vec![])
|
||||||
.await?;
|
.await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
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 composite_handler;
|
||||||
pub mod event_handler;
|
pub mod event_handler;
|
||||||
|
pub mod federation_event_bridge;
|
||||||
pub mod objects;
|
pub mod objects;
|
||||||
pub mod port;
|
pub mod port;
|
||||||
pub mod remote_review_repository;
|
pub mod remote_review_repository;
|
||||||
@@ -10,8 +11,9 @@ pub mod watchlist_handler;
|
|||||||
|
|
||||||
// Re-export the generic base types that callers need
|
// Re-export the generic base types that callers need
|
||||||
pub use k_ap::{
|
pub use k_ap::{
|
||||||
ActivityPubService, ApFederationConfig, ApObjectHandler, ApUser, ApUserRepository,
|
ActivityPubService, ActivityRepository, ActorRepository, ApContentReader, ApFederationConfig,
|
||||||
FederationData, FederationRepository, Follower, FollowerStatus, FollowingStatus, RemoteActor,
|
ApObjectHandler, ApUser, ApUserRepository, BlocklistRepository, FederationData,
|
||||||
|
FollowRepository, Follower, FollowerStatus, FollowingStatus, RemoteActor,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub use event_handler::ActivityPubEventHandler;
|
pub use event_handler::ActivityPubEventHandler;
|
||||||
@@ -20,22 +22,50 @@ pub use remote_review_repository::RemoteReviewRepository;
|
|||||||
pub use review_handler::ReviewObjectHandler;
|
pub use review_handler::ReviewObjectHandler;
|
||||||
pub use user_adapter::DomainUserRepoAdapter;
|
pub use user_adapter::DomainUserRepoAdapter;
|
||||||
|
|
||||||
|
pub type FederationRepos = (
|
||||||
|
std::sync::Arc<dyn ActivityRepository>,
|
||||||
|
std::sync::Arc<dyn FollowRepository>,
|
||||||
|
std::sync::Arc<dyn ActorRepository>,
|
||||||
|
std::sync::Arc<dyn BlocklistRepository>,
|
||||||
|
std::sync::Arc<dyn domain::ports::SocialQueryPort>,
|
||||||
|
std::sync::Arc<dyn RemoteReviewRepository>,
|
||||||
|
std::sync::Arc<dyn domain::ports::RemoteWatchlistRepository>,
|
||||||
|
);
|
||||||
|
|
||||||
pub struct ActivityPubWire {
|
pub struct ActivityPubWire {
|
||||||
pub service: std::sync::Arc<dyn ActivityPubPort>,
|
pub service: std::sync::Arc<dyn ActivityPubPort>,
|
||||||
pub router: axum::Router,
|
pub router: axum::Router,
|
||||||
pub event_handler: std::sync::Arc<dyn domain::ports::EventHandler>,
|
pub event_handler: std::sync::Arc<dyn domain::ports::EventHandler>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn wire(
|
pub struct ActivityPubDeps {
|
||||||
federation_repo: std::sync::Arc<dyn FederationRepository>,
|
pub activity_repo: std::sync::Arc<dyn ActivityRepository>,
|
||||||
review_store: std::sync::Arc<dyn RemoteReviewRepository>,
|
pub follow_repo: std::sync::Arc<dyn FollowRepository>,
|
||||||
remote_watchlist_repo: std::sync::Arc<dyn domain::ports::RemoteWatchlistRepository>,
|
pub actor_repo: std::sync::Arc<dyn ActorRepository>,
|
||||||
local_ap_content: std::sync::Arc<dyn domain::ports::LocalApContentQuery>,
|
pub blocklist_repo: std::sync::Arc<dyn BlocklistRepository>,
|
||||||
user_repo: std::sync::Arc<dyn domain::ports::UserRepository>,
|
pub review_store: std::sync::Arc<dyn RemoteReviewRepository>,
|
||||||
base_url: String,
|
pub remote_watchlist_repo: std::sync::Arc<dyn domain::ports::RemoteWatchlistRepository>,
|
||||||
allow_registration: bool,
|
pub local_ap_content: std::sync::Arc<dyn domain::ports::LocalApContentQuery>,
|
||||||
_event_publisher: std::sync::Arc<dyn domain::ports::EventPublisher>,
|
pub user_repo: std::sync::Arc<dyn domain::ports::UserRepository>,
|
||||||
) -> anyhow::Result<ActivityPubWire> {
|
pub base_url: String,
|
||||||
|
pub allow_registration: bool,
|
||||||
|
pub event_publisher: std::sync::Arc<dyn domain::ports::EventPublisher>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn wire(deps: ActivityPubDeps) -> anyhow::Result<ActivityPubWire> {
|
||||||
|
let ActivityPubDeps {
|
||||||
|
activity_repo,
|
||||||
|
follow_repo,
|
||||||
|
actor_repo,
|
||||||
|
blocklist_repo,
|
||||||
|
review_store,
|
||||||
|
remote_watchlist_repo,
|
||||||
|
local_ap_content,
|
||||||
|
user_repo,
|
||||||
|
base_url,
|
||||||
|
allow_registration,
|
||||||
|
event_publisher,
|
||||||
|
} = deps;
|
||||||
let review_handler = std::sync::Arc::new(ReviewObjectHandler {
|
let review_handler = std::sync::Arc::new(ReviewObjectHandler {
|
||||||
content_query: std::sync::Arc::clone(&local_ap_content),
|
content_query: std::sync::Arc::clone(&local_ap_content),
|
||||||
review_store,
|
review_store,
|
||||||
@@ -62,18 +92,28 @@ pub async fn wire(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let fed_event_bridge = std::sync::Arc::new(
|
||||||
|
federation_event_bridge::FederationEventBridge::new(event_publisher),
|
||||||
|
);
|
||||||
|
|
||||||
let concrete = std::sync::Arc::new(
|
let concrete = std::sync::Arc::new(
|
||||||
ActivityPubService::builder(
|
ActivityPubService::builder(base_url.clone())
|
||||||
federation_repo,
|
.activity_repo(activity_repo)
|
||||||
std::sync::Arc::new(DomainUserRepoAdapter::new(user_repo, base_url.clone())),
|
.follow_repo(follow_repo)
|
||||||
composite,
|
.actor_repo(actor_repo)
|
||||||
base_url.clone(),
|
.blocklist_repo(blocklist_repo)
|
||||||
)
|
.user_repo(std::sync::Arc::new(DomainUserRepoAdapter::new(
|
||||||
.allow_registration(allow_registration)
|
user_repo,
|
||||||
.software_name("movies-diary")
|
base_url.clone(),
|
||||||
.debug(federation_debug)
|
)))
|
||||||
.build()
|
.content_reader(composite.clone() as std::sync::Arc<dyn ApContentReader>)
|
||||||
.await?,
|
.object_handler(composite as std::sync::Arc<dyn ApObjectHandler>)
|
||||||
|
.event_publisher(fed_event_bridge)
|
||||||
|
.allow_registration(allow_registration)
|
||||||
|
.software_name("movies-diary")
|
||||||
|
.debug(federation_debug)
|
||||||
|
.build()
|
||||||
|
.await?,
|
||||||
);
|
);
|
||||||
|
|
||||||
let router = concrete.router();
|
let router = concrete.router();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
|
use chrono::{DateTime, Utc};
|
||||||
use k_ap::AS_PUBLIC;
|
use k_ap::AS_PUBLIC;
|
||||||
use k_ap::NoteType;
|
use k_ap::NoteType;
|
||||||
use chrono::{DateTime, Utc};
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
@@ -131,16 +131,28 @@ pub struct WatchlistObject {
|
|||||||
pub(crate) cc: Vec<String>,
|
pub(crate) cc: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn watchlist_to_ap_object(
|
pub struct WatchlistApInput {
|
||||||
ap_id: Url,
|
pub ap_id: Url,
|
||||||
actor_url: Url,
|
pub actor_url: Url,
|
||||||
movie_title: String,
|
pub movie_title: String,
|
||||||
release_year: u16,
|
pub release_year: u16,
|
||||||
external_metadata_id: Option<String>,
|
pub external_metadata_id: Option<String>,
|
||||||
poster_url: Option<String>,
|
pub poster_url: Option<String>,
|
||||||
added_at: chrono::DateTime<chrono::Utc>,
|
pub added_at: chrono::DateTime<chrono::Utc>,
|
||||||
base_url: &str,
|
pub base_url: String,
|
||||||
) -> WatchlistObject {
|
}
|
||||||
|
|
||||||
|
pub fn watchlist_to_ap_object(input: WatchlistApInput) -> WatchlistObject {
|
||||||
|
let WatchlistApInput {
|
||||||
|
ap_id,
|
||||||
|
actor_url,
|
||||||
|
movie_title,
|
||||||
|
release_year,
|
||||||
|
external_metadata_id,
|
||||||
|
poster_url,
|
||||||
|
added_at,
|
||||||
|
base_url,
|
||||||
|
} = input;
|
||||||
let year_str = if release_year > 0 {
|
let year_str = if release_year > 0 {
|
||||||
format!(" ({})", release_year)
|
format!(" ({})", release_year)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -31,7 +31,22 @@ pub trait ActivityPubPort: Send + Sync {
|
|||||||
async fn add_blocked_domain(&self, domain: &str, reason: Option<&str>) -> anyhow::Result<()>;
|
async fn add_blocked_domain(&self, domain: &str, reason: Option<&str>) -> anyhow::Result<()>;
|
||||||
async fn remove_blocked_domain(&self, domain: &str) -> anyhow::Result<()>;
|
async fn remove_blocked_domain(&self, domain: &str) -> anyhow::Result<()>;
|
||||||
async fn get_blocked_domains(&self) -> anyhow::Result<Vec<BlockedDomain>>;
|
async fn get_blocked_domains(&self) -> anyhow::Result<Vec<BlockedDomain>>;
|
||||||
async fn backfill_outbox(&self, outbox_url: &str, actor_url: &str) -> anyhow::Result<()>;
|
async fn import_remote_outbox(&self, outbox_url: &str, actor_url: &str) -> anyhow::Result<()>;
|
||||||
|
async fn followers_collection_json(
|
||||||
|
&self,
|
||||||
|
user_id: Uuid,
|
||||||
|
page: Option<u32>,
|
||||||
|
) -> anyhow::Result<String>;
|
||||||
|
async fn following_collection_json(
|
||||||
|
&self,
|
||||||
|
user_id: Uuid,
|
||||||
|
page: Option<u32>,
|
||||||
|
) -> anyhow::Result<String>;
|
||||||
|
async fn run_backfill_for_follower(
|
||||||
|
&self,
|
||||||
|
owner_user_id: Uuid,
|
||||||
|
follower_inbox_url: String,
|
||||||
|
) -> anyhow::Result<()>;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -98,8 +113,30 @@ impl ActivityPubPort for ActivityPubService {
|
|||||||
async fn get_blocked_domains(&self) -> anyhow::Result<Vec<BlockedDomain>> {
|
async fn get_blocked_domains(&self) -> anyhow::Result<Vec<BlockedDomain>> {
|
||||||
self.get_blocked_domains().await
|
self.get_blocked_domains().await
|
||||||
}
|
}
|
||||||
async fn backfill_outbox(&self, outbox_url: &str, actor_url: &str) -> anyhow::Result<()> {
|
async fn import_remote_outbox(&self, outbox_url: &str, actor_url: &str) -> anyhow::Result<()> {
|
||||||
self.backfill_outbox(outbox_url, actor_url).await
|
self.import_remote_outbox(outbox_url, actor_url).await
|
||||||
|
}
|
||||||
|
async fn followers_collection_json(
|
||||||
|
&self,
|
||||||
|
user_id: Uuid,
|
||||||
|
page: Option<u32>,
|
||||||
|
) -> anyhow::Result<String> {
|
||||||
|
self.followers_collection_json(user_id, page).await
|
||||||
|
}
|
||||||
|
async fn following_collection_json(
|
||||||
|
&self,
|
||||||
|
user_id: Uuid,
|
||||||
|
page: Option<u32>,
|
||||||
|
) -> anyhow::Result<String> {
|
||||||
|
self.following_collection_json(user_id, page).await
|
||||||
|
}
|
||||||
|
async fn run_backfill_for_follower(
|
||||||
|
&self,
|
||||||
|
owner_user_id: Uuid,
|
||||||
|
follower_inbox_url: String,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
self.run_backfill_for_follower(owner_user_id, follower_inbox_url)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,7 +195,16 @@ impl ActivityPubPort for NoopActivityPubService {
|
|||||||
async fn get_blocked_domains(&self) -> anyhow::Result<Vec<BlockedDomain>> {
|
async fn get_blocked_domains(&self) -> anyhow::Result<Vec<BlockedDomain>> {
|
||||||
Ok(vec![])
|
Ok(vec![])
|
||||||
}
|
}
|
||||||
async fn backfill_outbox(&self, _: &str, _: &str) -> anyhow::Result<()> {
|
async fn import_remote_outbox(&self, _: &str, _: &str) -> anyhow::Result<()> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
async fn followers_collection_json(&self, _: Uuid, _: Option<u32>) -> anyhow::Result<String> {
|
||||||
|
Ok(String::new())
|
||||||
|
}
|
||||||
|
async fn following_collection_json(&self, _: Uuid, _: Option<u32>) -> anyhow::Result<String> {
|
||||||
|
Ok(String::new())
|
||||||
|
}
|
||||||
|
async fn run_backfill_for_follower(&self, _: Uuid, _: String) -> anyhow::Result<()> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use k_ap::ApObjectHandler;
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use domain::{
|
use domain::{
|
||||||
models::ReviewSource,
|
models::ReviewSource,
|
||||||
ports::LocalApContentQuery,
|
ports::LocalApContentQuery,
|
||||||
value_objects::{Comment, MovieId, Rating, ReviewId, UserId},
|
value_objects::{Comment, MovieId, Rating, ReviewId, UserId},
|
||||||
};
|
};
|
||||||
|
use k_ap::{ApContentReader, ApObjectHandler};
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
use crate::objects::{ReviewObject, review_to_ap_object};
|
use crate::objects::{ReviewObject, review_to_ap_object};
|
||||||
@@ -20,43 +20,7 @@ pub struct ReviewObjectHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl ApObjectHandler for ReviewObjectHandler {
|
impl ApContentReader for ReviewObjectHandler {
|
||||||
async fn get_local_objects_for_user(
|
|
||||||
&self,
|
|
||||||
user_id: uuid::Uuid,
|
|
||||||
) -> anyhow::Result<Vec<(Url, serde_json::Value)>> {
|
|
||||||
let domain_user_id = UserId::from_uuid(user_id);
|
|
||||||
let entries = self
|
|
||||||
.content_query
|
|
||||||
.get_local_reviews_for_user(&domain_user_id)
|
|
||||||
.await
|
|
||||||
.map_err(|e| anyhow::anyhow!(e.to_string()))?;
|
|
||||||
|
|
||||||
let actor = actor_url(&self.base_url, user_id);
|
|
||||||
let mut results = Vec::new();
|
|
||||||
for entry in entries {
|
|
||||||
let review = entry.review();
|
|
||||||
let movie = entry.movie();
|
|
||||||
|
|
||||||
let ap_id = review_url(&self.base_url, review.id());
|
|
||||||
let poster_url = movie
|
|
||||||
.poster_path()
|
|
||||||
.map(|p| format!("{}/images/{}", self.base_url, p.value()));
|
|
||||||
|
|
||||||
let obj = review_to_ap_object(
|
|
||||||
review,
|
|
||||||
ap_id.clone(),
|
|
||||||
actor.clone(),
|
|
||||||
movie.title().value().to_string(),
|
|
||||||
movie.release_year().value(),
|
|
||||||
poster_url,
|
|
||||||
&self.base_url,
|
|
||||||
);
|
|
||||||
results.push((ap_id, serde_json::to_value(obj)?));
|
|
||||||
}
|
|
||||||
Ok(results)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn get_local_objects_page(
|
async fn get_local_objects_page(
|
||||||
&self,
|
&self,
|
||||||
user_id: uuid::Uuid,
|
user_id: uuid::Uuid,
|
||||||
@@ -64,9 +28,10 @@ impl ApObjectHandler for ReviewObjectHandler {
|
|||||||
limit: usize,
|
limit: usize,
|
||||||
) -> anyhow::Result<Vec<(url::Url, serde_json::Value, chrono::DateTime<chrono::Utc>)>> {
|
) -> anyhow::Result<Vec<(url::Url, serde_json::Value, chrono::DateTime<chrono::Utc>)>> {
|
||||||
let domain_user_id = UserId::from_uuid(user_id);
|
let domain_user_id = UserId::from_uuid(user_id);
|
||||||
|
let before_naive = before.map(|dt| dt.naive_utc());
|
||||||
let entries = self
|
let entries = self
|
||||||
.content_query
|
.content_query
|
||||||
.get_local_reviews_for_user(&domain_user_id)
|
.get_local_reviews_page(&domain_user_id, before_naive, limit)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| anyhow::anyhow!(e.to_string()))?;
|
.map_err(|e| anyhow::anyhow!(e.to_string()))?;
|
||||||
|
|
||||||
@@ -74,14 +39,8 @@ impl ApObjectHandler for ReviewObjectHandler {
|
|||||||
let mut results = Vec::new();
|
let mut results = Vec::new();
|
||||||
for entry in entries {
|
for entry in entries {
|
||||||
let review = entry.review();
|
let review = entry.review();
|
||||||
let published = chrono::DateTime::from_naive_utc_and_offset(*review.watched_at(), chrono::Utc);
|
let published =
|
||||||
|
chrono::DateTime::from_naive_utc_and_offset(*review.watched_at(), chrono::Utc);
|
||||||
if let Some(cutoff) = before
|
|
||||||
&& published >= cutoff
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let movie = entry.movie();
|
let movie = entry.movie();
|
||||||
let ap_id = review_url(&self.base_url, review.id());
|
let ap_id = review_url(&self.base_url, review.id());
|
||||||
let poster_url = movie
|
let poster_url = movie
|
||||||
@@ -98,14 +57,20 @@ impl ApObjectHandler for ReviewObjectHandler {
|
|||||||
&self.base_url,
|
&self.base_url,
|
||||||
);
|
);
|
||||||
results.push((ap_id, serde_json::to_value(obj)?, published));
|
results.push((ap_id, serde_json::to_value(obj)?, published));
|
||||||
|
|
||||||
if results.len() >= limit {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Ok(results)
|
Ok(results)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn count_local_posts(&self) -> anyhow::Result<u64> {
|
||||||
|
self.content_query
|
||||||
|
.count_local_posts()
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!(e.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl ApObjectHandler for ReviewObjectHandler {
|
||||||
async fn on_create(
|
async fn on_create(
|
||||||
&self,
|
&self,
|
||||||
_ap_id: &Url,
|
_ap_id: &Url,
|
||||||
@@ -133,18 +98,18 @@ impl ApObjectHandler for ReviewObjectHandler {
|
|||||||
let rating = Rating::new(obj.rating.min(5))?;
|
let rating = Rating::new(obj.rating.min(5))?;
|
||||||
let comment = obj.comment.map(Comment::new).transpose()?;
|
let comment = obj.comment.map(Comment::new).transpose()?;
|
||||||
|
|
||||||
let review = domain::models::Review::from_persistence(
|
let review = domain::models::Review::from_persistence(domain::models::PersistedReview {
|
||||||
review_id,
|
id: review_id,
|
||||||
movie_id,
|
movie_id,
|
||||||
user_id,
|
user_id,
|
||||||
rating,
|
rating,
|
||||||
comment,
|
comment,
|
||||||
obj.watched_at.naive_utc(),
|
watched_at: obj.watched_at.naive_utc(),
|
||||||
obj.published.naive_utc(),
|
created_at: obj.published.naive_utc(),
|
||||||
ReviewSource::Remote {
|
source: ReviewSource::Remote {
|
||||||
actor_url: actor_url_str,
|
actor_url: actor_url_str,
|
||||||
},
|
},
|
||||||
);
|
});
|
||||||
|
|
||||||
self.review_store
|
self.review_store
|
||||||
.save_remote_review(
|
.save_remote_review(
|
||||||
@@ -200,18 +165,23 @@ impl ApObjectHandler for ReviewObjectHandler {
|
|||||||
self.review_store.delete_by_actor(actor_url.as_str()).await
|
self.review_store.delete_by_actor(actor_url.as_str()).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn count_local_posts(&self) -> anyhow::Result<u64> {
|
|
||||||
self.content_query
|
|
||||||
.count_local_posts()
|
|
||||||
.await
|
|
||||||
.map_err(|e| anyhow::anyhow!(e.to_string()))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn on_like(&self, _object_url: &Url, _actor_url: &Url) -> anyhow::Result<()> {
|
async fn on_like(&self, _object_url: &Url, _actor_url: &Url) -> anyhow::Result<()> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn on_announce_received(&self, _object_url: &Url, _actor_url: &Url) -> anyhow::Result<()> {
|
async fn on_announce_received(
|
||||||
|
&self,
|
||||||
|
_object_url: &Url,
|
||||||
|
_actor_url: &Url,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn on_announce_of_remote(
|
||||||
|
&self,
|
||||||
|
_object_url: &Url,
|
||||||
|
_actor_url: &Url,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,7 +189,12 @@ impl ApObjectHandler for ReviewObjectHandler {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn on_mention(&self, _thought_ap_id: &Url, _mentioned_user_uuid: uuid::Uuid, _actor_url: &Url) -> anyhow::Result<()> {
|
async fn on_mention(
|
||||||
|
&self,
|
||||||
|
_thought_ap_id: &Url,
|
||||||
|
_mentioned_user_uuid: uuid::Uuid,
|
||||||
|
_actor_url: &Url,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,20 +14,22 @@ fn normalize_hashtag_strips_non_alphanumeric() {
|
|||||||
fn review_to_ap_object_includes_two_hashtags() {
|
fn review_to_ap_object_includes_two_hashtags() {
|
||||||
use chrono::NaiveDateTime;
|
use chrono::NaiveDateTime;
|
||||||
use domain::{
|
use domain::{
|
||||||
models::{Review, ReviewSource},
|
models::{PersistedReview, Review, ReviewSource},
|
||||||
value_objects::{MovieId, Rating, ReviewId, UserId},
|
value_objects::{MovieId, Rating, ReviewId, UserId},
|
||||||
};
|
};
|
||||||
|
|
||||||
let review = Review::from_persistence(
|
let review = Review::from_persistence(PersistedReview {
|
||||||
ReviewId::generate(),
|
id: ReviewId::generate(),
|
||||||
MovieId::from_uuid(uuid::Uuid::new_v4()),
|
movie_id: MovieId::from_uuid(uuid::Uuid::new_v4()),
|
||||||
UserId::from_uuid(uuid::Uuid::new_v4()),
|
user_id: UserId::from_uuid(uuid::Uuid::new_v4()),
|
||||||
Rating::new(4).unwrap(),
|
rating: Rating::new(4).unwrap(),
|
||||||
None,
|
comment: None,
|
||||||
NaiveDateTime::parse_from_str("2024-01-01 00:00:00", "%Y-%m-%d %H:%M:%S").unwrap(),
|
watched_at: NaiveDateTime::parse_from_str("2024-01-01 00:00:00", "%Y-%m-%d %H:%M:%S")
|
||||||
NaiveDateTime::parse_from_str("2024-01-01 00:00:00", "%Y-%m-%d %H:%M:%S").unwrap(),
|
.unwrap(),
|
||||||
ReviewSource::Local,
|
created_at: NaiveDateTime::parse_from_str("2024-01-01 00:00:00", "%Y-%m-%d %H:%M:%S")
|
||||||
);
|
.unwrap(),
|
||||||
|
source: ReviewSource::Local,
|
||||||
|
});
|
||||||
let obj = review_to_ap_object(
|
let obj = review_to_ap_object(
|
||||||
&review,
|
&review,
|
||||||
"https://example.com/reviews/1".parse().unwrap(),
|
"https://example.com/reviews/1".parse().unwrap(),
|
||||||
@@ -47,20 +49,22 @@ fn review_to_ap_object_includes_two_hashtags() {
|
|||||||
fn review_to_ap_object_has_public_addressing() {
|
fn review_to_ap_object_has_public_addressing() {
|
||||||
use chrono::NaiveDateTime;
|
use chrono::NaiveDateTime;
|
||||||
use domain::{
|
use domain::{
|
||||||
models::{Review, ReviewSource},
|
models::{PersistedReview, Review, ReviewSource},
|
||||||
value_objects::{MovieId, Rating, ReviewId, UserId},
|
value_objects::{MovieId, Rating, ReviewId, UserId},
|
||||||
};
|
};
|
||||||
|
|
||||||
let review = Review::from_persistence(
|
let review = Review::from_persistence(PersistedReview {
|
||||||
ReviewId::generate(),
|
id: ReviewId::generate(),
|
||||||
MovieId::from_uuid(uuid::Uuid::new_v4()),
|
movie_id: MovieId::from_uuid(uuid::Uuid::new_v4()),
|
||||||
UserId::from_uuid(uuid::Uuid::new_v4()),
|
user_id: UserId::from_uuid(uuid::Uuid::new_v4()),
|
||||||
Rating::new(3).unwrap(),
|
rating: Rating::new(3).unwrap(),
|
||||||
None,
|
comment: None,
|
||||||
NaiveDateTime::parse_from_str("2024-06-01 00:00:00", "%Y-%m-%d %H:%M:%S").unwrap(),
|
watched_at: NaiveDateTime::parse_from_str("2024-06-01 00:00:00", "%Y-%m-%d %H:%M:%S")
|
||||||
NaiveDateTime::parse_from_str("2024-06-01 00:00:00", "%Y-%m-%d %H:%M:%S").unwrap(),
|
.unwrap(),
|
||||||
ReviewSource::Local,
|
created_at: NaiveDateTime::parse_from_str("2024-06-01 00:00:00", "%Y-%m-%d %H:%M:%S")
|
||||||
);
|
.unwrap(),
|
||||||
|
source: ReviewSource::Local,
|
||||||
|
});
|
||||||
let actor_url: url::Url = "https://example.com/users/abc".parse().unwrap();
|
let actor_url: url::Url = "https://example.com/users/abc".parse().unwrap();
|
||||||
let obj = review_to_ap_object(
|
let obj = review_to_ap_object(
|
||||||
&review,
|
&review,
|
||||||
@@ -78,16 +82,16 @@ fn review_to_ap_object_has_public_addressing() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn watchlist_to_ap_object_has_public_addressing() {
|
fn watchlist_to_ap_object_has_public_addressing() {
|
||||||
let actor_url: url::Url = "https://example.com/users/abc".parse().unwrap();
|
let actor_url: url::Url = "https://example.com/users/abc".parse().unwrap();
|
||||||
let obj = watchlist_to_ap_object(
|
let obj = watchlist_to_ap_object(WatchlistApInput {
|
||||||
"https://example.com/watchlist/1".parse().unwrap(),
|
ap_id: "https://example.com/watchlist/1".parse().unwrap(),
|
||||||
actor_url.clone(),
|
actor_url: actor_url.clone(),
|
||||||
"Alien".to_string(),
|
movie_title: "Alien".to_string(),
|
||||||
1979,
|
release_year: 1979,
|
||||||
None,
|
external_metadata_id: None,
|
||||||
None,
|
poster_url: None,
|
||||||
chrono::Utc::now(),
|
added_at: chrono::Utc::now(),
|
||||||
"https://example.com",
|
base_url: "https://example.com".to_string(),
|
||||||
);
|
});
|
||||||
assert_eq!(obj.to, vec!["https://www.w3.org/ns/activitystreams#Public"]);
|
assert_eq!(obj.to, vec!["https://www.w3.org/ns/activitystreams#Public"]);
|
||||||
assert_eq!(obj.cc, vec!["https://example.com/users/abc/followers"]);
|
assert_eq!(obj.cc, vec!["https://example.com/users/abc/followers"]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use k_ap::{ApProfileField, ApUser, ApUserRepository};
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use domain::{ports::UserRepository, value_objects::UserId};
|
use domain::{ports::UserRepository, value_objects::UserId};
|
||||||
|
use k_ap::{ApProfileField, ApUser, ApUserRepository};
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
pub struct DomainUserRepoAdapter {
|
pub struct DomainUserRepoAdapter {
|
||||||
@@ -26,10 +26,14 @@ impl DomainUserRepoAdapter {
|
|||||||
ApUser {
|
ApUser {
|
||||||
id: u.id().value(),
|
id: u.id().value(),
|
||||||
username: u.username().value().to_string(),
|
username: u.username().value().to_string(),
|
||||||
|
display_name: u.display_name().map(|s| s.to_string()),
|
||||||
bio: u.bio().map(|s| s.to_string()),
|
bio: u.bio().map(|s| s.to_string()),
|
||||||
avatar_url,
|
avatar_url,
|
||||||
banner_url,
|
banner_url,
|
||||||
also_known_as: u.also_known_as().map(|s| s.to_string()),
|
also_known_as: u
|
||||||
|
.also_known_as()
|
||||||
|
.map(|s| vec![s.to_string()])
|
||||||
|
.unwrap_or_default(),
|
||||||
profile_url,
|
profile_url,
|
||||||
attachment: u
|
attachment: u
|
||||||
.profile_fields()
|
.profile_fields()
|
||||||
@@ -39,6 +43,15 @@ impl DomainUserRepoAdapter {
|
|||||||
value: f.value.clone(),
|
value: f.value.clone(),
|
||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
|
manually_approves_followers: true,
|
||||||
|
discoverable: true,
|
||||||
|
actor_type: Default::default(),
|
||||||
|
featured_url: Url::parse(&format!(
|
||||||
|
"{}/users/{}/featured",
|
||||||
|
self.base_url,
|
||||||
|
u.id().value()
|
||||||
|
))
|
||||||
|
.ok(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,14 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use k_ap::ApObjectHandler;
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use chrono::Utc;
|
|
||||||
use domain::{
|
use domain::{
|
||||||
models::RemoteWatchlistEntry,
|
models::RemoteWatchlistEntry,
|
||||||
ports::{LocalApContentQuery, RemoteWatchlistRepository},
|
ports::{LocalApContentQuery, RemoteWatchlistRepository},
|
||||||
value_objects::UserId,
|
|
||||||
};
|
};
|
||||||
|
use k_ap::ApObjectHandler;
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
use crate::{objects::{WatchlistObject, watchlist_to_ap_object}, urls::{actor_url, watchlist_entry_url}};
|
use crate::objects::WatchlistObject;
|
||||||
|
|
||||||
pub struct WatchlistObjectHandler {
|
pub struct WatchlistObjectHandler {
|
||||||
pub remote_watchlist_repo: Arc<dyn RemoteWatchlistRepository>,
|
pub remote_watchlist_repo: Arc<dyn RemoteWatchlistRepository>,
|
||||||
@@ -20,55 +18,6 @@ pub struct WatchlistObjectHandler {
|
|||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl ApObjectHandler for WatchlistObjectHandler {
|
impl ApObjectHandler for WatchlistObjectHandler {
|
||||||
async fn get_local_objects_for_user(
|
|
||||||
&self,
|
|
||||||
user_id: uuid::Uuid,
|
|
||||||
) -> anyhow::Result<Vec<(Url, serde_json::Value)>> {
|
|
||||||
let domain_user_id = UserId::from_uuid(user_id);
|
|
||||||
let entries = self
|
|
||||||
.content_query
|
|
||||||
.get_local_watchlist_for_user(&domain_user_id)
|
|
||||||
.await
|
|
||||||
.map_err(|e| anyhow::anyhow!(e.to_string()))?;
|
|
||||||
|
|
||||||
let actor = actor_url(&self.base_url, user_id);
|
|
||||||
let mut results = Vec::new();
|
|
||||||
for wm in entries {
|
|
||||||
let movie_id = wm.entry.movie_id.value();
|
|
||||||
let ap_id = watchlist_entry_url(&self.base_url, user_id, movie_id);
|
|
||||||
let added_at = chrono::DateTime::from_naive_utc_and_offset(wm.entry.added_at, Utc);
|
|
||||||
let external_metadata_id = wm
|
|
||||||
.movie
|
|
||||||
.external_metadata_id()
|
|
||||||
.map(|id| id.value().to_string());
|
|
||||||
let poster_url = wm
|
|
||||||
.movie
|
|
||||||
.poster_path()
|
|
||||||
.map(|p| format!("{}/images/{}", self.base_url, p.value()));
|
|
||||||
let obj = watchlist_to_ap_object(
|
|
||||||
ap_id.clone(),
|
|
||||||
actor.clone(),
|
|
||||||
wm.movie.title().value().to_string(),
|
|
||||||
wm.movie.release_year().value(),
|
|
||||||
external_metadata_id,
|
|
||||||
poster_url,
|
|
||||||
added_at,
|
|
||||||
&self.base_url,
|
|
||||||
);
|
|
||||||
results.push((ap_id, serde_json::to_value(obj)?));
|
|
||||||
}
|
|
||||||
Ok(results)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn get_local_objects_page(
|
|
||||||
&self,
|
|
||||||
_user_id: uuid::Uuid,
|
|
||||||
_before: Option<chrono::DateTime<Utc>>,
|
|
||||||
_limit: usize,
|
|
||||||
) -> anyhow::Result<Vec<(Url, serde_json::Value, chrono::DateTime<Utc>)>> {
|
|
||||||
Ok(vec![])
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn on_create(
|
async fn on_create(
|
||||||
&self,
|
&self,
|
||||||
ap_id: &Url,
|
ap_id: &Url,
|
||||||
@@ -115,15 +64,23 @@ impl ApObjectHandler for WatchlistObjectHandler {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn count_local_posts(&self) -> anyhow::Result<u64> {
|
|
||||||
Ok(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn on_like(&self, _object_url: &Url, _actor_url: &Url) -> anyhow::Result<()> {
|
async fn on_like(&self, _object_url: &Url, _actor_url: &Url) -> anyhow::Result<()> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn on_announce_received(&self, _object_url: &Url, _actor_url: &Url) -> anyhow::Result<()> {
|
async fn on_announce_received(
|
||||||
|
&self,
|
||||||
|
_object_url: &Url,
|
||||||
|
_actor_url: &Url,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn on_announce_of_remote(
|
||||||
|
&self,
|
||||||
|
_object_url: &Url,
|
||||||
|
_actor_url: &Url,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,7 +88,12 @@ impl ApObjectHandler for WatchlistObjectHandler {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn on_mention(&self, _thought_ap_id: &Url, _mentioned_user_uuid: uuid::Uuid, _actor_url: &Url) -> anyhow::Result<()> {
|
async fn on_mention(
|
||||||
|
&self,
|
||||||
|
_thought_ap_id: &Url,
|
||||||
|
_mentioned_user_uuid: uuid::Uuid,
|
||||||
|
_actor_url: &Url,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,6 +63,10 @@ pub enum EventPayload {
|
|||||||
remote_actor_url: String,
|
remote_actor_url: String,
|
||||||
outbox_url: String,
|
outbox_url: String,
|
||||||
},
|
},
|
||||||
|
BackfillFollower {
|
||||||
|
owner_user_id: String,
|
||||||
|
follower_inbox_url: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
impl EventPayload {
|
impl EventPayload {
|
||||||
@@ -79,6 +83,7 @@ impl EventPayload {
|
|||||||
EventPayload::WatchlistEntryAdded { .. } => "WatchlistEntryAdded",
|
EventPayload::WatchlistEntryAdded { .. } => "WatchlistEntryAdded",
|
||||||
EventPayload::WatchlistEntryRemoved { .. } => "WatchlistEntryRemoved",
|
EventPayload::WatchlistEntryRemoved { .. } => "WatchlistEntryRemoved",
|
||||||
EventPayload::FollowAccepted { .. } => "FollowAccepted",
|
EventPayload::FollowAccepted { .. } => "FollowAccepted",
|
||||||
|
EventPayload::BackfillFollower { .. } => "BackfillFollower",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -181,6 +186,13 @@ impl From<&DomainEvent> for EventPayload {
|
|||||||
remote_actor_url: remote_actor_url.clone(),
|
remote_actor_url: remote_actor_url.clone(),
|
||||||
outbox_url: outbox_url.clone(),
|
outbox_url: outbox_url.clone(),
|
||||||
},
|
},
|
||||||
|
DomainEvent::BackfillFollower {
|
||||||
|
owner_user_id,
|
||||||
|
follower_inbox_url,
|
||||||
|
} => EventPayload::BackfillFollower {
|
||||||
|
owner_user_id: owner_user_id.value().to_string(),
|
||||||
|
follower_inbox_url: follower_inbox_url.clone(),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -281,6 +293,13 @@ impl TryFrom<EventPayload> for DomainEvent {
|
|||||||
remote_actor_url,
|
remote_actor_url,
|
||||||
outbox_url,
|
outbox_url,
|
||||||
}),
|
}),
|
||||||
|
EventPayload::BackfillFollower {
|
||||||
|
owner_user_id,
|
||||||
|
follower_inbox_url,
|
||||||
|
} => Ok(DomainEvent::BackfillFollower {
|
||||||
|
owner_user_id: UserId::from_uuid(parse_uuid(&owner_user_id, "owner_user_id")?),
|
||||||
|
follower_inbox_url,
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,12 +11,14 @@ use domain::ports::{
|
|||||||
};
|
};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
type ConversionPair = (Arc<dyn EventHandler>, Arc<dyn PeriodicJob>);
|
||||||
|
|
||||||
pub fn build(
|
pub fn build(
|
||||||
image_storage: Arc<dyn ImageStorage>,
|
image_storage: Arc<dyn ImageStorage>,
|
||||||
image_ref_command: Arc<dyn ImageRefCommand>,
|
image_ref_command: Arc<dyn ImageRefCommand>,
|
||||||
image_ref_query: Arc<dyn ImageRefQuery>,
|
image_ref_query: Arc<dyn ImageRefQuery>,
|
||||||
event_publisher: Arc<dyn EventPublisher>,
|
event_publisher: Arc<dyn EventPublisher>,
|
||||||
) -> anyhow::Result<Option<(Arc<dyn EventHandler>, Arc<dyn PeriodicJob>)>> {
|
) -> anyhow::Result<Option<ConversionPair>> {
|
||||||
let config = match ConversionConfig::from_env()? {
|
let config = match ConversionConfig::from_env()? {
|
||||||
Some(c) => c,
|
Some(c) => c,
|
||||||
None => return Ok(None),
|
None => return Ok(None),
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ pub fn event_to_subject(prefix: &str, event: &DomainEvent) -> String {
|
|||||||
DomainEvent::WatchlistEntryAdded { .. } => "watchlist.entry.added",
|
DomainEvent::WatchlistEntryAdded { .. } => "watchlist.entry.added",
|
||||||
DomainEvent::WatchlistEntryRemoved { .. } => "watchlist.entry.removed",
|
DomainEvent::WatchlistEntryRemoved { .. } => "watchlist.entry.removed",
|
||||||
DomainEvent::FollowAccepted { .. } => "follow.accepted",
|
DomainEvent::FollowAccepted { .. } => "follow.accepted",
|
||||||
|
DomainEvent::BackfillFollower { .. } => "backfill.follower",
|
||||||
};
|
};
|
||||||
format!("{prefix}.{suffix}")
|
format!("{prefix}.{suffix}")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,12 +11,12 @@ sqlx = { version = "0.8.6", features = [
|
|||||||
"macros",
|
"macros",
|
||||||
"chrono",
|
"chrono",
|
||||||
] }
|
] }
|
||||||
activitypub = { workspace = true }
|
activitypub = { workspace = true }
|
||||||
k-ap = { git = "https://git.gabrielkaszewski.dev/GKaszewski/k-ap.git", tag = "v0.1.10" }
|
k-ap = { version = "0.3.1", registry = "gitea" }
|
||||||
domain = { workspace = true }
|
domain = { workspace = true }
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
chrono = { workspace = true }
|
chrono = { workspace = true }
|
||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
async-trait = { workspace = true }
|
async-trait = { workspace = true }
|
||||||
anyhow = { workspace = true }
|
anyhow = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
|||||||
@@ -4,11 +4,12 @@ use chrono::{NaiveDateTime, Utc};
|
|||||||
use sqlx::{PgPool, Row};
|
use sqlx::{PgPool, Row};
|
||||||
|
|
||||||
use activitypub::RemoteReviewRepository;
|
use activitypub::RemoteReviewRepository;
|
||||||
use k_ap::{
|
|
||||||
BlockedDomain, FederationRepository, Follower, FollowerStatus, FollowingStatus, RemoteActor,
|
|
||||||
};
|
|
||||||
use domain::models::{RemoteWatchlistEntry, Review, ReviewSource};
|
use domain::models::{RemoteWatchlistEntry, Review, ReviewSource};
|
||||||
use domain::ports::RemoteWatchlistRepository;
|
use domain::ports::RemoteWatchlistRepository;
|
||||||
|
use k_ap::{
|
||||||
|
ActivityRepository, ActorRepository, BlockedDomain, BlocklistRepository, FollowRepository,
|
||||||
|
Follower, FollowerStatus, FollowingStatus, RemoteActor,
|
||||||
|
};
|
||||||
|
|
||||||
fn datetime_to_str(dt: &NaiveDateTime) -> String {
|
fn datetime_to_str(dt: &NaiveDateTime) -> String {
|
||||||
dt.format("%Y-%m-%d %H:%M:%S").to_string()
|
dt.format("%Y-%m-%d %H:%M:%S").to_string()
|
||||||
@@ -40,8 +41,37 @@ fn str_to_status(s: &str) -> FollowerStatus {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn pg_remote_actor(row: &sqlx::postgres::PgRow, url_col: &str) -> RemoteActor {
|
||||||
|
RemoteActor {
|
||||||
|
url: row.get(url_col),
|
||||||
|
handle: row.try_get("handle").unwrap_or_default(),
|
||||||
|
inbox_url: row.try_get("inbox_url").unwrap_or_default(),
|
||||||
|
shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(),
|
||||||
|
display_name: row.try_get("display_name").ok().flatten(),
|
||||||
|
avatar_url: row.try_get("avatar_url").ok().flatten(),
|
||||||
|
outbox_url: row.try_get("outbox_url").ok().flatten(),
|
||||||
|
bio: row.try_get("bio").ok().flatten(),
|
||||||
|
banner_url: row.try_get("banner_url").ok().flatten(),
|
||||||
|
followers_url: row.try_get("followers_url").ok().flatten(),
|
||||||
|
following_url: row.try_get("following_url").ok().flatten(),
|
||||||
|
also_known_as: row
|
||||||
|
.try_get::<Option<String>, _>("also_known_as")
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.map(|s| {
|
||||||
|
serde_json::from_str::<Vec<String>>(&s).unwrap_or_else(|e| {
|
||||||
|
tracing::warn!(raw = %s, error = %e, "failed to parse also_known_as JSON");
|
||||||
|
vec![s]
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.unwrap_or_default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const PG_ACTOR_COLS: &str = "a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.avatar_url, a.outbox_url, a.bio, a.banner_url, a.followers_url, a.following_url, a.also_known_as";
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl FederationRepository for PostgresFederationRepository {
|
impl FollowRepository for PostgresFederationRepository {
|
||||||
async fn add_follower(
|
async fn add_follower(
|
||||||
&self,
|
&self,
|
||||||
local_user_id: uuid::Uuid,
|
local_user_id: uuid::Uuid,
|
||||||
@@ -102,37 +132,18 @@ impl FederationRepository for PostgresFederationRepository {
|
|||||||
|
|
||||||
async fn get_followers(&self, local_user_id: uuid::Uuid) -> Result<Vec<Follower>> {
|
async fn get_followers(&self, local_user_id: uuid::Uuid) -> Result<Vec<Follower>> {
|
||||||
let uid = local_user_id.to_string();
|
let uid = local_user_id.to_string();
|
||||||
let rows = sqlx::query(
|
let q = format!(
|
||||||
"SELECT f.remote_actor_url, f.status,
|
"SELECT f.remote_actor_url, f.status, {PG_ACTOR_COLS}
|
||||||
a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.avatar_url
|
FROM ap_followers f LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
|
||||||
FROM ap_followers f
|
WHERE f.local_user_id = $1"
|
||||||
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
|
);
|
||||||
WHERE f.local_user_id = $1",
|
let rows = sqlx::query(&q).bind(&uid).fetch_all(&self.pool).await?;
|
||||||
)
|
|
||||||
.bind(&uid)
|
|
||||||
.fetch_all(&self.pool)
|
|
||||||
.await?;
|
|
||||||
Ok(rows
|
Ok(rows
|
||||||
.into_iter()
|
.iter()
|
||||||
.map(|row| {
|
.map(|row| {
|
||||||
let url: String = row.get("remote_actor_url");
|
|
||||||
let status_str: String = row.get("status");
|
let status_str: String = row.get("status");
|
||||||
let handle: String = row.try_get("handle").unwrap_or_default();
|
|
||||||
let inbox_url: String = row.try_get("inbox_url").unwrap_or_default();
|
|
||||||
let shared_inbox_url: Option<String> =
|
|
||||||
row.try_get("shared_inbox_url").ok().flatten();
|
|
||||||
let display_name: Option<String> = row.try_get("display_name").ok().flatten();
|
|
||||||
let avatar_url: Option<String> = row.try_get("avatar_url").ok().flatten();
|
|
||||||
Follower {
|
Follower {
|
||||||
actor: RemoteActor {
|
actor: pg_remote_actor(row, "remote_actor_url"),
|
||||||
url,
|
|
||||||
handle,
|
|
||||||
inbox_url,
|
|
||||||
shared_inbox_url,
|
|
||||||
display_name,
|
|
||||||
avatar_url,
|
|
||||||
outbox_url: row.try_get("outbox_url").ok().flatten(),
|
|
||||||
},
|
|
||||||
status: str_to_status(&status_str),
|
status: str_to_status(&status_str),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -146,45 +157,24 @@ impl FederationRepository for PostgresFederationRepository {
|
|||||||
limit: usize,
|
limit: usize,
|
||||||
) -> Result<Vec<Follower>> {
|
) -> Result<Vec<Follower>> {
|
||||||
let uid = local_user_id.to_string();
|
let uid = local_user_id.to_string();
|
||||||
let limit_i64 = limit as i64;
|
let q = format!(
|
||||||
let offset_i64 = offset as i64;
|
"SELECT f.remote_actor_url, f.status, {PG_ACTOR_COLS}
|
||||||
|
FROM ap_followers f LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
|
||||||
let rows = sqlx::query(
|
|
||||||
"SELECT f.remote_actor_url, f.status,
|
|
||||||
a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.avatar_url
|
|
||||||
FROM ap_followers f
|
|
||||||
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
|
|
||||||
WHERE f.local_user_id = $1 AND f.status = 'accepted'
|
WHERE f.local_user_id = $1 AND f.status = 'accepted'
|
||||||
ORDER BY f.created_at ASC
|
ORDER BY f.created_at ASC LIMIT $2 OFFSET $3"
|
||||||
LIMIT $2 OFFSET $3",
|
);
|
||||||
)
|
let rows = sqlx::query(&q)
|
||||||
.bind(&uid)
|
.bind(&uid)
|
||||||
.bind(limit_i64)
|
.bind(limit as i64)
|
||||||
.bind(offset_i64)
|
.bind(offset as i64)
|
||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(rows
|
Ok(rows
|
||||||
.into_iter()
|
.iter()
|
||||||
.map(|row| {
|
.map(|row| {
|
||||||
let url: String = row.get("remote_actor_url");
|
|
||||||
let status_str: String = row.get("status");
|
let status_str: String = row.get("status");
|
||||||
let handle: String = row.try_get("handle").unwrap_or_default();
|
|
||||||
let inbox_url: String = row.try_get("inbox_url").unwrap_or_default();
|
|
||||||
let shared_inbox_url: Option<String> =
|
|
||||||
row.try_get("shared_inbox_url").ok().flatten();
|
|
||||||
let display_name: Option<String> = row.try_get("display_name").ok().flatten();
|
|
||||||
let avatar_url: Option<String> = row.try_get("avatar_url").ok().flatten();
|
|
||||||
Follower {
|
Follower {
|
||||||
actor: RemoteActor {
|
actor: pg_remote_actor(row, "remote_actor_url"),
|
||||||
url,
|
|
||||||
handle,
|
|
||||||
inbox_url,
|
|
||||||
shared_inbox_url,
|
|
||||||
display_name,
|
|
||||||
avatar_url,
|
|
||||||
outbox_url: row.try_get("outbox_url").ok().flatten(),
|
|
||||||
},
|
|
||||||
status: str_to_status(&status_str),
|
status: str_to_status(&status_str),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -212,18 +202,86 @@ impl FederationRepository for PostgresFederationRepository {
|
|||||||
let status_str = status_to_str(&status);
|
let status_str = status_to_str(&status);
|
||||||
let result = sqlx::query(
|
let result = sqlx::query(
|
||||||
"UPDATE ap_followers SET status = $1 WHERE local_user_id = $2 AND remote_actor_url = $3",
|
"UPDATE ap_followers SET status = $1 WHERE local_user_id = $2 AND remote_actor_url = $3",
|
||||||
)
|
).bind(status_str).bind(&uid).bind(remote_actor_url).execute(&self.pool).await?;
|
||||||
.bind(status_str)
|
|
||||||
.bind(&uid)
|
|
||||||
.bind(remote_actor_url)
|
|
||||||
.execute(&self.pool)
|
|
||||||
.await?;
|
|
||||||
if result.rows_affected() == 0 {
|
if result.rows_affected() == 0 {
|
||||||
tracing::warn!(local_user_id = %local_user_id, remote_actor_url, "update_follower_status: no row found");
|
tracing::warn!(local_user_id = %local_user_id, remote_actor_url, "update_follower_status: no row found");
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn get_pending_followers(&self, local_user_id: uuid::Uuid) -> Result<Vec<RemoteActor>> {
|
||||||
|
let uid = local_user_id.to_string();
|
||||||
|
let q = format!(
|
||||||
|
"SELECT f.remote_actor_url, {PG_ACTOR_COLS}
|
||||||
|
FROM ap_followers f LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
|
||||||
|
WHERE f.local_user_id = $1 AND f.status = 'pending'"
|
||||||
|
);
|
||||||
|
let rows = sqlx::query(&q).bind(&uid).fetch_all(&self.pool).await?;
|
||||||
|
Ok(rows
|
||||||
|
.iter()
|
||||||
|
.map(|row| pg_remote_actor(row, "remote_actor_url"))
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_accepted_follower_inboxes(
|
||||||
|
&self,
|
||||||
|
local_user_id: uuid::Uuid,
|
||||||
|
) -> Result<Vec<String>> {
|
||||||
|
let uid = local_user_id.to_string();
|
||||||
|
let rows = sqlx::query(
|
||||||
|
"SELECT DISTINCT COALESCE(a.shared_inbox_url, a.inbox_url) as inbox
|
||||||
|
FROM ap_followers f
|
||||||
|
INNER JOIN ap_remote_actors a ON a.url = f.remote_actor_url
|
||||||
|
WHERE f.local_user_id = $1 AND f.status = 'accepted'
|
||||||
|
AND f.remote_actor_url NOT IN (
|
||||||
|
SELECT remote_actor_url FROM blocked_actors WHERE local_user_id = $1
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.bind(&uid)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(rows
|
||||||
|
.iter()
|
||||||
|
.filter_map(|r| r.try_get::<String, _>("inbox").ok())
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn count_accepted_followers(&self, local_user_id: uuid::Uuid) -> Result<usize> {
|
||||||
|
let uid = local_user_id.to_string();
|
||||||
|
let count: i64 = sqlx::query_scalar(
|
||||||
|
"SELECT COUNT(*) FROM ap_followers WHERE local_user_id = $1 AND status = 'accepted'",
|
||||||
|
)
|
||||||
|
.bind(&uid)
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(count as usize)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_accepted_followers_page(
|
||||||
|
&self,
|
||||||
|
local_user_id: uuid::Uuid,
|
||||||
|
offset: u32,
|
||||||
|
limit: usize,
|
||||||
|
) -> Result<Vec<RemoteActor>> {
|
||||||
|
let uid = local_user_id.to_string();
|
||||||
|
let q = format!(
|
||||||
|
"SELECT f.remote_actor_url, {PG_ACTOR_COLS}
|
||||||
|
FROM ap_followers f LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
|
||||||
|
WHERE f.local_user_id = $1 AND f.status = 'accepted'
|
||||||
|
ORDER BY f.created_at ASC LIMIT $2 OFFSET $3"
|
||||||
|
);
|
||||||
|
let rows = sqlx::query(&q)
|
||||||
|
.bind(&uid)
|
||||||
|
.bind(limit as i64)
|
||||||
|
.bind(offset as i64)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(rows
|
||||||
|
.iter()
|
||||||
|
.map(|row| pg_remote_actor(row, "remote_actor_url"))
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
async fn add_following(
|
async fn add_following(
|
||||||
&self,
|
&self,
|
||||||
local_user_id: uuid::Uuid,
|
local_user_id: uuid::Uuid,
|
||||||
@@ -233,18 +291,11 @@ impl FederationRepository for PostgresFederationRepository {
|
|||||||
let uid = local_user_id.to_string();
|
let uid = local_user_id.to_string();
|
||||||
let now = Utc::now().naive_utc();
|
let now = Utc::now().naive_utc();
|
||||||
let created_at = datetime_to_str(&now);
|
let created_at = datetime_to_str(&now);
|
||||||
self.upsert_remote_actor(actor.clone()).await?;
|
ActorRepository::upsert_remote_actor(self, actor.clone()).await?;
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO ap_following (local_user_id, remote_actor_url, follow_activity_id, created_at)
|
"INSERT INTO ap_following (local_user_id, remote_actor_url, follow_activity_id, created_at)
|
||||||
VALUES ($1, $2, $3, $4::timestamptz)
|
VALUES ($1, $2, $3, $4::timestamptz) ON CONFLICT DO NOTHING",
|
||||||
ON CONFLICT DO NOTHING",
|
).bind(&uid).bind(&actor.url).bind(follow_activity_id).bind(&created_at).execute(&self.pool).await?;
|
||||||
)
|
|
||||||
.bind(&uid)
|
|
||||||
.bind(&actor.url)
|
|
||||||
.bind(follow_activity_id)
|
|
||||||
.bind(&created_at)
|
|
||||||
.execute(&self.pool)
|
|
||||||
.await?;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,11 +307,7 @@ impl FederationRepository for PostgresFederationRepository {
|
|||||||
let uid = local_user_id.to_string();
|
let uid = local_user_id.to_string();
|
||||||
let row: Option<String> = sqlx::query_scalar(
|
let row: Option<String> = sqlx::query_scalar(
|
||||||
"SELECT follow_activity_id FROM ap_following WHERE local_user_id = $1 AND remote_actor_url = $2",
|
"SELECT follow_activity_id FROM ap_following WHERE local_user_id = $1 AND remote_actor_url = $2",
|
||||||
)
|
).bind(&uid).bind(remote_actor_url).fetch_optional(&self.pool).await?;
|
||||||
.bind(&uid)
|
|
||||||
.bind(remote_actor_url)
|
|
||||||
.fetch_optional(&self.pool)
|
|
||||||
.await?;
|
|
||||||
Ok(row)
|
Ok(row)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,27 +323,13 @@ impl FederationRepository for PostgresFederationRepository {
|
|||||||
|
|
||||||
async fn get_following(&self, local_user_id: uuid::Uuid) -> Result<Vec<RemoteActor>> {
|
async fn get_following(&self, local_user_id: uuid::Uuid) -> Result<Vec<RemoteActor>> {
|
||||||
let uid = local_user_id.to_string();
|
let uid = local_user_id.to_string();
|
||||||
let rows = sqlx::query(
|
let q = format!(
|
||||||
"SELECT a.url, a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.avatar_url
|
"SELECT a.url, {PG_ACTOR_COLS}
|
||||||
FROM ap_following f
|
FROM ap_following f INNER JOIN ap_remote_actors a ON a.url = f.remote_actor_url
|
||||||
INNER JOIN ap_remote_actors a ON a.url = f.remote_actor_url
|
WHERE f.local_user_id = $1 AND f.status = 'accepted'"
|
||||||
WHERE f.local_user_id = $1 AND f.status = 'accepted'",
|
);
|
||||||
)
|
let rows = sqlx::query(&q).bind(&uid).fetch_all(&self.pool).await?;
|
||||||
.bind(&uid)
|
Ok(rows.iter().map(|row| pg_remote_actor(row, "url")).collect())
|
||||||
.fetch_all(&self.pool)
|
|
||||||
.await?;
|
|
||||||
Ok(rows
|
|
||||||
.into_iter()
|
|
||||||
.map(|row| RemoteActor {
|
|
||||||
url: row.get("url"),
|
|
||||||
handle: row.get("handle"),
|
|
||||||
inbox_url: row.get("inbox_url"),
|
|
||||||
shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(),
|
|
||||||
display_name: row.try_get("display_name").ok().flatten(),
|
|
||||||
avatar_url: row.try_get("avatar_url").ok().flatten(),
|
|
||||||
outbox_url: row.try_get("outbox_url").ok().flatten(),
|
|
||||||
})
|
|
||||||
.collect())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn count_following(&self, local_user_id: uuid::Uuid) -> Result<usize> {
|
async fn count_following(&self, local_user_id: uuid::Uuid) -> Result<usize> {
|
||||||
@@ -317,84 +350,82 @@ impl FederationRepository for PostgresFederationRepository {
|
|||||||
limit: usize,
|
limit: usize,
|
||||||
) -> Result<Vec<RemoteActor>> {
|
) -> Result<Vec<RemoteActor>> {
|
||||||
let uid = local_user_id.to_string();
|
let uid = local_user_id.to_string();
|
||||||
let limit_i64 = limit as i64;
|
let q = format!(
|
||||||
let offset_i64 = offset as i64;
|
"SELECT a.url, {PG_ACTOR_COLS}
|
||||||
|
FROM ap_following f INNER JOIN ap_remote_actors a ON a.url = f.remote_actor_url
|
||||||
let rows = sqlx::query(
|
|
||||||
"SELECT a.url, a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.avatar_url
|
|
||||||
FROM ap_following f
|
|
||||||
INNER JOIN ap_remote_actors a ON a.url = f.remote_actor_url
|
|
||||||
WHERE f.local_user_id = $1 AND f.status = 'accepted'
|
WHERE f.local_user_id = $1 AND f.status = 'accepted'
|
||||||
ORDER BY f.created_at ASC
|
ORDER BY f.created_at ASC LIMIT $2 OFFSET $3"
|
||||||
LIMIT $2 OFFSET $3",
|
);
|
||||||
)
|
let rows = sqlx::query(&q)
|
||||||
.bind(&uid)
|
.bind(&uid)
|
||||||
.bind(limit_i64)
|
.bind(limit as i64)
|
||||||
.bind(offset_i64)
|
.bind(offset as i64)
|
||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
Ok(rows.iter().map(|row| pg_remote_actor(row, "url")).collect())
|
||||||
Ok(rows
|
|
||||||
.into_iter()
|
|
||||||
.map(|row| RemoteActor {
|
|
||||||
url: row.get("url"),
|
|
||||||
handle: row.get("handle"),
|
|
||||||
inbox_url: row.get("inbox_url"),
|
|
||||||
shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(),
|
|
||||||
display_name: row.try_get("display_name").ok().flatten(),
|
|
||||||
avatar_url: row.try_get("avatar_url").ok().flatten(),
|
|
||||||
outbox_url: row.try_get("outbox_url").ok().flatten(),
|
|
||||||
})
|
|
||||||
.collect())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn upsert_remote_actor(&self, actor: RemoteActor) -> Result<()> {
|
async fn update_following_status(
|
||||||
let now = Utc::now().naive_utc();
|
&self,
|
||||||
let fetched_at = datetime_to_str(&now);
|
local_user_id: uuid::Uuid,
|
||||||
sqlx::query(
|
remote_actor_url: &str,
|
||||||
"INSERT INTO ap_remote_actors (url, handle, inbox_url, shared_inbox_url, display_name, avatar_url, outbox_url, fetched_at)
|
status: FollowingStatus,
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8::timestamptz)
|
) -> Result<()> {
|
||||||
ON CONFLICT(url) DO UPDATE SET
|
let uid = local_user_id.to_string();
|
||||||
handle = EXCLUDED.handle,
|
let status_str = match status {
|
||||||
inbox_url = EXCLUDED.inbox_url,
|
FollowingStatus::Pending => "pending",
|
||||||
shared_inbox_url = EXCLUDED.shared_inbox_url,
|
FollowingStatus::Accepted => "accepted",
|
||||||
display_name = EXCLUDED.display_name,
|
};
|
||||||
avatar_url = EXCLUDED.avatar_url,
|
let result = sqlx::query(
|
||||||
outbox_url = COALESCE(EXCLUDED.outbox_url, ap_remote_actors.outbox_url),
|
"UPDATE ap_following SET status = $1 WHERE local_user_id = $2 AND remote_actor_url = $3",
|
||||||
fetched_at = EXCLUDED.fetched_at",
|
).bind(status_str).bind(&uid).bind(remote_actor_url).execute(&self.pool).await?;
|
||||||
)
|
if result.rows_affected() == 0 {
|
||||||
.bind(&actor.url)
|
tracing::warn!(local_user_id = %local_user_id, remote_actor_url, "update_following_status: no row found");
|
||||||
.bind(&actor.handle)
|
}
|
||||||
.bind(&actor.inbox_url)
|
|
||||||
.bind(&actor.shared_inbox_url)
|
|
||||||
.bind(&actor.display_name)
|
|
||||||
.bind(&actor.avatar_url)
|
|
||||||
.bind(&actor.outbox_url)
|
|
||||||
.bind(&fetched_at)
|
|
||||||
.execute(&self.pool)
|
|
||||||
.await?;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_remote_actor(&self, actor_url: &str) -> Result<Option<RemoteActor>> {
|
async fn get_following_outbox_url(
|
||||||
let row = sqlx::query(
|
&self,
|
||||||
"SELECT url, handle, inbox_url, shared_inbox_url, display_name, avatar_url
|
local_user_id: uuid::Uuid,
|
||||||
FROM ap_remote_actors WHERE url = $1",
|
remote_actor_url: &str,
|
||||||
)
|
) -> Result<Option<String>> {
|
||||||
.bind(actor_url)
|
let uid = local_user_id.to_string();
|
||||||
.fetch_optional(&self.pool)
|
let row: Option<Option<String>> = sqlx::query_scalar(
|
||||||
.await?;
|
"SELECT a.outbox_url FROM ap_following f INNER JOIN ap_remote_actors a ON a.url = f.remote_actor_url
|
||||||
Ok(row.map(|row| RemoteActor {
|
WHERE f.local_user_id = $1 AND f.remote_actor_url = $2",
|
||||||
url: row.get("url"),
|
).bind(&uid).bind(remote_actor_url).fetch_optional(&self.pool).await?;
|
||||||
handle: row.get("handle"),
|
Ok(row.flatten())
|
||||||
inbox_url: row.get("inbox_url"),
|
|
||||||
shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(),
|
|
||||||
display_name: row.try_get("display_name").ok().flatten(),
|
|
||||||
avatar_url: row.try_get("avatar_url").ok().flatten(),
|
|
||||||
outbox_url: row.try_get("outbox_url").ok().flatten(),
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn migrate_follower_actor(
|
||||||
|
&self,
|
||||||
|
old_actor_url: &str,
|
||||||
|
new_actor_url: &str,
|
||||||
|
) -> Result<Vec<uuid::Uuid>> {
|
||||||
|
let candidates: Vec<String> = sqlx::query_scalar(
|
||||||
|
"SELECT local_user_id FROM ap_following WHERE remote_actor_url = $1
|
||||||
|
AND local_user_id NOT IN (SELECT local_user_id FROM ap_following WHERE remote_actor_url = $2)",
|
||||||
|
).bind(old_actor_url).bind(new_actor_url).fetch_all(&self.pool).await?;
|
||||||
|
|
||||||
|
if candidates.is_empty() {
|
||||||
|
return Ok(vec![]);
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE ap_following SET remote_actor_url = $1 WHERE remote_actor_url = $2
|
||||||
|
AND local_user_id NOT IN (SELECT local_user_id FROM ap_following WHERE remote_actor_url = $1)",
|
||||||
|
).bind(new_actor_url).bind(old_actor_url).execute(&self.pool).await?;
|
||||||
|
|
||||||
|
candidates
|
||||||
|
.into_iter()
|
||||||
|
.map(|s| uuid::Uuid::parse_str(&s).map_err(|e| anyhow::anyhow!(e)))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl ActorRepository for PostgresFederationRepository {
|
||||||
async fn get_local_actor_keypair(
|
async fn get_local_actor_keypair(
|
||||||
&self,
|
&self,
|
||||||
user_id: uuid::Uuid,
|
user_id: uuid::Uuid,
|
||||||
@@ -420,86 +451,40 @@ impl FederationRepository for PostgresFederationRepository {
|
|||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO ap_local_actors (user_id, public_key, private_key, created_at)
|
"INSERT INTO ap_local_actors (user_id, public_key, private_key, created_at)
|
||||||
VALUES ($1, $2, $3, $4::timestamptz)
|
VALUES ($1, $2, $3, $4::timestamptz)
|
||||||
ON CONFLICT(user_id) DO UPDATE SET
|
ON CONFLICT(user_id) DO UPDATE SET public_key = EXCLUDED.public_key, private_key = EXCLUDED.private_key",
|
||||||
public_key = EXCLUDED.public_key,
|
).bind(&uid).bind(&public_key).bind(&private_key).bind(&created_at).execute(&self.pool).await?;
|
||||||
private_key = EXCLUDED.private_key",
|
|
||||||
)
|
|
||||||
.bind(&uid)
|
|
||||||
.bind(&public_key)
|
|
||||||
.bind(&private_key)
|
|
||||||
.bind(&created_at)
|
|
||||||
.execute(&self.pool)
|
|
||||||
.await?;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_pending_followers(&self, local_user_id: uuid::Uuid) -> Result<Vec<RemoteActor>> {
|
async fn upsert_remote_actor(&self, actor: RemoteActor) -> Result<()> {
|
||||||
let uid = local_user_id.to_string();
|
let now = Utc::now().naive_utc();
|
||||||
let rows = sqlx::query(
|
let fetched_at = datetime_to_str(&now);
|
||||||
"SELECT f.remote_actor_url, a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.avatar_url
|
let aka_json = serde_json::to_string(&actor.also_known_as).unwrap_or_default();
|
||||||
FROM ap_followers f
|
sqlx::query(
|
||||||
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
|
"INSERT INTO ap_remote_actors (url, handle, inbox_url, shared_inbox_url, display_name, avatar_url, outbox_url, bio, banner_url, followers_url, following_url, also_known_as, fetched_at)
|
||||||
WHERE f.local_user_id = $1 AND f.status = 'pending'",
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13::timestamptz)
|
||||||
|
ON CONFLICT(url) DO UPDATE SET
|
||||||
|
handle=EXCLUDED.handle, inbox_url=EXCLUDED.inbox_url, shared_inbox_url=EXCLUDED.shared_inbox_url,
|
||||||
|
display_name=EXCLUDED.display_name, avatar_url=EXCLUDED.avatar_url,
|
||||||
|
outbox_url=COALESCE(EXCLUDED.outbox_url, ap_remote_actors.outbox_url),
|
||||||
|
bio=EXCLUDED.bio, banner_url=EXCLUDED.banner_url, followers_url=EXCLUDED.followers_url,
|
||||||
|
following_url=EXCLUDED.following_url, also_known_as=EXCLUDED.also_known_as, fetched_at=EXCLUDED.fetched_at",
|
||||||
)
|
)
|
||||||
.bind(&uid)
|
.bind(&actor.url).bind(&actor.handle).bind(&actor.inbox_url).bind(&actor.shared_inbox_url)
|
||||||
.fetch_all(&self.pool)
|
.bind(&actor.display_name).bind(&actor.avatar_url).bind(&actor.outbox_url)
|
||||||
.await?;
|
.bind(&actor.bio).bind(&actor.banner_url).bind(&actor.followers_url).bind(&actor.following_url)
|
||||||
Ok(rows
|
.bind(&aka_json).bind(&fetched_at)
|
||||||
.into_iter()
|
.execute(&self.pool).await?;
|
||||||
.map(|row| RemoteActor {
|
|
||||||
url: row.get("remote_actor_url"),
|
|
||||||
handle: row.try_get("handle").unwrap_or_default(),
|
|
||||||
inbox_url: row.try_get("inbox_url").unwrap_or_default(),
|
|
||||||
shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(),
|
|
||||||
display_name: row.try_get("display_name").ok().flatten(),
|
|
||||||
avatar_url: row.try_get("avatar_url").ok().flatten(),
|
|
||||||
outbox_url: row.try_get("outbox_url").ok().flatten(),
|
|
||||||
})
|
|
||||||
.collect())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn update_following_status(
|
|
||||||
&self,
|
|
||||||
local_user_id: uuid::Uuid,
|
|
||||||
remote_actor_url: &str,
|
|
||||||
status: FollowingStatus,
|
|
||||||
) -> Result<()> {
|
|
||||||
let uid = local_user_id.to_string();
|
|
||||||
let status_str = match status {
|
|
||||||
FollowingStatus::Pending => "pending",
|
|
||||||
FollowingStatus::Accepted => "accepted",
|
|
||||||
};
|
|
||||||
let result = sqlx::query(
|
|
||||||
"UPDATE ap_following SET status = $1 WHERE local_user_id = $2 AND remote_actor_url = $3",
|
|
||||||
)
|
|
||||||
.bind(status_str)
|
|
||||||
.bind(&uid)
|
|
||||||
.bind(remote_actor_url)
|
|
||||||
.execute(&self.pool)
|
|
||||||
.await?;
|
|
||||||
if result.rows_affected() == 0 {
|
|
||||||
tracing::warn!(local_user_id = %local_user_id, remote_actor_url, "update_following_status: no row found");
|
|
||||||
}
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_following_outbox_url(
|
async fn get_remote_actor(&self, actor_url: &str) -> Result<Option<RemoteActor>> {
|
||||||
&self,
|
let q = format!("SELECT url, {PG_ACTOR_COLS} FROM ap_remote_actors a WHERE url = $1");
|
||||||
local_user_id: uuid::Uuid,
|
let row = sqlx::query(&q)
|
||||||
remote_actor_url: &str,
|
.bind(actor_url)
|
||||||
) -> Result<Option<String>> {
|
.fetch_optional(&self.pool)
|
||||||
let uid = local_user_id.to_string();
|
.await?;
|
||||||
let row: Option<Option<String>> = sqlx::query_scalar(
|
Ok(row.as_ref().map(|r| pg_remote_actor(r, "url")))
|
||||||
"SELECT a.outbox_url
|
|
||||||
FROM ap_following f
|
|
||||||
INNER JOIN ap_remote_actors a ON a.url = f.remote_actor_url
|
|
||||||
WHERE f.local_user_id = $1 AND f.remote_actor_url = $2",
|
|
||||||
)
|
|
||||||
.bind(&uid)
|
|
||||||
.bind(remote_actor_url)
|
|
||||||
.fetch_optional(&self.pool)
|
|
||||||
.await?;
|
|
||||||
Ok(row.flatten())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn add_announce(
|
async fn add_announce(
|
||||||
@@ -510,15 +495,17 @@ impl FederationRepository for PostgresFederationRepository {
|
|||||||
announced_at: chrono::DateTime<chrono::Utc>,
|
announced_at: chrono::DateTime<chrono::Utc>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let ts = announced_at.format("%Y-%m-%d %H:%M:%S").to_string();
|
let ts = announced_at.format("%Y-%m-%d %H:%M:%S").to_string();
|
||||||
sqlx::query(
|
sqlx::query("INSERT INTO ap_announces (id, object_url, actor_url, announced_at) VALUES ($1, $2, $3, $4) ON CONFLICT (id) DO NOTHING")
|
||||||
"INSERT INTO ap_announces (id, object_url, actor_url, announced_at) VALUES ($1, $2, $3, $4) ON CONFLICT (id) DO NOTHING",
|
.bind(activity_id).bind(object_url).bind(actor_url).bind(&ts).execute(&self.pool).await?;
|
||||||
)
|
Ok(())
|
||||||
.bind(activity_id)
|
}
|
||||||
.bind(object_url)
|
|
||||||
.bind(actor_url)
|
async fn remove_announce(&self, activity_id: &str, actor_url: &str) -> Result<()> {
|
||||||
.bind(&ts)
|
sqlx::query("DELETE FROM ap_announces WHERE id = $1 AND actor_url = $2")
|
||||||
.execute(&self.pool)
|
.bind(activity_id)
|
||||||
.await?;
|
.bind(actor_url)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -529,22 +516,16 @@ impl FederationRepository for PostgresFederationRepository {
|
|||||||
.await?;
|
.await?;
|
||||||
Ok(row.get::<i64, _>("cnt") as usize)
|
Ok(row.get::<i64, _>("cnt") as usize)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl BlocklistRepository for PostgresFederationRepository {
|
||||||
async fn add_blocked_domain(&self, domain: &str, reason: Option<&str>) -> Result<()> {
|
async fn add_blocked_domain(&self, domain: &str, reason: Option<&str>) -> Result<()> {
|
||||||
let now = Utc::now().naive_utc();
|
let ts = datetime_to_str(&Utc::now().naive_utc());
|
||||||
let ts = datetime_to_str(&now);
|
sqlx::query("INSERT INTO blocked_domains (domain, reason, blocked_at) VALUES ($1, $2, $3) ON CONFLICT(domain) DO UPDATE SET reason = EXCLUDED.reason")
|
||||||
sqlx::query(
|
.bind(domain).bind(reason).bind(&ts).execute(&self.pool).await?;
|
||||||
"INSERT INTO blocked_domains (domain, reason, blocked_at) VALUES ($1, $2, $3)
|
|
||||||
ON CONFLICT(domain) DO UPDATE SET reason = EXCLUDED.reason",
|
|
||||||
)
|
|
||||||
.bind(domain)
|
|
||||||
.bind(reason)
|
|
||||||
.bind(&ts)
|
|
||||||
.execute(&self.pool)
|
|
||||||
.await?;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn remove_blocked_domain(&self, domain: &str) -> Result<()> {
|
async fn remove_blocked_domain(&self, domain: &str) -> Result<()> {
|
||||||
sqlx::query("DELETE FROM blocked_domains WHERE domain = $1")
|
sqlx::query("DELETE FROM blocked_domains WHERE domain = $1")
|
||||||
.bind(domain)
|
.bind(domain)
|
||||||
@@ -552,7 +533,6 @@ impl FederationRepository for PostgresFederationRepository {
|
|||||||
.await?;
|
.await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_blocked_domains(&self) -> Result<Vec<BlockedDomain>> {
|
async fn get_blocked_domains(&self) -> Result<Vec<BlockedDomain>> {
|
||||||
let rows = sqlx::query(
|
let rows = sqlx::query(
|
||||||
"SELECT domain, reason, blocked_at FROM blocked_domains ORDER BY blocked_at DESC",
|
"SELECT domain, reason, blocked_at FROM blocked_domains ORDER BY blocked_at DESC",
|
||||||
@@ -568,7 +548,6 @@ impl FederationRepository for PostgresFederationRepository {
|
|||||||
})
|
})
|
||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn is_domain_blocked(&self, domain: &str) -> Result<bool> {
|
async fn is_domain_blocked(&self, domain: &str) -> Result<bool> {
|
||||||
let count: i64 =
|
let count: i64 =
|
||||||
sqlx::query_scalar("SELECT COUNT(*) FROM blocked_domains WHERE domain = $1")
|
sqlx::query_scalar("SELECT COUNT(*) FROM blocked_domains WHERE domain = $1")
|
||||||
@@ -577,23 +556,13 @@ impl FederationRepository for PostgresFederationRepository {
|
|||||||
.await?;
|
.await?;
|
||||||
Ok(count > 0)
|
Ok(count > 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn add_blocked_actor(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()> {
|
async fn add_blocked_actor(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()> {
|
||||||
let uid = local_user_id.to_string();
|
let uid = local_user_id.to_string();
|
||||||
let ts = datetime_to_str(&Utc::now().naive_utc());
|
let ts = datetime_to_str(&Utc::now().naive_utc());
|
||||||
sqlx::query(
|
sqlx::query("INSERT INTO blocked_actors (local_user_id, remote_actor_url, blocked_at) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING")
|
||||||
"INSERT INTO blocked_actors (local_user_id, remote_actor_url, blocked_at)
|
.bind(&uid).bind(actor_url).bind(&ts).execute(&self.pool).await?;
|
||||||
VALUES ($1, $2, $3)
|
|
||||||
ON CONFLICT DO NOTHING",
|
|
||||||
)
|
|
||||||
.bind(&uid)
|
|
||||||
.bind(actor_url)
|
|
||||||
.bind(&ts)
|
|
||||||
.execute(&self.pool)
|
|
||||||
.await?;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn remove_blocked_actor(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()> {
|
async fn remove_blocked_actor(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()> {
|
||||||
let uid = local_user_id.to_string();
|
let uid = local_user_id.to_string();
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
@@ -605,70 +574,42 @@ impl FederationRepository for PostgresFederationRepository {
|
|||||||
.await?;
|
.await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_blocked_actors(&self, local_user_id: uuid::Uuid) -> Result<Vec<String>> {
|
async fn get_blocked_actors(&self, local_user_id: uuid::Uuid) -> Result<Vec<String>> {
|
||||||
let uid = local_user_id.to_string();
|
let uid = local_user_id.to_string();
|
||||||
let rows = sqlx::query(
|
let rows = sqlx::query("SELECT remote_actor_url FROM blocked_actors WHERE local_user_id = $1 ORDER BY blocked_at DESC")
|
||||||
"SELECT remote_actor_url FROM blocked_actors WHERE local_user_id = $1 ORDER BY blocked_at DESC",
|
.bind(&uid).fetch_all(&self.pool).await?;
|
||||||
)
|
|
||||||
.bind(&uid)
|
|
||||||
.fetch_all(&self.pool)
|
|
||||||
.await?;
|
|
||||||
Ok(rows
|
Ok(rows
|
||||||
.iter()
|
.iter()
|
||||||
.map(|r| r.get::<String, _>("remote_actor_url"))
|
.map(|r| r.get::<String, _>("remote_actor_url"))
|
||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn is_actor_blocked(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<bool> {
|
async fn is_actor_blocked(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<bool> {
|
||||||
let uid = local_user_id.to_string();
|
let uid = local_user_id.to_string();
|
||||||
let count: i64 = sqlx::query_scalar(
|
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM blocked_actors WHERE local_user_id = $1 AND remote_actor_url = $2")
|
||||||
"SELECT COUNT(*) FROM blocked_actors WHERE local_user_id = $1 AND remote_actor_url = $2",
|
.bind(&uid).bind(actor_url).fetch_one(&self.pool).await?;
|
||||||
)
|
|
||||||
.bind(&uid)
|
|
||||||
.bind(actor_url)
|
|
||||||
.fetch_one(&self.pool)
|
|
||||||
.await?;
|
|
||||||
Ok(count > 0)
|
Ok(count > 0)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn migrate_follower_actor(
|
#[async_trait]
|
||||||
&self,
|
impl ActivityRepository for PostgresFederationRepository {
|
||||||
old_actor_url: &str,
|
async fn is_activity_processed(&self, activity_id: &str) -> Result<bool> {
|
||||||
new_actor_url: &str,
|
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM ap_activities WHERE id = $1")
|
||||||
) -> Result<Vec<uuid::Uuid>> {
|
.bind(activity_id)
|
||||||
let candidates: Vec<String> = sqlx::query_scalar(
|
.fetch_one(&self.pool)
|
||||||
"SELECT local_user_id FROM ap_following
|
.await?;
|
||||||
WHERE remote_actor_url = $1
|
Ok(count > 0)
|
||||||
AND local_user_id NOT IN (
|
}
|
||||||
SELECT local_user_id FROM ap_following WHERE remote_actor_url = $2
|
async fn mark_activity_processed(&self, activity_id: &str) -> Result<()> {
|
||||||
)",
|
let ts = datetime_to_str(&Utc::now().naive_utc());
|
||||||
)
|
|
||||||
.bind(old_actor_url)
|
|
||||||
.bind(new_actor_url)
|
|
||||||
.fetch_all(&self.pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if candidates.is_empty() {
|
|
||||||
return Ok(vec![]);
|
|
||||||
}
|
|
||||||
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"UPDATE ap_following SET remote_actor_url = $1
|
"INSERT INTO ap_activities (id, processed_at) VALUES ($1, $2) ON CONFLICT DO NOTHING",
|
||||||
WHERE remote_actor_url = $2
|
|
||||||
AND local_user_id NOT IN (
|
|
||||||
SELECT local_user_id FROM ap_following WHERE remote_actor_url = $1
|
|
||||||
)",
|
|
||||||
)
|
)
|
||||||
.bind(new_actor_url)
|
.bind(activity_id)
|
||||||
.bind(old_actor_url)
|
.bind(&ts)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
Ok(())
|
||||||
candidates
|
|
||||||
.into_iter()
|
|
||||||
.map(|s| uuid::Uuid::parse_str(&s).map_err(|e| anyhow::anyhow!(e)))
|
|
||||||
.collect()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -807,6 +748,64 @@ impl domain::ports::SocialQueryPort for PostgresFederationRepository {
|
|||||||
)
|
)
|
||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn count_following(
|
||||||
|
&self,
|
||||||
|
user_id: uuid::Uuid,
|
||||||
|
) -> Result<usize, domain::errors::DomainError> {
|
||||||
|
let uid = user_id.to_string();
|
||||||
|
let count: i64 = sqlx::query_scalar(
|
||||||
|
"SELECT COUNT(*) FROM ap_following WHERE local_user_id = $1 AND status = 'accepted'",
|
||||||
|
)
|
||||||
|
.bind(&uid)
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| domain::errors::DomainError::InfrastructureError(e.to_string()))?;
|
||||||
|
Ok(count as usize)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn count_accepted_followers(
|
||||||
|
&self,
|
||||||
|
user_id: uuid::Uuid,
|
||||||
|
) -> Result<usize, domain::errors::DomainError> {
|
||||||
|
let uid = user_id.to_string();
|
||||||
|
let count: i64 = sqlx::query_scalar(
|
||||||
|
"SELECT COUNT(*) FROM ap_followers WHERE local_user_id = $1 AND status = 'accepted'",
|
||||||
|
)
|
||||||
|
.bind(&uid)
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| domain::errors::DomainError::InfrastructureError(e.to_string()))?;
|
||||||
|
Ok(count as usize)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_pending_followers(
|
||||||
|
&self,
|
||||||
|
user_id: uuid::Uuid,
|
||||||
|
) -> Result<Vec<domain::ports::PendingFollowerInfo>, domain::errors::DomainError> {
|
||||||
|
let uid = user_id.to_string();
|
||||||
|
let rows = sqlx::query_as::<_, (String, String, Option<String>, Option<String>)>(
|
||||||
|
"SELECT ar.url, ar.handle, ar.display_name, ar.avatar_url
|
||||||
|
FROM ap_followers f
|
||||||
|
JOIN ap_remote_actors ar ON ar.url = f.remote_actor_url
|
||||||
|
WHERE f.local_user_id = $1 AND f.status = 'pending'",
|
||||||
|
)
|
||||||
|
.bind(&uid)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| domain::errors::DomainError::InfrastructureError(e.to_string()))?;
|
||||||
|
Ok(rows
|
||||||
|
.into_iter()
|
||||||
|
.map(
|
||||||
|
|(url, handle, display_name, avatar_url)| domain::ports::PendingFollowerInfo {
|
||||||
|
url,
|
||||||
|
handle,
|
||||||
|
display_name,
|
||||||
|
avatar_url,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -913,16 +912,12 @@ impl RemoteWatchlistRepository for PostgresFederationRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn wire(
|
pub fn wire(pool: sqlx::PgPool) -> activitypub::FederationRepos {
|
||||||
pool: sqlx::PgPool,
|
|
||||||
) -> (
|
|
||||||
std::sync::Arc<dyn activitypub::FederationRepository>,
|
|
||||||
std::sync::Arc<dyn domain::ports::SocialQueryPort>,
|
|
||||||
std::sync::Arc<dyn activitypub::RemoteReviewRepository>,
|
|
||||||
std::sync::Arc<dyn domain::ports::RemoteWatchlistRepository>,
|
|
||||||
) {
|
|
||||||
let fed = std::sync::Arc::new(PostgresFederationRepository::new(pool));
|
let fed = std::sync::Arc::new(PostgresFederationRepository::new(pool));
|
||||||
(
|
(
|
||||||
|
std::sync::Arc::clone(&fed) as _,
|
||||||
|
std::sync::Arc::clone(&fed) as _,
|
||||||
|
std::sync::Arc::clone(&fed) as _,
|
||||||
std::sync::Arc::clone(&fed) as _,
|
std::sync::Arc::clone(&fed) as _,
|
||||||
std::sync::Arc::clone(&fed) as _,
|
std::sync::Arc::clone(&fed) as _,
|
||||||
std::sync::Arc::clone(&fed) as _,
|
std::sync::Arc::clone(&fed) as _,
|
||||||
|
|||||||
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 {
|
let movie = MovieRow {
|
||||||
id: row.try_get("m_id").map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
|
id: row
|
||||||
external_metadata_id: row.try_get("external_metadata_id").map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
|
.try_get("m_id")
|
||||||
title: row.try_get("title").map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
|
||||||
release_year: row.try_get("release_year").map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
|
external_metadata_id: row
|
||||||
director: row.try_get("director").map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
|
.try_get("external_metadata_id")
|
||||||
poster_path: row.try_get("poster_path").map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
|
||||||
|
title: row
|
||||||
|
.try_get("title")
|
||||||
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
|
||||||
|
release_year: row
|
||||||
|
.try_get("release_year")
|
||||||
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
|
||||||
|
director: row
|
||||||
|
.try_get("director")
|
||||||
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
|
||||||
|
poster_path: row
|
||||||
|
.try_get("poster_path")
|
||||||
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
|
||||||
}
|
}
|
||||||
.into_domain()?;
|
.into_domain()?;
|
||||||
Ok(WatchlistWithMovie { entry, movie })
|
Ok(WatchlistWithMovie { entry, movie })
|
||||||
@@ -103,10 +115,7 @@ impl LocalApContentQuery for PostgresApContentQuery {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_review_by_id(
|
async fn get_review_by_id(&self, review_id: &ReviewId) -> Result<Option<Review>, DomainError> {
|
||||||
&self,
|
|
||||||
review_id: &ReviewId,
|
|
||||||
) -> Result<Option<Review>, DomainError> {
|
|
||||||
let id = review_id.value().to_string();
|
let id = review_id.value().to_string();
|
||||||
sqlx::query_as::<_, ReviewRow>(
|
sqlx::query_as::<_, ReviewRow>(
|
||||||
"SELECT id, movie_id, user_id, rating, comment,
|
"SELECT id, movie_id, user_id, rating, comment,
|
||||||
@@ -145,4 +154,55 @@ impl LocalApContentQuery for PostgresApContentQuery {
|
|||||||
.map_err(Self::map_err)?;
|
.map_err(Self::map_err)?;
|
||||||
Ok(count as u64)
|
Ok(count as u64)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn get_local_reviews_page(
|
||||||
|
&self,
|
||||||
|
user_id: &UserId,
|
||||||
|
before: Option<chrono::NaiveDateTime>,
|
||||||
|
limit: usize,
|
||||||
|
) -> Result<Vec<DiaryEntry>, DomainError> {
|
||||||
|
let uid = user_id.value().to_string();
|
||||||
|
let limit_i64 = limit as i64;
|
||||||
|
|
||||||
|
let rows = if let Some(before_ts) = before {
|
||||||
|
let ts = before_ts.format("%Y-%m-%d %H:%M:%S").to_string();
|
||||||
|
sqlx::query_as::<_, DiaryRow>(
|
||||||
|
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
|
||||||
|
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment,
|
||||||
|
to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
|
||||||
|
to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
|
||||||
|
r.remote_actor_url
|
||||||
|
FROM reviews r
|
||||||
|
INNER JOIN movies m ON m.id = r.movie_id
|
||||||
|
WHERE r.user_id = $1 AND r.remote_actor_url IS NULL AND r.watched_at < $2::timestamptz
|
||||||
|
ORDER BY r.watched_at DESC
|
||||||
|
LIMIT $3",
|
||||||
|
)
|
||||||
|
.bind(&uid)
|
||||||
|
.bind(&ts)
|
||||||
|
.bind(limit_i64)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(Self::map_err)?
|
||||||
|
} else {
|
||||||
|
sqlx::query_as::<_, DiaryRow>(
|
||||||
|
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
|
||||||
|
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment,
|
||||||
|
to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
|
||||||
|
to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
|
||||||
|
r.remote_actor_url
|
||||||
|
FROM reviews r
|
||||||
|
INNER JOIN movies m ON m.id = r.movie_id
|
||||||
|
WHERE r.user_id = $1 AND r.remote_actor_url IS NULL
|
||||||
|
ORDER BY r.watched_at DESC
|
||||||
|
LIMIT $2",
|
||||||
|
)
|
||||||
|
.bind(&uid)
|
||||||
|
.bind(limit_i64)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(Self::map_err)?
|
||||||
|
};
|
||||||
|
rows.into_iter().map(DiaryRow::into_domain).collect()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
use chrono::NaiveDateTime;
|
use chrono::NaiveDateTime;
|
||||||
use domain::{
|
use domain::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
models::{DiaryEntry, FeedEntry, Movie, MovieSummary, Review, ReviewSource, UserSummary},
|
models::{
|
||||||
|
DiaryEntry, FeedEntry, Movie, MovieSummary, PersistedReview, Review, ReviewSource,
|
||||||
|
UserSummary,
|
||||||
|
},
|
||||||
value_objects::{
|
value_objects::{
|
||||||
Comment, Email, ExternalMetadataId, MovieId, MovieTitle, PosterPath, Rating, ReleaseYear,
|
Comment, Email, ExternalMetadataId, MovieId, MovieTitle, PosterPath, Rating, ReleaseYear,
|
||||||
ReviewId, UserId,
|
ReviewId, UserId,
|
||||||
@@ -102,9 +105,16 @@ impl ReviewRow {
|
|||||||
None => ReviewSource::Local,
|
None => ReviewSource::Local,
|
||||||
Some(url) => ReviewSource::Remote { actor_url: url },
|
Some(url) => ReviewSource::Remote { actor_url: url },
|
||||||
};
|
};
|
||||||
Ok(Review::from_persistence(
|
Ok(Review::from_persistence(PersistedReview {
|
||||||
id, movie_id, user_id, rating, comment, watched_at, created_at, source,
|
id,
|
||||||
))
|
movie_id,
|
||||||
|
user_id,
|
||||||
|
rating,
|
||||||
|
comment,
|
||||||
|
watched_at,
|
||||||
|
created_at,
|
||||||
|
source,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use sqlx::PgPool;
|
use sqlx::{PgPool, Row};
|
||||||
|
|
||||||
use domain::{
|
use domain::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
@@ -33,116 +33,67 @@ impl PostgresUserRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn row_to_user(
|
fn row_to_user(
|
||||||
id_str: String,
|
row: &sqlx::postgres::PgRow,
|
||||||
email_str: String,
|
|
||||||
username_str: String,
|
|
||||||
hash_str: String,
|
|
||||||
role: UserRole,
|
|
||||||
bio: Option<String>,
|
|
||||||
avatar_path: Option<String>,
|
|
||||||
banner_path: Option<String>,
|
|
||||||
also_known_as: Option<String>,
|
|
||||||
profile_fields: Vec<ProfileField>,
|
profile_fields: Vec<ProfileField>,
|
||||||
) -> Result<User, DomainError> {
|
) -> Result<User, DomainError> {
|
||||||
|
let id_str: String = row.get("id");
|
||||||
let id = uuid::Uuid::parse_str(&id_str)
|
let id = uuid::Uuid::parse_str(&id_str)
|
||||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||||
let email =
|
let email = Email::new(row.get::<String, _>("email"))
|
||||||
Email::new(email_str).map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
|
||||||
let username = Username::new(username_str)
|
|
||||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||||
let hash = PasswordHash::new(hash_str)
|
let username = Username::new(row.get::<String, _>("username"))
|
||||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||||
|
let hash = PasswordHash::new(row.get::<String, _>("password_hash"))
|
||||||
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||||
|
let role_str: String = row.get("role");
|
||||||
Ok(User::from_persistence(
|
Ok(User::from_persistence(
|
||||||
UserId::from_uuid(id),
|
UserId::from_uuid(id),
|
||||||
email,
|
email,
|
||||||
username,
|
username,
|
||||||
hash,
|
hash,
|
||||||
role,
|
Self::parse_role(&role_str),
|
||||||
bio,
|
domain::models::UserProfile {
|
||||||
avatar_path,
|
display_name: row.try_get("display_name").ok().flatten(),
|
||||||
banner_path,
|
bio: row.try_get("bio").ok().flatten(),
|
||||||
also_known_as,
|
avatar_path: row.try_get("avatar_path").ok().flatten(),
|
||||||
profile_fields,
|
banner_path: row.try_get("banner_path").ok().flatten(),
|
||||||
|
also_known_as: row.try_get("also_known_as").ok().flatten(),
|
||||||
|
profile_fields,
|
||||||
|
},
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PG_USER_COLS: &str = "id, email, username, password_hash, role, display_name, bio, avatar_path, banner_path, also_known_as";
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl UserRepository for PostgresUserRepository {
|
impl UserRepository for PostgresUserRepository {
|
||||||
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError> {
|
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError> {
|
||||||
let email_str = email.value();
|
let email_str = email.value();
|
||||||
#[derive(sqlx::FromRow)]
|
let row = sqlx::query(&format!(
|
||||||
struct Row {
|
"SELECT {PG_USER_COLS} FROM users WHERE email = $1"
|
||||||
id: String,
|
))
|
||||||
email: String,
|
|
||||||
username: String,
|
|
||||||
password_hash: String,
|
|
||||||
role: String,
|
|
||||||
bio: Option<String>,
|
|
||||||
avatar_path: Option<String>,
|
|
||||||
banner_path: Option<String>,
|
|
||||||
also_known_as: Option<String>,
|
|
||||||
}
|
|
||||||
let row = sqlx::query_as::<_, Row>(
|
|
||||||
"SELECT id, email, username, password_hash, role, bio, avatar_path, banner_path, also_known_as FROM users WHERE email = $1",
|
|
||||||
)
|
|
||||||
.bind(email_str)
|
.bind(email_str)
|
||||||
.fetch_optional(&self.pool)
|
.fetch_optional(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(Self::map_err)?;
|
.map_err(Self::map_err)?;
|
||||||
row.map(|r| {
|
row.as_ref()
|
||||||
Self::row_to_user(
|
.map(|r| Self::row_to_user(r, vec![]))
|
||||||
r.id,
|
.transpose()
|
||||||
r.email,
|
|
||||||
r.username,
|
|
||||||
r.password_hash,
|
|
||||||
Self::parse_role(&r.role),
|
|
||||||
r.bio,
|
|
||||||
r.avatar_path,
|
|
||||||
r.banner_path,
|
|
||||||
r.also_known_as,
|
|
||||||
vec![],
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.transpose()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn find_by_username(&self, username: &Username) -> Result<Option<User>, DomainError> {
|
async fn find_by_username(&self, username: &Username) -> Result<Option<User>, DomainError> {
|
||||||
let username_str = username.value();
|
let username_str = username.value();
|
||||||
#[derive(sqlx::FromRow)]
|
let row = sqlx::query(&format!(
|
||||||
struct Row {
|
"SELECT {PG_USER_COLS} FROM users WHERE username = $1"
|
||||||
id: String,
|
))
|
||||||
email: String,
|
|
||||||
username: String,
|
|
||||||
password_hash: String,
|
|
||||||
role: String,
|
|
||||||
bio: Option<String>,
|
|
||||||
avatar_path: Option<String>,
|
|
||||||
banner_path: Option<String>,
|
|
||||||
also_known_as: Option<String>,
|
|
||||||
}
|
|
||||||
let row = sqlx::query_as::<_, Row>(
|
|
||||||
"SELECT id, email, username, password_hash, role, bio, avatar_path, banner_path, also_known_as FROM users WHERE username = $1",
|
|
||||||
)
|
|
||||||
.bind(username_str)
|
.bind(username_str)
|
||||||
.fetch_optional(&self.pool)
|
.fetch_optional(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(Self::map_err)?;
|
.map_err(Self::map_err)?;
|
||||||
row.map(|r| {
|
row.as_ref()
|
||||||
Self::row_to_user(
|
.map(|r| Self::row_to_user(r, vec![]))
|
||||||
r.id,
|
.transpose()
|
||||||
r.email,
|
|
||||||
r.username,
|
|
||||||
r.password_hash,
|
|
||||||
Self::parse_role(&r.role),
|
|
||||||
r.bio,
|
|
||||||
r.avatar_path,
|
|
||||||
r.banner_path,
|
|
||||||
r.also_known_as,
|
|
||||||
vec![],
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.transpose()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn save(&self, user: &User) -> Result<(), DomainError> {
|
async fn save(&self, user: &User) -> Result<(), DomainError> {
|
||||||
@@ -185,34 +136,15 @@ impl UserRepository for PostgresUserRepository {
|
|||||||
|
|
||||||
async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError> {
|
async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError> {
|
||||||
let id_str = id.value().to_string();
|
let id_str = id.value().to_string();
|
||||||
#[derive(sqlx::FromRow)]
|
let row = sqlx::query(&format!("SELECT {PG_USER_COLS} FROM users WHERE id = $1"))
|
||||||
struct Row {
|
.bind(&id_str)
|
||||||
id: String,
|
.fetch_optional(&self.pool)
|
||||||
email: String,
|
.await
|
||||||
username: String,
|
.map_err(Self::map_err)?;
|
||||||
password_hash: String,
|
|
||||||
role: String,
|
|
||||||
bio: Option<String>,
|
|
||||||
avatar_path: Option<String>,
|
|
||||||
banner_path: Option<String>,
|
|
||||||
also_known_as: Option<String>,
|
|
||||||
}
|
|
||||||
let row = sqlx::query_as::<_, Row>(
|
|
||||||
"SELECT id, email, username, password_hash, role, bio, avatar_path, banner_path, also_known_as FROM users WHERE id = $1",
|
|
||||||
)
|
|
||||||
.bind(&id_str)
|
|
||||||
.fetch_optional(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(Self::map_err)?;
|
|
||||||
|
|
||||||
let Some(r) = row else { return Ok(None) };
|
let Some(r) = row else { return Ok(None) };
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
let field_rows = sqlx::query(
|
||||||
struct FieldRow {
|
|
||||||
name: String,
|
|
||||||
value: String,
|
|
||||||
}
|
|
||||||
let field_rows = sqlx::query_as::<_, FieldRow>(
|
|
||||||
"SELECT name, value FROM user_profile_fields WHERE user_id = $1 ORDER BY position ASC",
|
"SELECT name, value FROM user_profile_fields WHERE user_id = $1 ORDER BY position ASC",
|
||||||
)
|
)
|
||||||
.bind(&id_str)
|
.bind(&id_str)
|
||||||
@@ -221,44 +153,30 @@ impl UserRepository for PostgresUserRepository {
|
|||||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||||
|
|
||||||
let profile_fields = field_rows
|
let profile_fields = field_rows
|
||||||
.into_iter()
|
.iter()
|
||||||
.map(|f| ProfileField {
|
.map(|f| ProfileField {
|
||||||
name: f.name,
|
name: f.get("name"),
|
||||||
value: f.value,
|
value: f.get("value"),
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
Self::row_to_user(
|
Self::row_to_user(&r, profile_fields).map(Some)
|
||||||
r.id,
|
|
||||||
r.email,
|
|
||||||
r.username,
|
|
||||||
r.password_hash,
|
|
||||||
Self::parse_role(&r.role),
|
|
||||||
r.bio,
|
|
||||||
r.avatar_path,
|
|
||||||
r.banner_path,
|
|
||||||
r.also_known_as,
|
|
||||||
profile_fields,
|
|
||||||
)
|
|
||||||
.map(Some)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn update_profile(
|
async fn update_profile(
|
||||||
&self,
|
&self,
|
||||||
user_id: &UserId,
|
user_id: &UserId,
|
||||||
bio: Option<String>,
|
profile: &domain::models::UserProfile,
|
||||||
avatar_path: Option<String>,
|
|
||||||
banner_path: Option<String>,
|
|
||||||
also_known_as: Option<String>,
|
|
||||||
) -> Result<(), DomainError> {
|
) -> Result<(), DomainError> {
|
||||||
let id_str = user_id.value().to_string();
|
let id_str = user_id.value().to_string();
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"UPDATE users SET bio = $1, avatar_path = $2, banner_path = $3, also_known_as = $4 WHERE id = $5",
|
"UPDATE users SET display_name = $1, bio = $2, avatar_path = $3, banner_path = $4, also_known_as = $5 WHERE id = $6",
|
||||||
)
|
)
|
||||||
.bind(&bio)
|
.bind(&profile.display_name)
|
||||||
.bind(&avatar_path)
|
.bind(&profile.bio)
|
||||||
.bind(&banner_path)
|
.bind(&profile.avatar_path)
|
||||||
.bind(&also_known_as)
|
.bind(&profile.banner_path)
|
||||||
|
.bind(&profile.also_known_as)
|
||||||
.bind(&id_str)
|
.bind(&id_str)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -6,9 +6,10 @@ edition = "2024"
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
sqlx = { workspace = true }
|
sqlx = { workspace = true }
|
||||||
activitypub = { workspace = true }
|
activitypub = { workspace = true }
|
||||||
k-ap = { git = "https://git.gabrielkaszewski.dev/GKaszewski/k-ap.git", tag = "v0.1.10" }
|
k-ap = { version = "0.3.1", registry = "gitea" }
|
||||||
domain = { workspace = true }
|
domain = { workspace = true }
|
||||||
anyhow = { workspace = true }
|
anyhow = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
chrono = { workspace = true }
|
chrono = { workspace = true }
|
||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
|
|||||||
@@ -4,11 +4,12 @@ use chrono::{NaiveDateTime, Utc};
|
|||||||
use sqlx::{Row, SqlitePool};
|
use sqlx::{Row, SqlitePool};
|
||||||
|
|
||||||
use activitypub::RemoteReviewRepository;
|
use activitypub::RemoteReviewRepository;
|
||||||
use k_ap::{
|
|
||||||
BlockedDomain, FederationRepository, Follower, FollowerStatus, FollowingStatus, RemoteActor,
|
|
||||||
};
|
|
||||||
use domain::models::{RemoteWatchlistEntry, Review, ReviewSource};
|
use domain::models::{RemoteWatchlistEntry, Review, ReviewSource};
|
||||||
use domain::ports::RemoteWatchlistRepository;
|
use domain::ports::RemoteWatchlistRepository;
|
||||||
|
use k_ap::{
|
||||||
|
ActivityRepository, ActorRepository, BlockedDomain, BlocklistRepository, FollowRepository,
|
||||||
|
Follower, FollowerStatus, FollowingStatus, RemoteActor,
|
||||||
|
};
|
||||||
|
|
||||||
fn datetime_to_str(dt: &NaiveDateTime) -> String {
|
fn datetime_to_str(dt: &NaiveDateTime) -> String {
|
||||||
dt.format("%Y-%m-%d %H:%M:%S").to_string()
|
dt.format("%Y-%m-%d %H:%M:%S").to_string()
|
||||||
@@ -40,8 +41,35 @@ fn str_to_status(s: &str) -> FollowerStatus {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn remote_actor_from_row(row: &sqlx::sqlite::SqliteRow, url_col: &str) -> RemoteActor {
|
||||||
|
RemoteActor {
|
||||||
|
url: row.get(url_col),
|
||||||
|
handle: row.try_get("handle").unwrap_or_default(),
|
||||||
|
inbox_url: row.try_get("inbox_url").unwrap_or_default(),
|
||||||
|
shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(),
|
||||||
|
display_name: row.try_get("display_name").ok().flatten(),
|
||||||
|
avatar_url: row.try_get("avatar_url").ok().flatten(),
|
||||||
|
outbox_url: row.try_get("outbox_url").ok().flatten(),
|
||||||
|
bio: row.try_get("bio").ok().flatten(),
|
||||||
|
banner_url: row.try_get("banner_url").ok().flatten(),
|
||||||
|
followers_url: row.try_get("followers_url").ok().flatten(),
|
||||||
|
following_url: row.try_get("following_url").ok().flatten(),
|
||||||
|
also_known_as: row
|
||||||
|
.try_get::<Option<String>, _>("also_known_as")
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.map(|s| {
|
||||||
|
serde_json::from_str::<Vec<String>>(&s).unwrap_or_else(|e| {
|
||||||
|
tracing::warn!(raw = %s, error = %e, "failed to parse also_known_as JSON");
|
||||||
|
vec![s]
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.unwrap_or_default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl FederationRepository for SqliteFederationRepository {
|
impl FollowRepository for SqliteFederationRepository {
|
||||||
async fn add_follower(
|
async fn add_follower(
|
||||||
&self,
|
&self,
|
||||||
local_user_id: uuid::Uuid,
|
local_user_id: uuid::Uuid,
|
||||||
@@ -107,7 +135,8 @@ impl FederationRepository for SqliteFederationRepository {
|
|||||||
|
|
||||||
let rows = sqlx::query(
|
let rows = sqlx::query(
|
||||||
"SELECT f.remote_actor_url, f.status,
|
"SELECT f.remote_actor_url, f.status,
|
||||||
a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.avatar_url
|
a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.avatar_url,
|
||||||
|
a.outbox_url, a.bio, a.banner_url, a.followers_url, a.following_url, a.also_known_as
|
||||||
FROM ap_followers f
|
FROM ap_followers f
|
||||||
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
|
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
|
||||||
WHERE f.local_user_id = ?",
|
WHERE f.local_user_id = ?",
|
||||||
@@ -116,34 +145,16 @@ impl FederationRepository for SqliteFederationRepository {
|
|||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let followers = rows
|
Ok(rows
|
||||||
.into_iter()
|
.iter()
|
||||||
.map(|row| {
|
.map(|row| {
|
||||||
let url: String = row.get("remote_actor_url");
|
|
||||||
let status_str: String = row.get("status");
|
let status_str: String = row.get("status");
|
||||||
let handle: String = row.try_get("handle").unwrap_or_default();
|
|
||||||
let inbox_url: String = row.try_get("inbox_url").unwrap_or_default();
|
|
||||||
let shared_inbox_url: Option<String> =
|
|
||||||
row.try_get("shared_inbox_url").ok().flatten();
|
|
||||||
let display_name: Option<String> = row.try_get("display_name").ok().flatten();
|
|
||||||
let avatar_url: Option<String> = row.try_get("avatar_url").ok().flatten();
|
|
||||||
|
|
||||||
Follower {
|
Follower {
|
||||||
actor: RemoteActor {
|
actor: remote_actor_from_row(row, "remote_actor_url"),
|
||||||
url,
|
|
||||||
handle,
|
|
||||||
inbox_url,
|
|
||||||
shared_inbox_url,
|
|
||||||
display_name,
|
|
||||||
avatar_url,
|
|
||||||
outbox_url: row.try_get("outbox_url").ok().flatten(),
|
|
||||||
},
|
|
||||||
status: str_to_status(&status_str),
|
status: str_to_status(&status_str),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect();
|
.collect())
|
||||||
|
|
||||||
Ok(followers)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_followers_page(
|
async fn get_followers_page(
|
||||||
@@ -158,7 +169,8 @@ impl FederationRepository for SqliteFederationRepository {
|
|||||||
|
|
||||||
let rows = sqlx::query(
|
let rows = sqlx::query(
|
||||||
"SELECT f.remote_actor_url, f.status,
|
"SELECT f.remote_actor_url, f.status,
|
||||||
a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.avatar_url
|
a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.avatar_url,
|
||||||
|
a.outbox_url, a.bio, a.banner_url, a.followers_url, a.following_url, a.also_known_as
|
||||||
FROM ap_followers f
|
FROM ap_followers f
|
||||||
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
|
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
|
||||||
WHERE f.local_user_id = ? AND f.status = 'accepted'
|
WHERE f.local_user_id = ? AND f.status = 'accepted'
|
||||||
@@ -172,26 +184,11 @@ impl FederationRepository for SqliteFederationRepository {
|
|||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(rows
|
Ok(rows
|
||||||
.into_iter()
|
.iter()
|
||||||
.map(|row| {
|
.map(|row| {
|
||||||
let url: String = row.get("remote_actor_url");
|
|
||||||
let status_str: String = row.get("status");
|
let status_str: String = row.get("status");
|
||||||
let handle: String = row.try_get("handle").unwrap_or_default();
|
|
||||||
let inbox_url: String = row.try_get("inbox_url").unwrap_or_default();
|
|
||||||
let shared_inbox_url: Option<String> =
|
|
||||||
row.try_get("shared_inbox_url").ok().flatten();
|
|
||||||
let display_name: Option<String> = row.try_get("display_name").ok().flatten();
|
|
||||||
let avatar_url: Option<String> = row.try_get("avatar_url").ok().flatten();
|
|
||||||
Follower {
|
Follower {
|
||||||
actor: RemoteActor {
|
actor: remote_actor_from_row(row, "remote_actor_url"),
|
||||||
url,
|
|
||||||
handle,
|
|
||||||
inbox_url,
|
|
||||||
shared_inbox_url,
|
|
||||||
display_name,
|
|
||||||
avatar_url,
|
|
||||||
outbox_url: row.try_get("outbox_url").ok().flatten(),
|
|
||||||
},
|
|
||||||
status: str_to_status(&status_str),
|
status: str_to_status(&status_str),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -234,6 +231,95 @@ impl FederationRepository for SqliteFederationRepository {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn get_pending_followers(&self, local_user_id: uuid::Uuid) -> Result<Vec<RemoteActor>> {
|
||||||
|
let uid = local_user_id.to_string();
|
||||||
|
|
||||||
|
let rows = sqlx::query(
|
||||||
|
"SELECT f.remote_actor_url,
|
||||||
|
a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.avatar_url,
|
||||||
|
a.outbox_url, a.bio, a.banner_url, a.followers_url, a.following_url, a.also_known_as
|
||||||
|
FROM ap_followers f
|
||||||
|
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
|
||||||
|
WHERE f.local_user_id = ? AND f.status = 'pending'",
|
||||||
|
)
|
||||||
|
.bind(&uid)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(rows
|
||||||
|
.iter()
|
||||||
|
.map(|row| remote_actor_from_row(row, "remote_actor_url"))
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_accepted_follower_inboxes(
|
||||||
|
&self,
|
||||||
|
local_user_id: uuid::Uuid,
|
||||||
|
) -> Result<Vec<String>> {
|
||||||
|
let uid = local_user_id.to_string();
|
||||||
|
let rows = sqlx::query(
|
||||||
|
"SELECT DISTINCT COALESCE(a.shared_inbox_url, a.inbox_url) as inbox
|
||||||
|
FROM ap_followers f
|
||||||
|
INNER JOIN ap_remote_actors a ON a.url = f.remote_actor_url
|
||||||
|
WHERE f.local_user_id = ? AND f.status = 'accepted'
|
||||||
|
AND f.remote_actor_url NOT IN (
|
||||||
|
SELECT remote_actor_url FROM blocked_actors WHERE local_user_id = ?
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.bind(&uid)
|
||||||
|
.bind(&uid)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(rows
|
||||||
|
.iter()
|
||||||
|
.filter_map(|r| r.try_get::<String, _>("inbox").ok())
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn count_accepted_followers(&self, local_user_id: uuid::Uuid) -> Result<usize> {
|
||||||
|
let uid = local_user_id.to_string();
|
||||||
|
let count: i64 = sqlx::query_scalar(
|
||||||
|
"SELECT COUNT(*) FROM ap_followers WHERE local_user_id = ? AND status = 'accepted'",
|
||||||
|
)
|
||||||
|
.bind(&uid)
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(count as usize)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_accepted_followers_page(
|
||||||
|
&self,
|
||||||
|
local_user_id: uuid::Uuid,
|
||||||
|
offset: u32,
|
||||||
|
limit: usize,
|
||||||
|
) -> Result<Vec<RemoteActor>> {
|
||||||
|
let uid = local_user_id.to_string();
|
||||||
|
let limit_i64 = limit as i64;
|
||||||
|
let offset_i64 = offset as i64;
|
||||||
|
|
||||||
|
let rows = sqlx::query(
|
||||||
|
"SELECT f.remote_actor_url,
|
||||||
|
a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.avatar_url,
|
||||||
|
a.outbox_url, a.bio, a.banner_url, a.followers_url, a.following_url, a.also_known_as
|
||||||
|
FROM ap_followers f
|
||||||
|
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
|
||||||
|
WHERE f.local_user_id = ? AND f.status = 'accepted'
|
||||||
|
ORDER BY f.created_at ASC
|
||||||
|
LIMIT ? OFFSET ?",
|
||||||
|
)
|
||||||
|
.bind(&uid)
|
||||||
|
.bind(limit_i64)
|
||||||
|
.bind(offset_i64)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(rows
|
||||||
|
.iter()
|
||||||
|
.map(|row| remote_actor_from_row(row, "remote_actor_url"))
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
async fn add_following(
|
async fn add_following(
|
||||||
&self,
|
&self,
|
||||||
local_user_id: uuid::Uuid,
|
local_user_id: uuid::Uuid,
|
||||||
@@ -244,7 +330,7 @@ impl FederationRepository for SqliteFederationRepository {
|
|||||||
let now = Utc::now().naive_utc();
|
let now = Utc::now().naive_utc();
|
||||||
let created_at = datetime_to_str(&now);
|
let created_at = datetime_to_str(&now);
|
||||||
|
|
||||||
self.upsert_remote_actor(actor.clone()).await?;
|
ActorRepository::upsert_remote_actor(self, actor.clone()).await?;
|
||||||
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT OR IGNORE INTO ap_following (local_user_id, remote_actor_url, follow_activity_id, created_at)
|
"INSERT OR IGNORE INTO ap_following (local_user_id, remote_actor_url, follow_activity_id, created_at)
|
||||||
@@ -290,7 +376,8 @@ impl FederationRepository for SqliteFederationRepository {
|
|||||||
let uid = local_user_id.to_string();
|
let uid = local_user_id.to_string();
|
||||||
|
|
||||||
let rows = sqlx::query(
|
let rows = sqlx::query(
|
||||||
"SELECT a.url, a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.avatar_url
|
"SELECT a.url, a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.avatar_url,
|
||||||
|
a.outbox_url, a.bio, a.banner_url, a.followers_url, a.following_url, a.also_known_as
|
||||||
FROM ap_following f
|
FROM ap_following f
|
||||||
INNER JOIN ap_remote_actors a ON a.url = f.remote_actor_url
|
INNER JOIN ap_remote_actors a ON a.url = f.remote_actor_url
|
||||||
WHERE f.local_user_id = ? AND f.status = 'accepted'",
|
WHERE f.local_user_id = ? AND f.status = 'accepted'",
|
||||||
@@ -300,16 +387,8 @@ impl FederationRepository for SqliteFederationRepository {
|
|||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(rows
|
Ok(rows
|
||||||
.into_iter()
|
.iter()
|
||||||
.map(|row| RemoteActor {
|
.map(|row| remote_actor_from_row(row, "url"))
|
||||||
url: row.get("url"),
|
|
||||||
handle: row.get("handle"),
|
|
||||||
inbox_url: row.get("inbox_url"),
|
|
||||||
shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(),
|
|
||||||
display_name: row.try_get("display_name").ok().flatten(),
|
|
||||||
avatar_url: row.try_get("avatar_url").ok().flatten(),
|
|
||||||
outbox_url: row.try_get("outbox_url").ok().flatten(),
|
|
||||||
})
|
|
||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -335,7 +414,8 @@ impl FederationRepository for SqliteFederationRepository {
|
|||||||
let offset_i64 = offset as i64;
|
let offset_i64 = offset as i64;
|
||||||
|
|
||||||
let rows = sqlx::query(
|
let rows = sqlx::query(
|
||||||
"SELECT a.url, a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.avatar_url
|
"SELECT a.url, a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.avatar_url,
|
||||||
|
a.outbox_url, a.bio, a.banner_url, a.followers_url, a.following_url, a.also_known_as
|
||||||
FROM ap_following f
|
FROM ap_following f
|
||||||
INNER JOIN ap_remote_actors a ON a.url = f.remote_actor_url
|
INNER JOIN ap_remote_actors a ON a.url = f.remote_actor_url
|
||||||
WHERE f.local_user_id = ? AND f.status = 'accepted'
|
WHERE f.local_user_id = ? AND f.status = 'accepted'
|
||||||
@@ -349,134 +429,8 @@ impl FederationRepository for SqliteFederationRepository {
|
|||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(rows
|
Ok(rows
|
||||||
.into_iter()
|
.iter()
|
||||||
.map(|row| RemoteActor {
|
.map(|row| remote_actor_from_row(row, "url"))
|
||||||
url: row.get("url"),
|
|
||||||
handle: row.get("handle"),
|
|
||||||
inbox_url: row.get("inbox_url"),
|
|
||||||
shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(),
|
|
||||||
display_name: row.try_get("display_name").ok().flatten(),
|
|
||||||
avatar_url: row.try_get("avatar_url").ok().flatten(),
|
|
||||||
outbox_url: row.try_get("outbox_url").ok().flatten(),
|
|
||||||
})
|
|
||||||
.collect())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn upsert_remote_actor(&self, actor: RemoteActor) -> Result<()> {
|
|
||||||
let now = Utc::now().naive_utc();
|
|
||||||
let fetched_at = datetime_to_str(&now);
|
|
||||||
|
|
||||||
sqlx::query(
|
|
||||||
"INSERT INTO ap_remote_actors (url, handle, inbox_url, shared_inbox_url, display_name, avatar_url, outbox_url, fetched_at)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
ON CONFLICT(url) DO UPDATE SET
|
|
||||||
handle = excluded.handle,
|
|
||||||
inbox_url = excluded.inbox_url,
|
|
||||||
shared_inbox_url = excluded.shared_inbox_url,
|
|
||||||
display_name = excluded.display_name,
|
|
||||||
avatar_url = excluded.avatar_url,
|
|
||||||
outbox_url = COALESCE(excluded.outbox_url, ap_remote_actors.outbox_url),
|
|
||||||
fetched_at = excluded.fetched_at",
|
|
||||||
)
|
|
||||||
.bind(&actor.url)
|
|
||||||
.bind(&actor.handle)
|
|
||||||
.bind(&actor.inbox_url)
|
|
||||||
.bind(&actor.shared_inbox_url)
|
|
||||||
.bind(&actor.display_name)
|
|
||||||
.bind(&actor.avatar_url)
|
|
||||||
.bind(&actor.outbox_url)
|
|
||||||
.bind(&fetched_at)
|
|
||||||
.execute(&self.pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn get_remote_actor(&self, actor_url: &str) -> Result<Option<RemoteActor>> {
|
|
||||||
let row = sqlx::query(
|
|
||||||
"SELECT url, handle, inbox_url, shared_inbox_url, display_name, avatar_url
|
|
||||||
FROM ap_remote_actors WHERE url = ?",
|
|
||||||
)
|
|
||||||
.bind(actor_url)
|
|
||||||
.fetch_optional(&self.pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(row.map(|row| RemoteActor {
|
|
||||||
url: row.get("url"),
|
|
||||||
handle: row.get("handle"),
|
|
||||||
inbox_url: row.get("inbox_url"),
|
|
||||||
shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(),
|
|
||||||
display_name: row.try_get("display_name").ok().flatten(),
|
|
||||||
avatar_url: row.try_get("avatar_url").ok().flatten(),
|
|
||||||
outbox_url: row.try_get("outbox_url").ok().flatten(),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn get_local_actor_keypair(
|
|
||||||
&self,
|
|
||||||
user_id: uuid::Uuid,
|
|
||||||
) -> Result<Option<(String, String)>> {
|
|
||||||
let uid = user_id.to_string();
|
|
||||||
let row =
|
|
||||||
sqlx::query("SELECT public_key, private_key FROM ap_local_actors WHERE user_id = ?")
|
|
||||||
.bind(&uid)
|
|
||||||
.fetch_optional(&self.pool)
|
|
||||||
.await?;
|
|
||||||
Ok(row.map(|r| (r.get("public_key"), r.get("private_key"))))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn save_local_actor_keypair(
|
|
||||||
&self,
|
|
||||||
user_id: uuid::Uuid,
|
|
||||||
public_key: String,
|
|
||||||
private_key: String,
|
|
||||||
) -> Result<()> {
|
|
||||||
let uid = user_id.to_string();
|
|
||||||
let now = Utc::now().naive_utc();
|
|
||||||
let created_at = datetime_to_str(&now);
|
|
||||||
|
|
||||||
sqlx::query(
|
|
||||||
"INSERT INTO ap_local_actors (user_id, public_key, private_key, created_at)
|
|
||||||
VALUES (?, ?, ?, ?)
|
|
||||||
ON CONFLICT(user_id) DO UPDATE SET
|
|
||||||
public_key = excluded.public_key,
|
|
||||||
private_key = excluded.private_key",
|
|
||||||
)
|
|
||||||
.bind(&uid)
|
|
||||||
.bind(&public_key)
|
|
||||||
.bind(&private_key)
|
|
||||||
.bind(&created_at)
|
|
||||||
.execute(&self.pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn get_pending_followers(&self, local_user_id: uuid::Uuid) -> Result<Vec<RemoteActor>> {
|
|
||||||
let uid = local_user_id.to_string();
|
|
||||||
|
|
||||||
let rows = sqlx::query(
|
|
||||||
"SELECT f.remote_actor_url,
|
|
||||||
a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.avatar_url
|
|
||||||
FROM ap_followers f
|
|
||||||
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
|
|
||||||
WHERE f.local_user_id = ? AND f.status = 'pending'",
|
|
||||||
)
|
|
||||||
.bind(&uid)
|
|
||||||
.fetch_all(&self.pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(rows
|
|
||||||
.into_iter()
|
|
||||||
.map(|row| RemoteActor {
|
|
||||||
url: row.get("remote_actor_url"),
|
|
||||||
handle: row.try_get("handle").unwrap_or_default(),
|
|
||||||
inbox_url: row.try_get("inbox_url").unwrap_or_default(),
|
|
||||||
shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(),
|
|
||||||
display_name: row.try_get("display_name").ok().flatten(),
|
|
||||||
avatar_url: row.try_get("avatar_url").ok().flatten(),
|
|
||||||
outbox_url: row.try_get("outbox_url").ok().flatten(),
|
|
||||||
})
|
|
||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -527,6 +481,142 @@ impl FederationRepository for SqliteFederationRepository {
|
|||||||
Ok(row.flatten())
|
Ok(row.flatten())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn migrate_follower_actor(
|
||||||
|
&self,
|
||||||
|
old_actor_url: &str,
|
||||||
|
new_actor_url: &str,
|
||||||
|
) -> Result<Vec<uuid::Uuid>> {
|
||||||
|
let candidates: Vec<String> = sqlx::query_scalar(
|
||||||
|
"SELECT local_user_id FROM ap_following
|
||||||
|
WHERE remote_actor_url = ?1
|
||||||
|
AND local_user_id NOT IN (
|
||||||
|
SELECT local_user_id FROM ap_following WHERE remote_actor_url = ?2
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.bind(old_actor_url)
|
||||||
|
.bind(new_actor_url)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if candidates.is_empty() {
|
||||||
|
return Ok(vec![]);
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE ap_following SET remote_actor_url = ?1
|
||||||
|
WHERE remote_actor_url = ?2
|
||||||
|
AND local_user_id NOT IN (
|
||||||
|
SELECT local_user_id FROM ap_following WHERE remote_actor_url = ?1
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.bind(new_actor_url)
|
||||||
|
.bind(old_actor_url)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
candidates
|
||||||
|
.into_iter()
|
||||||
|
.map(|s| uuid::Uuid::parse_str(&s).map_err(|e| anyhow::anyhow!(e)))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl ActorRepository for SqliteFederationRepository {
|
||||||
|
async fn get_local_actor_keypair(
|
||||||
|
&self,
|
||||||
|
user_id: uuid::Uuid,
|
||||||
|
) -> Result<Option<(String, String)>> {
|
||||||
|
let uid = user_id.to_string();
|
||||||
|
let row =
|
||||||
|
sqlx::query("SELECT public_key, private_key FROM ap_local_actors WHERE user_id = ?")
|
||||||
|
.bind(&uid)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(row.map(|r| (r.get("public_key"), r.get("private_key"))))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn save_local_actor_keypair(
|
||||||
|
&self,
|
||||||
|
user_id: uuid::Uuid,
|
||||||
|
public_key: String,
|
||||||
|
private_key: String,
|
||||||
|
) -> Result<()> {
|
||||||
|
let uid = user_id.to_string();
|
||||||
|
let now = Utc::now().naive_utc();
|
||||||
|
let created_at = datetime_to_str(&now);
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO ap_local_actors (user_id, public_key, private_key, created_at)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
ON CONFLICT(user_id) DO UPDATE SET
|
||||||
|
public_key = excluded.public_key,
|
||||||
|
private_key = excluded.private_key",
|
||||||
|
)
|
||||||
|
.bind(&uid)
|
||||||
|
.bind(&public_key)
|
||||||
|
.bind(&private_key)
|
||||||
|
.bind(&created_at)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn upsert_remote_actor(&self, actor: RemoteActor) -> Result<()> {
|
||||||
|
let now = Utc::now().naive_utc();
|
||||||
|
let fetched_at = datetime_to_str(&now);
|
||||||
|
let aka_json = serde_json::to_string(&actor.also_known_as).unwrap_or_default();
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO ap_remote_actors (url, handle, inbox_url, shared_inbox_url, display_name, avatar_url, outbox_url, bio, banner_url, followers_url, following_url, also_known_as, fetched_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(url) DO UPDATE SET
|
||||||
|
handle = excluded.handle,
|
||||||
|
inbox_url = excluded.inbox_url,
|
||||||
|
shared_inbox_url = excluded.shared_inbox_url,
|
||||||
|
display_name = excluded.display_name,
|
||||||
|
avatar_url = excluded.avatar_url,
|
||||||
|
outbox_url = COALESCE(excluded.outbox_url, ap_remote_actors.outbox_url),
|
||||||
|
bio = excluded.bio,
|
||||||
|
banner_url = excluded.banner_url,
|
||||||
|
followers_url = excluded.followers_url,
|
||||||
|
following_url = excluded.following_url,
|
||||||
|
also_known_as = excluded.also_known_as,
|
||||||
|
fetched_at = excluded.fetched_at",
|
||||||
|
)
|
||||||
|
.bind(&actor.url)
|
||||||
|
.bind(&actor.handle)
|
||||||
|
.bind(&actor.inbox_url)
|
||||||
|
.bind(&actor.shared_inbox_url)
|
||||||
|
.bind(&actor.display_name)
|
||||||
|
.bind(&actor.avatar_url)
|
||||||
|
.bind(&actor.outbox_url)
|
||||||
|
.bind(&actor.bio)
|
||||||
|
.bind(&actor.banner_url)
|
||||||
|
.bind(&actor.followers_url)
|
||||||
|
.bind(&actor.following_url)
|
||||||
|
.bind(&aka_json)
|
||||||
|
.bind(&fetched_at)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_remote_actor(&self, actor_url: &str) -> Result<Option<RemoteActor>> {
|
||||||
|
let row = sqlx::query(
|
||||||
|
"SELECT url, handle, inbox_url, shared_inbox_url, display_name, avatar_url,
|
||||||
|
outbox_url, bio, banner_url, followers_url, following_url, also_known_as
|
||||||
|
FROM ap_remote_actors WHERE url = ?",
|
||||||
|
)
|
||||||
|
.bind(actor_url)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(row.as_ref().map(|r| remote_actor_from_row(r, "url")))
|
||||||
|
}
|
||||||
|
|
||||||
async fn add_announce(
|
async fn add_announce(
|
||||||
&self,
|
&self,
|
||||||
activity_id: &str,
|
activity_id: &str,
|
||||||
@@ -548,6 +638,15 @@ impl FederationRepository for SqliteFederationRepository {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn remove_announce(&self, activity_id: &str, actor_url: &str) -> Result<()> {
|
||||||
|
sqlx::query("DELETE FROM ap_announces WHERE id = ?1 AND actor_url = ?2")
|
||||||
|
.bind(activity_id)
|
||||||
|
.bind(actor_url)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
async fn count_announces(&self, object_url: &str) -> Result<usize> {
|
async fn count_announces(&self, object_url: &str) -> Result<usize> {
|
||||||
let row = sqlx::query("SELECT COUNT(*) as cnt FROM ap_announces WHERE object_url = ?1")
|
let row = sqlx::query("SELECT COUNT(*) as cnt FROM ap_announces WHERE object_url = ?1")
|
||||||
.bind(object_url)
|
.bind(object_url)
|
||||||
@@ -555,7 +654,10 @@ impl FederationRepository for SqliteFederationRepository {
|
|||||||
.await?;
|
.await?;
|
||||||
Ok(row.get::<i64, _>("cnt") as usize)
|
Ok(row.get::<i64, _>("cnt") as usize)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl BlocklistRepository for SqliteFederationRepository {
|
||||||
async fn add_blocked_domain(&self, domain: &str, reason: Option<&str>) -> Result<()> {
|
async fn add_blocked_domain(&self, domain: &str, reason: Option<&str>) -> Result<()> {
|
||||||
let now = Utc::now().naive_utc();
|
let now = Utc::now().naive_utc();
|
||||||
let ts = datetime_to_str(&now);
|
let ts = datetime_to_str(&now);
|
||||||
@@ -656,44 +758,26 @@ impl FederationRepository for SqliteFederationRepository {
|
|||||||
.await?;
|
.await?;
|
||||||
Ok(count > 0)
|
Ok(count > 0)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn migrate_follower_actor(
|
#[async_trait]
|
||||||
&self,
|
impl ActivityRepository for SqliteFederationRepository {
|
||||||
old_actor_url: &str,
|
async fn is_activity_processed(&self, activity_id: &str) -> Result<bool> {
|
||||||
new_actor_url: &str,
|
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM ap_activities WHERE id = ?1")
|
||||||
) -> Result<Vec<uuid::Uuid>> {
|
.bind(activity_id)
|
||||||
let candidates: Vec<String> = sqlx::query_scalar(
|
.fetch_one(&self.pool)
|
||||||
"SELECT local_user_id FROM ap_following
|
.await?;
|
||||||
WHERE remote_actor_url = ?1
|
Ok(count > 0)
|
||||||
AND local_user_id NOT IN (
|
}
|
||||||
SELECT local_user_id FROM ap_following WHERE remote_actor_url = ?2
|
|
||||||
)",
|
|
||||||
)
|
|
||||||
.bind(old_actor_url)
|
|
||||||
.bind(new_actor_url)
|
|
||||||
.fetch_all(&self.pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if candidates.is_empty() {
|
async fn mark_activity_processed(&self, activity_id: &str) -> Result<()> {
|
||||||
return Ok(vec![]);
|
let ts = datetime_to_str(&Utc::now().naive_utc());
|
||||||
}
|
sqlx::query("INSERT OR IGNORE INTO ap_activities (id, processed_at) VALUES (?1, ?2)")
|
||||||
|
.bind(activity_id)
|
||||||
sqlx::query(
|
.bind(&ts)
|
||||||
"UPDATE ap_following SET remote_actor_url = ?1
|
.execute(&self.pool)
|
||||||
WHERE remote_actor_url = ?2
|
.await?;
|
||||||
AND local_user_id NOT IN (
|
Ok(())
|
||||||
SELECT local_user_id FROM ap_following WHERE remote_actor_url = ?1
|
|
||||||
)",
|
|
||||||
)
|
|
||||||
.bind(new_actor_url)
|
|
||||||
.bind(old_actor_url)
|
|
||||||
.execute(&self.pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
candidates
|
|
||||||
.into_iter()
|
|
||||||
.map(|s| uuid::Uuid::parse_str(&s).map_err(|e| anyhow::anyhow!(e)))
|
|
||||||
.collect()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -840,6 +924,64 @@ impl domain::ports::SocialQueryPort for SqliteFederationRepository {
|
|||||||
)
|
)
|
||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn count_following(
|
||||||
|
&self,
|
||||||
|
user_id: uuid::Uuid,
|
||||||
|
) -> Result<usize, domain::errors::DomainError> {
|
||||||
|
let uid = user_id.to_string();
|
||||||
|
let count: i64 = sqlx::query_scalar(
|
||||||
|
"SELECT COUNT(*) FROM ap_following WHERE local_user_id = ? AND status = 'accepted'",
|
||||||
|
)
|
||||||
|
.bind(&uid)
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| domain::errors::DomainError::InfrastructureError(e.to_string()))?;
|
||||||
|
Ok(count as usize)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn count_accepted_followers(
|
||||||
|
&self,
|
||||||
|
user_id: uuid::Uuid,
|
||||||
|
) -> Result<usize, domain::errors::DomainError> {
|
||||||
|
let uid = user_id.to_string();
|
||||||
|
let count: i64 = sqlx::query_scalar(
|
||||||
|
"SELECT COUNT(*) FROM ap_followers WHERE local_user_id = ? AND status = 'accepted'",
|
||||||
|
)
|
||||||
|
.bind(&uid)
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| domain::errors::DomainError::InfrastructureError(e.to_string()))?;
|
||||||
|
Ok(count as usize)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_pending_followers(
|
||||||
|
&self,
|
||||||
|
user_id: uuid::Uuid,
|
||||||
|
) -> Result<Vec<domain::ports::PendingFollowerInfo>, domain::errors::DomainError> {
|
||||||
|
let uid = user_id.to_string();
|
||||||
|
let rows = sqlx::query_as::<_, (String, String, Option<String>, Option<String>)>(
|
||||||
|
"SELECT ar.url, ar.handle, ar.display_name, ar.avatar_url
|
||||||
|
FROM ap_followers f
|
||||||
|
JOIN ap_remote_actors ar ON ar.url = f.remote_actor_url
|
||||||
|
WHERE f.local_user_id = ? AND f.status = 'pending'",
|
||||||
|
)
|
||||||
|
.bind(&uid)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| domain::errors::DomainError::InfrastructureError(e.to_string()))?;
|
||||||
|
Ok(rows
|
||||||
|
.into_iter()
|
||||||
|
.map(
|
||||||
|
|(url, handle, display_name, avatar_url)| domain::ports::PendingFollowerInfo {
|
||||||
|
url,
|
||||||
|
handle,
|
||||||
|
display_name,
|
||||||
|
avatar_url,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -954,16 +1096,12 @@ impl RemoteWatchlistRepository for SqliteFederationRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn wire(
|
pub fn wire(pool: sqlx::SqlitePool) -> activitypub::FederationRepos {
|
||||||
pool: sqlx::SqlitePool,
|
|
||||||
) -> (
|
|
||||||
std::sync::Arc<dyn activitypub::FederationRepository>,
|
|
||||||
std::sync::Arc<dyn domain::ports::SocialQueryPort>,
|
|
||||||
std::sync::Arc<dyn activitypub::RemoteReviewRepository>,
|
|
||||||
std::sync::Arc<dyn domain::ports::RemoteWatchlistRepository>,
|
|
||||||
) {
|
|
||||||
let fed = std::sync::Arc::new(SqliteFederationRepository::new(pool));
|
let fed = std::sync::Arc::new(SqliteFederationRepository::new(pool));
|
||||||
(
|
(
|
||||||
|
std::sync::Arc::clone(&fed) as _,
|
||||||
|
std::sync::Arc::clone(&fed) as _,
|
||||||
|
std::sync::Arc::clone(&fed) as _,
|
||||||
std::sync::Arc::clone(&fed) as _,
|
std::sync::Arc::clone(&fed) as _,
|
||||||
std::sync::Arc::clone(&fed) as _,
|
std::sync::Arc::clone(&fed) as _,
|
||||||
std::sync::Arc::clone(&fed) as _,
|
std::sync::Arc::clone(&fed) as _,
|
||||||
@@ -974,7 +1112,7 @@ pub fn wire(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod outbox_url_tests {
|
mod outbox_url_tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use k_ap::{FederationRepository, FollowingStatus, RemoteActor};
|
use k_ap::{FollowRepository, FollowingStatus, RemoteActor};
|
||||||
|
|
||||||
async fn setup_pool() -> SqlitePool {
|
async fn setup_pool() -> SqlitePool {
|
||||||
let pool = SqlitePool::connect(":memory:").await.unwrap();
|
let pool = SqlitePool::connect(":memory:").await.unwrap();
|
||||||
@@ -982,7 +1120,8 @@ mod outbox_url_tests {
|
|||||||
"CREATE TABLE ap_remote_actors (
|
"CREATE TABLE ap_remote_actors (
|
||||||
url TEXT PRIMARY KEY, handle TEXT NOT NULL, inbox_url TEXT NOT NULL,
|
url TEXT PRIMARY KEY, handle TEXT NOT NULL, inbox_url TEXT NOT NULL,
|
||||||
shared_inbox_url TEXT, display_name TEXT, avatar_url TEXT,
|
shared_inbox_url TEXT, display_name TEXT, avatar_url TEXT,
|
||||||
outbox_url TEXT, fetched_at TEXT NOT NULL
|
outbox_url TEXT, bio TEXT, banner_url TEXT, followers_url TEXT,
|
||||||
|
following_url TEXT, also_known_as TEXT, fetched_at TEXT NOT NULL
|
||||||
);
|
);
|
||||||
CREATE TABLE ap_following (
|
CREATE TABLE ap_following (
|
||||||
local_user_id TEXT NOT NULL, remote_actor_url TEXT NOT NULL,
|
local_user_id TEXT NOT NULL, remote_actor_url TEXT NOT NULL,
|
||||||
@@ -1010,6 +1149,11 @@ mod outbox_url_tests {
|
|||||||
display_name: None,
|
display_name: None,
|
||||||
avatar_url: None,
|
avatar_url: None,
|
||||||
outbox_url: Some("https://remote.example/users/alice/outbox".to_string()),
|
outbox_url: Some("https://remote.example/users/alice/outbox".to_string()),
|
||||||
|
bio: None,
|
||||||
|
banner_url: None,
|
||||||
|
followers_url: None,
|
||||||
|
following_url: None,
|
||||||
|
also_known_as: vec![],
|
||||||
};
|
};
|
||||||
repo.add_following(local_user, actor, "https://local/activities/1")
|
repo.add_following(local_user, actor, "https://local/activities/1")
|
||||||
.await
|
.await
|
||||||
|
|||||||
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()
|
rows.into_iter().map(WatchlistRow::into_domain).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_review_by_id(
|
async fn get_review_by_id(&self, review_id: &ReviewId) -> Result<Option<Review>, DomainError> {
|
||||||
&self,
|
|
||||||
review_id: &ReviewId,
|
|
||||||
) -> Result<Option<Review>, DomainError> {
|
|
||||||
let id = review_id.value().to_string();
|
let id = review_id.value().to_string();
|
||||||
sqlx::query_as::<_, ReviewRow>(
|
sqlx::query_as::<_, ReviewRow>(
|
||||||
"SELECT id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url
|
"SELECT id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url
|
||||||
@@ -106,4 +103,49 @@ impl LocalApContentQuery for SqliteApContentQuery {
|
|||||||
.map_err(Self::map_err)?;
|
.map_err(Self::map_err)?;
|
||||||
Ok(count as u64)
|
Ok(count as u64)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn get_local_reviews_page(
|
||||||
|
&self,
|
||||||
|
user_id: &UserId,
|
||||||
|
before: Option<chrono::NaiveDateTime>,
|
||||||
|
limit: usize,
|
||||||
|
) -> Result<Vec<DiaryEntry>, DomainError> {
|
||||||
|
let uid = user_id.value().to_string();
|
||||||
|
let limit_i64 = limit as i64;
|
||||||
|
|
||||||
|
let rows = if let Some(before_ts) = before {
|
||||||
|
let ts = before_ts.format("%Y-%m-%d %H:%M:%S").to_string();
|
||||||
|
sqlx::query_as::<_, DiaryRow>(
|
||||||
|
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
|
||||||
|
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url
|
||||||
|
FROM reviews r
|
||||||
|
INNER JOIN movies m ON m.id = r.movie_id
|
||||||
|
WHERE r.user_id = ? AND r.remote_actor_url IS NULL AND r.watched_at < ?
|
||||||
|
ORDER BY r.watched_at DESC
|
||||||
|
LIMIT ?",
|
||||||
|
)
|
||||||
|
.bind(&uid)
|
||||||
|
.bind(&ts)
|
||||||
|
.bind(limit_i64)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(Self::map_err)?
|
||||||
|
} else {
|
||||||
|
sqlx::query_as::<_, DiaryRow>(
|
||||||
|
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
|
||||||
|
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url
|
||||||
|
FROM reviews r
|
||||||
|
INNER JOIN movies m ON m.id = r.movie_id
|
||||||
|
WHERE r.user_id = ? AND r.remote_actor_url IS NULL
|
||||||
|
ORDER BY r.watched_at DESC
|
||||||
|
LIMIT ?",
|
||||||
|
)
|
||||||
|
.bind(&uid)
|
||||||
|
.bind(limit_i64)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(Self::map_err)?
|
||||||
|
};
|
||||||
|
rows.into_iter().map(DiaryRow::into_domain).collect()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ use chrono::NaiveDateTime;
|
|||||||
use domain::{
|
use domain::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
models::{
|
models::{
|
||||||
DiaryEntry, FeedEntry, Movie, MovieSummary, Review, ReviewSource, UserSummary,
|
DiaryEntry, FeedEntry, Movie, MovieSummary, PersistedReview, Review, ReviewSource,
|
||||||
WatchlistEntry, WatchlistWithMovie,
|
UserSummary, WatchlistEntry, WatchlistWithMovie,
|
||||||
},
|
},
|
||||||
value_objects::{
|
value_objects::{
|
||||||
Comment, Email, ExternalMetadataId, MovieId, MovieTitle, PosterPath, Rating, ReleaseYear,
|
Comment, Email, ExternalMetadataId, MovieId, MovieTitle, PosterPath, Rating, ReleaseYear,
|
||||||
@@ -109,9 +109,16 @@ impl ReviewRow {
|
|||||||
None => ReviewSource::Local,
|
None => ReviewSource::Local,
|
||||||
Some(url) => ReviewSource::Remote { actor_url: url },
|
Some(url) => ReviewSource::Remote { actor_url: url },
|
||||||
};
|
};
|
||||||
Ok(Review::from_persistence(
|
Ok(Review::from_persistence(PersistedReview {
|
||||||
id, movie_id, user_id, rating, comment, watched_at, created_at, source,
|
id,
|
||||||
))
|
movie_id,
|
||||||
|
user_id,
|
||||||
|
rating,
|
||||||
|
comment,
|
||||||
|
watched_at,
|
||||||
|
created_at,
|
||||||
|
source,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use sqlx::SqlitePool;
|
|||||||
async fn setup() -> (SqlitePool, SqliteUserRepository) {
|
async fn setup() -> (SqlitePool, SqliteUserRepository) {
|
||||||
let pool = SqlitePool::connect(":memory:").await.unwrap();
|
let pool = SqlitePool::connect(":memory:").await.unwrap();
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"CREATE TABLE users (id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, created_at TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'standard', bio TEXT, avatar_path TEXT, banner_path TEXT, also_known_as TEXT)"
|
"CREATE TABLE users (id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, created_at TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'standard', display_name TEXT, bio TEXT, avatar_path TEXT, banner_path TEXT, also_known_as TEXT)"
|
||||||
)
|
)
|
||||||
.execute(&pool)
|
.execute(&pool)
|
||||||
.await
|
.await
|
||||||
@@ -65,10 +65,11 @@ async fn update_profile_persists_bio_and_avatar() {
|
|||||||
|
|
||||||
repo.update_profile(
|
repo.update_profile(
|
||||||
user.id(),
|
user.id(),
|
||||||
Some("My biography".to_string()),
|
&domain::models::UserProfile {
|
||||||
Some("avatars/user1".to_string()),
|
bio: Some("My biography".to_string()),
|
||||||
None,
|
avatar_path: Some("avatars/user1".to_string()),
|
||||||
None,
|
..Default::default()
|
||||||
|
},
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -90,14 +91,15 @@ async fn update_profile_clears_fields_with_none() {
|
|||||||
repo.save(&user).await.unwrap();
|
repo.save(&user).await.unwrap();
|
||||||
repo.update_profile(
|
repo.update_profile(
|
||||||
user.id(),
|
user.id(),
|
||||||
Some("bio".to_string()),
|
&domain::models::UserProfile {
|
||||||
Some("path".to_string()),
|
bio: Some("bio".to_string()),
|
||||||
None,
|
avatar_path: Some("path".to_string()),
|
||||||
None,
|
..Default::default()
|
||||||
|
},
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
repo.update_profile(user.id(), None, None, None, None)
|
repo.update_profile(user.id(), &domain::models::UserProfile::default())
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use sqlx::SqlitePool;
|
use sqlx::{Row, SqlitePool};
|
||||||
|
|
||||||
use super::models::UserSummaryRow;
|
use super::models::UserSummaryRow;
|
||||||
use domain::{
|
use domain::{
|
||||||
@@ -32,104 +32,73 @@ impl SqliteUserRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn row_to_user(
|
fn row_to_user(
|
||||||
id_str: String,
|
row: &sqlx::sqlite::SqliteRow,
|
||||||
email_str: String,
|
|
||||||
username_str: String,
|
|
||||||
hash_str: String,
|
|
||||||
role: UserRole,
|
|
||||||
bio: Option<String>,
|
|
||||||
avatar_path: Option<String>,
|
|
||||||
banner_path: Option<String>,
|
|
||||||
also_known_as: Option<String>,
|
|
||||||
profile_fields: Vec<ProfileField>,
|
profile_fields: Vec<ProfileField>,
|
||||||
) -> Result<User, DomainError> {
|
) -> Result<User, DomainError> {
|
||||||
|
let id_str: String = row.try_get("id").unwrap_or_default();
|
||||||
let id = uuid::Uuid::parse_str(&id_str)
|
let id = uuid::Uuid::parse_str(&id_str)
|
||||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||||
let email =
|
let email = Email::new(row.get("email"))
|
||||||
Email::new(email_str).map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
|
||||||
let username = Username::new(username_str)
|
|
||||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||||
let hash = PasswordHash::new(hash_str)
|
let username = Username::new(row.get("username"))
|
||||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||||
|
let hash = PasswordHash::new(row.get("password_hash"))
|
||||||
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||||
|
let role_str: String = row.get("role");
|
||||||
Ok(User::from_persistence(
|
Ok(User::from_persistence(
|
||||||
UserId::from_uuid(id),
|
UserId::from_uuid(id),
|
||||||
email,
|
email,
|
||||||
username,
|
username,
|
||||||
hash,
|
hash,
|
||||||
role,
|
Self::parse_role(&role_str),
|
||||||
bio,
|
domain::models::UserProfile {
|
||||||
avatar_path,
|
display_name: row.try_get("display_name").ok().flatten(),
|
||||||
banner_path,
|
bio: row.try_get("bio").ok().flatten(),
|
||||||
also_known_as,
|
avatar_path: row.try_get("avatar_path").ok().flatten(),
|
||||||
profile_fields,
|
banner_path: row.try_get("banner_path").ok().flatten(),
|
||||||
|
also_known_as: row.try_get("also_known_as").ok().flatten(),
|
||||||
|
profile_fields,
|
||||||
|
},
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const USER_COLS: &str = "id, email, username, password_hash, role, display_name, bio, avatar_path, banner_path, also_known_as";
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl UserRepository for SqliteUserRepository {
|
impl UserRepository for SqliteUserRepository {
|
||||||
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError> {
|
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError> {
|
||||||
let email_str = email.value();
|
let email_str = email.value();
|
||||||
let row = sqlx::query!(
|
let row = sqlx::query(&format!("SELECT {USER_COLS} FROM users WHERE email = ?"))
|
||||||
"SELECT id, email, username, password_hash, role, bio, avatar_path, banner_path, also_known_as FROM users WHERE email = ?",
|
.bind(email_str)
|
||||||
email_str
|
.fetch_optional(&self.pool)
|
||||||
)
|
.await
|
||||||
.fetch_optional(&self.pool)
|
.map_err(Self::map_err)?;
|
||||||
.await
|
|
||||||
.map_err(Self::map_err)?;
|
|
||||||
|
|
||||||
row.map(|r| {
|
row.as_ref()
|
||||||
Self::row_to_user(
|
.map(|r| Self::row_to_user(r, vec![]))
|
||||||
r.id.unwrap_or_default(),
|
.transpose()
|
||||||
r.email,
|
|
||||||
r.username,
|
|
||||||
r.password_hash,
|
|
||||||
Self::parse_role(&r.role),
|
|
||||||
r.bio,
|
|
||||||
r.avatar_path,
|
|
||||||
r.banner_path,
|
|
||||||
r.also_known_as,
|
|
||||||
vec![],
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.transpose()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn find_by_username(&self, username: &Username) -> Result<Option<User>, DomainError> {
|
async fn find_by_username(&self, username: &Username) -> Result<Option<User>, DomainError> {
|
||||||
let username_str = username.value();
|
let username_str = username.value();
|
||||||
let row = sqlx::query!(
|
let row = sqlx::query(&format!("SELECT {USER_COLS} FROM users WHERE username = ?"))
|
||||||
"SELECT id, email, username, password_hash, role, bio, avatar_path, banner_path, also_known_as FROM users WHERE username = ?",
|
.bind(username_str)
|
||||||
username_str
|
.fetch_optional(&self.pool)
|
||||||
)
|
.await
|
||||||
.fetch_optional(&self.pool)
|
.map_err(Self::map_err)?;
|
||||||
.await
|
|
||||||
.map_err(Self::map_err)?;
|
|
||||||
|
|
||||||
row.map(|r| {
|
row.as_ref()
|
||||||
Self::row_to_user(
|
.map(|r| Self::row_to_user(r, vec![]))
|
||||||
r.id.unwrap_or_default(),
|
.transpose()
|
||||||
r.email,
|
|
||||||
r.username,
|
|
||||||
r.password_hash,
|
|
||||||
Self::parse_role(&r.role),
|
|
||||||
r.bio,
|
|
||||||
r.avatar_path,
|
|
||||||
r.banner_path,
|
|
||||||
r.also_known_as,
|
|
||||||
vec![],
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.transpose()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn save(&self, user: &User) -> Result<(), DomainError> {
|
async fn save(&self, user: &User) -> Result<(), DomainError> {
|
||||||
// Check email uniqueness first (clearer error than INSERT OR IGNORE)
|
|
||||||
if self.find_by_email(user.email()).await?.is_some() {
|
if self.find_by_email(user.email()).await?.is_some() {
|
||||||
return Err(DomainError::ValidationError(
|
return Err(DomainError::ValidationError(
|
||||||
"Email already registered".into(),
|
"Email already registered".into(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
// Check username uniqueness
|
|
||||||
if self.find_by_username(user.username()).await?.is_some() {
|
if self.find_by_username(user.username()).await?.is_some() {
|
||||||
return Err(DomainError::ValidationError(
|
return Err(DomainError::ValidationError(
|
||||||
"Username already taken".into(),
|
"Username already taken".into(),
|
||||||
@@ -141,15 +110,20 @@ impl UserRepository for SqliteUserRepository {
|
|||||||
let username = user.username().value();
|
let username = user.username().value();
|
||||||
let hash = user.password_hash().value();
|
let hash = user.password_hash().value();
|
||||||
let created_at = Utc::now().to_rfc3339();
|
let created_at = Utc::now().to_rfc3339();
|
||||||
|
|
||||||
let role = match user.role() {
|
let role = match user.role() {
|
||||||
UserRole::Admin => "admin",
|
UserRole::Admin => "admin",
|
||||||
UserRole::Standard => "standard",
|
UserRole::Standard => "standard",
|
||||||
};
|
};
|
||||||
sqlx::query!(
|
|
||||||
|
sqlx::query(
|
||||||
"INSERT INTO users (id, email, username, password_hash, created_at, role) VALUES (?, ?, ?, ?, ?, ?)",
|
"INSERT INTO users (id, email, username, password_hash, created_at, role) VALUES (?, ?, ?, ?, ?, ?)",
|
||||||
id, email, username, hash, created_at, role
|
|
||||||
)
|
)
|
||||||
|
.bind(&id)
|
||||||
|
.bind(email)
|
||||||
|
.bind(username)
|
||||||
|
.bind(hash)
|
||||||
|
.bind(&created_at)
|
||||||
|
.bind(role)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(Self::map_err)?;
|
.map_err(Self::map_err)?;
|
||||||
@@ -159,63 +133,47 @@ impl UserRepository for SqliteUserRepository {
|
|||||||
|
|
||||||
async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError> {
|
async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError> {
|
||||||
let id_str = id.value().to_string();
|
let id_str = id.value().to_string();
|
||||||
let row = sqlx::query!(
|
let row = sqlx::query(&format!("SELECT {USER_COLS} FROM users WHERE id = ?"))
|
||||||
"SELECT id, email, username, password_hash, role, bio, avatar_path, banner_path, also_known_as FROM users WHERE id = ?",
|
.bind(&id_str)
|
||||||
id_str
|
.fetch_optional(&self.pool)
|
||||||
)
|
.await
|
||||||
.fetch_optional(&self.pool)
|
.map_err(Self::map_err)?;
|
||||||
.await
|
|
||||||
.map_err(Self::map_err)?;
|
|
||||||
|
|
||||||
let Some(r) = row else { return Ok(None) };
|
let Some(r) = row else { return Ok(None) };
|
||||||
|
|
||||||
let field_rows = sqlx::query!(
|
let field_rows = sqlx::query(
|
||||||
"SELECT name, value FROM user_profile_fields WHERE user_id = ? ORDER BY position ASC",
|
"SELECT name, value FROM user_profile_fields WHERE user_id = ? ORDER BY position ASC",
|
||||||
id_str
|
|
||||||
)
|
)
|
||||||
|
.bind(&id_str)
|
||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||||
|
|
||||||
let profile_fields = field_rows
|
let profile_fields = field_rows
|
||||||
.into_iter()
|
.iter()
|
||||||
.map(|f| ProfileField {
|
.map(|f| ProfileField {
|
||||||
name: f.name,
|
name: f.get("name"),
|
||||||
value: f.value,
|
value: f.get("value"),
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
Self::row_to_user(
|
Self::row_to_user(&r, profile_fields).map(Some)
|
||||||
r.id.unwrap_or_default(),
|
|
||||||
r.email,
|
|
||||||
r.username,
|
|
||||||
r.password_hash,
|
|
||||||
Self::parse_role(&r.role),
|
|
||||||
r.bio,
|
|
||||||
r.avatar_path,
|
|
||||||
r.banner_path,
|
|
||||||
r.also_known_as,
|
|
||||||
profile_fields,
|
|
||||||
)
|
|
||||||
.map(Some)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn update_profile(
|
async fn update_profile(
|
||||||
&self,
|
&self,
|
||||||
user_id: &UserId,
|
user_id: &UserId,
|
||||||
bio: Option<String>,
|
profile: &domain::models::UserProfile,
|
||||||
avatar_path: Option<String>,
|
|
||||||
banner_path: Option<String>,
|
|
||||||
also_known_as: Option<String>,
|
|
||||||
) -> Result<(), DomainError> {
|
) -> Result<(), DomainError> {
|
||||||
let id_str = user_id.value().to_string();
|
let id_str = user_id.value().to_string();
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"UPDATE users SET bio = ?, avatar_path = ?, banner_path = ?, also_known_as = ? WHERE id = ?",
|
"UPDATE users SET display_name = ?, bio = ?, avatar_path = ?, banner_path = ?, also_known_as = ? WHERE id = ?",
|
||||||
)
|
)
|
||||||
.bind(&bio)
|
.bind(&profile.display_name)
|
||||||
.bind(&avatar_path)
|
.bind(&profile.bio)
|
||||||
.bind(&banner_path)
|
.bind(&profile.avatar_path)
|
||||||
.bind(&also_known_as)
|
.bind(&profile.banner_path)
|
||||||
|
.bind(&profile.also_known_as)
|
||||||
.bind(&id_str)
|
.bind(&id_str)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ pub struct LogReviewCommand {
|
|||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct SyncPosterCommand {
|
pub struct SyncPosterCommand {
|
||||||
pub movie_id: Uuid,
|
pub movie_id: Uuid,
|
||||||
pub external_metadata_id: String,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct RegisterCommand {
|
pub struct RegisterCommand {
|
||||||
@@ -75,6 +74,7 @@ pub struct DeleteImportProfileCommand {
|
|||||||
|
|
||||||
pub struct UpdateProfileCommand {
|
pub struct UpdateProfileCommand {
|
||||||
pub user_id: Uuid,
|
pub user_id: Uuid,
|
||||||
|
pub display_name: Option<String>,
|
||||||
pub bio: Option<String>,
|
pub bio: Option<String>,
|
||||||
pub avatar_bytes: Option<Vec<u8>>,
|
pub avatar_bytes: Option<Vec<u8>>,
|
||||||
pub avatar_content_type: Option<String>,
|
pub avatar_content_type: Option<String>,
|
||||||
@@ -102,3 +102,9 @@ pub struct RemoveFromWatchlistCommand {
|
|||||||
pub user_id: Uuid,
|
pub user_id: Uuid,
|
||||||
pub movie_id: Uuid,
|
pub movie_id: Uuid,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub struct RegisterAndLoginCommand {
|
||||||
|
pub email: String,
|
||||||
|
pub username: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ use domain::ports::{
|
|||||||
AuthService, DiaryExporter, DiaryRepository, DocumentParser, EventPublisher, ImageStorage,
|
AuthService, DiaryExporter, DiaryRepository, DocumentParser, EventPublisher, ImageStorage,
|
||||||
ImportProfileRepository, ImportSessionRepository, MetadataClient, MovieProfileRepository,
|
ImportProfileRepository, ImportSessionRepository, MetadataClient, MovieProfileRepository,
|
||||||
MovieRepository, PasswordHasher, PersonCommand, PersonQuery, PosterFetcherClient,
|
MovieRepository, PasswordHasher, PersonCommand, PersonQuery, PosterFetcherClient,
|
||||||
ReviewRepository, SearchCommand, SearchPort, StatsRepository, UserProfileFieldsRepository,
|
ReviewRepository, SearchCommand, SearchPort, SocialQueryPort, StatsRepository,
|
||||||
UserRepository, WatchlistRepository,
|
UserProfileFieldsRepository, UserRepository, WatchlistRepository,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::config::AppConfig;
|
use crate::config::AppConfig;
|
||||||
@@ -38,5 +38,7 @@ pub struct AppContext {
|
|||||||
pub profile_fields_repository: Arc<dyn UserProfileFieldsRepository>,
|
pub profile_fields_repository: Arc<dyn UserProfileFieldsRepository>,
|
||||||
#[cfg(feature = "federation")]
|
#[cfg(feature = "federation")]
|
||||||
pub remote_watchlist_repository: Arc<dyn RemoteWatchlistRepository>,
|
pub remote_watchlist_repository: Arc<dyn RemoteWatchlistRepository>,
|
||||||
|
#[cfg(feature = "federation")]
|
||||||
|
pub social_query: Arc<dyn SocialQueryPort>,
|
||||||
pub config: AppConfig,
|
pub config: AppConfig,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,11 +117,10 @@ impl ResolutionStrategy for TitleSearchStrategy {
|
|||||||
match deps.metadata_client.fetch_movie_metadata(&criteria).await {
|
match deps.metadata_client.fetch_movie_metadata(&criteria).await {
|
||||||
Ok(m) => {
|
Ok(m) => {
|
||||||
// Movie may already exist in DB under this external_metadata_id
|
// Movie may already exist in DB under this external_metadata_id
|
||||||
if let Some(ext_id) = m.external_metadata_id() {
|
if let Some(ext_id) = m.external_metadata_id()
|
||||||
if let Some(existing) = deps.repository.get_movie_by_external_id(ext_id).await?
|
&& let Some(existing) = deps.repository.get_movie_by_external_id(ext_id).await?
|
||||||
{
|
{
|
||||||
return Ok(Some((existing, false)));
|
return Ok(Some((existing, false)));
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Ok(Some((m, true)))
|
Ok(Some((m, true)))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,8 @@ pub struct GetActivityFeedQuery {
|
|||||||
pub offset: u32,
|
pub offset: u32,
|
||||||
pub sort_by: domain::ports::FeedSortBy,
|
pub sort_by: domain::ports::FeedSortBy,
|
||||||
pub search: Option<String>,
|
pub search: Option<String>,
|
||||||
pub following: Option<domain::ports::FollowingFilter>,
|
pub viewer_user_id: Option<Uuid>,
|
||||||
|
pub filter_following: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct GetUsersQuery;
|
pub struct GetUsersQuery;
|
||||||
@@ -73,6 +74,7 @@ pub struct GetUserProfileQuery {
|
|||||||
pub offset: Option<u32>,
|
pub offset: Option<u32>,
|
||||||
pub sort_by: domain::ports::FeedSortBy,
|
pub sort_by: domain::ports::FeedSortBy,
|
||||||
pub search: Option<String>,
|
pub search: Option<String>,
|
||||||
|
pub is_own_profile: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct GetMovieSocialPageQuery {
|
pub struct GetMovieSocialPageQuery {
|
||||||
@@ -99,3 +101,7 @@ pub struct IsOnWatchlistQuery {
|
|||||||
pub user_id: Uuid,
|
pub user_id: Uuid,
|
||||||
pub movie_id: Uuid,
|
pub movie_id: Uuid,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub struct GetCurrentProfileQuery {
|
||||||
|
pub user_id: Uuid,
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
#[cfg(feature = "federation")]
|
||||||
|
use domain::testing::PanicRemoteWatchlistRepository;
|
||||||
|
#[cfg(feature = "federation")]
|
||||||
|
use domain::testing::PanicSocialQueryPort;
|
||||||
use domain::{
|
use domain::{
|
||||||
ports::{
|
ports::{
|
||||||
AuthService, DiaryExporter, DiaryRepository, DocumentParser, EventPublisher, ImageStorage,
|
AuthService, DiaryExporter, DiaryRepository, DocumentParser, EventPublisher, ImageStorage,
|
||||||
@@ -18,13 +22,8 @@ use domain::{
|
|||||||
PanicSearchPort, PanicStatsRepository,
|
PanicSearchPort, PanicStatsRepository,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
#[cfg(feature = "federation")]
|
|
||||||
use domain::testing::PanicRemoteWatchlistRepository;
|
|
||||||
|
|
||||||
use crate::{
|
use crate::{config::AppConfig, context::AppContext};
|
||||||
config::AppConfig,
|
|
||||||
context::AppContext,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub struct TestContextBuilder {
|
pub struct TestContextBuilder {
|
||||||
pub movie_repo: Arc<dyn MovieRepository>,
|
pub movie_repo: Arc<dyn MovieRepository>,
|
||||||
@@ -147,6 +146,8 @@ impl TestContextBuilder {
|
|||||||
config: self.config,
|
config: self.config,
|
||||||
#[cfg(feature = "federation")]
|
#[cfg(feature = "federation")]
|
||||||
remote_watchlist_repository: std::sync::Arc::new(PanicRemoteWatchlistRepository),
|
remote_watchlist_repository: std::sync::Arc::new(PanicRemoteWatchlistRepository),
|
||||||
|
#[cfg(feature = "federation")]
|
||||||
|
social_query: std::sync::Arc::new(PanicSocialQueryPort),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ impl EventHandler for RecordingHandler {
|
|||||||
"watchlist"
|
"watchlist"
|
||||||
}
|
}
|
||||||
DomainEvent::FollowAccepted { .. } => "follow_accepted",
|
DomainEvent::FollowAccepted { .. } => "follow_accepted",
|
||||||
|
DomainEvent::BackfillFollower { .. } => "backfill_follower",
|
||||||
};
|
};
|
||||||
self.calls.lock().unwrap().push(label);
|
self.calls.lock().unwrap().push(label);
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -68,8 +68,8 @@ mod tests {
|
|||||||
use domain::{
|
use domain::{
|
||||||
models::Movie,
|
models::Movie,
|
||||||
ports::MovieRepository,
|
ports::MovieRepository,
|
||||||
value_objects::{MovieTitle, ReleaseYear},
|
|
||||||
testing::{InMemoryMovieRepository, InMemoryWatchlistRepository},
|
testing::{InMemoryMovieRepository, InMemoryWatchlistRepository},
|
||||||
|
value_objects::{MovieTitle, ReleaseYear},
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
|||||||
@@ -62,17 +62,15 @@ mod tests {
|
|||||||
use domain::{
|
use domain::{
|
||||||
models::{Movie, Review},
|
models::{Movie, Review},
|
||||||
ports::{MovieRepository, ReviewRepository},
|
ports::{MovieRepository, ReviewRepository},
|
||||||
value_objects::{MovieId, MovieTitle, Rating, ReleaseYear, UserId},
|
|
||||||
testing::{
|
testing::{
|
||||||
FakeDiaryRepository, InMemoryMovieRepository, InMemoryReviewRepository,
|
FakeDiaryRepository, InMemoryMovieRepository, InMemoryReviewRepository,
|
||||||
NoopEventPublisher,
|
NoopEventPublisher,
|
||||||
},
|
},
|
||||||
|
value_objects::{MovieId, MovieTitle, Rating, ReleaseYear, UserId},
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
commands::DeleteReviewCommand,
|
commands::DeleteReviewCommand, test_helpers::TestContextBuilder, use_cases::delete_review,
|
||||||
test_helpers::TestContextBuilder,
|
|
||||||
use_cases::delete_review,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
fn make_movie() -> Movie {
|
fn make_movie() -> Movie {
|
||||||
@@ -86,8 +84,14 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn make_review(movie_id: MovieId, user_id: UserId) -> Review {
|
fn make_review(movie_id: MovieId, user_id: UserId) -> Review {
|
||||||
Review::new(movie_id, user_id, Rating::new(4).unwrap(), None, Utc::now().naive_utc())
|
Review::new(
|
||||||
.unwrap()
|
movie_id,
|
||||||
|
user_id,
|
||||||
|
Rating::new(4).unwrap(),
|
||||||
|
None,
|
||||||
|
Utc::now().naive_utc(),
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ use domain::{
|
|||||||
FeedEntry,
|
FeedEntry,
|
||||||
collections::{PageParams, Paginated},
|
collections::{PageParams, Paginated},
|
||||||
},
|
},
|
||||||
|
ports::FollowingFilter,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub async fn execute(
|
pub async fn execute(
|
||||||
@@ -12,12 +13,57 @@ pub async fn execute(
|
|||||||
query: GetActivityFeedQuery,
|
query: GetActivityFeedQuery,
|
||||||
) -> Result<Paginated<FeedEntry>, DomainError> {
|
) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||||
let page = PageParams::new(Some(query.limit), Some(query.offset))?;
|
let page = PageParams::new(Some(query.limit), Some(query.offset))?;
|
||||||
|
|
||||||
|
let following = build_following_filter(ctx, &query).await;
|
||||||
|
|
||||||
ctx.diary_repository
|
ctx.diary_repository
|
||||||
.query_activity_feed_filtered(
|
.query_activity_feed_filtered(
|
||||||
&page,
|
&page,
|
||||||
&query.sort_by,
|
&query.sort_by,
|
||||||
query.search.as_deref(),
|
query.search.as_deref(),
|
||||||
query.following.as_ref(),
|
following.as_ref(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn build_following_filter(
|
||||||
|
_ctx: &AppContext,
|
||||||
|
query: &GetActivityFeedQuery,
|
||||||
|
) -> Option<FollowingFilter> {
|
||||||
|
#[cfg(not(feature = "federation"))]
|
||||||
|
{
|
||||||
|
let _ = query;
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
#[cfg(feature = "federation")]
|
||||||
|
{
|
||||||
|
if !query.filter_following {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let viewer_id = match query.viewer_user_id {
|
||||||
|
Some(id) => id,
|
||||||
|
None => return None,
|
||||||
|
};
|
||||||
|
let urls = _ctx
|
||||||
|
.social_query
|
||||||
|
.get_accepted_following_urls(viewer_id)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
let base_url = &_ctx.config.base_url;
|
||||||
|
let mut local_ids = vec![viewer_id];
|
||||||
|
let mut remote_urls = Vec::new();
|
||||||
|
for url in urls {
|
||||||
|
if let Some(suffix) = url.strip_prefix(&format!("{}/users/", base_url))
|
||||||
|
&& let Ok(parsed_id) = uuid::Uuid::parse_str(suffix)
|
||||||
|
{
|
||||||
|
local_ids.push(parsed_id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
remote_urls.push(url);
|
||||||
|
}
|
||||||
|
Some(FollowingFilter {
|
||||||
|
local_user_ids: local_ids,
|
||||||
|
remote_actor_urls: remote_urls,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
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,
|
value_objects::UserId,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
pub struct PendingFollowerView {
|
||||||
|
pub url: String,
|
||||||
|
pub handle: String,
|
||||||
|
pub display_name: Option<String>,
|
||||||
|
pub avatar_url: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
pub struct UserProfileData {
|
pub struct UserProfileData {
|
||||||
pub stats: UserStats,
|
pub stats: UserStats,
|
||||||
pub entries: Option<Paginated<DiaryEntry>>,
|
pub entries: Option<Paginated<DiaryEntry>>,
|
||||||
pub history: Option<Vec<MonthActivity>>,
|
pub history: Option<Vec<MonthActivity>>,
|
||||||
pub trends: Option<UserTrends>,
|
pub trends: Option<UserTrends>,
|
||||||
|
pub following_count: usize,
|
||||||
|
pub followers_count: usize,
|
||||||
|
pub pending_followers: Vec<PendingFollowerView>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn execute(
|
pub async fn execute(
|
||||||
@@ -27,27 +37,30 @@ pub async fn execute(
|
|||||||
let user_id = UserId::from_uuid(query.user_id);
|
let user_id = UserId::from_uuid(query.user_id);
|
||||||
let stats = ctx.stats_repository.get_user_stats(&user_id).await?;
|
let stats = ctx.stats_repository.get_user_stats(&user_id).await?;
|
||||||
|
|
||||||
|
let (following_count, followers_count, pending_followers) =
|
||||||
|
load_social_counts(ctx, query.user_id, query.is_own_profile).await;
|
||||||
|
|
||||||
|
let base = |entries, history, trends| UserProfileData {
|
||||||
|
stats,
|
||||||
|
entries,
|
||||||
|
history,
|
||||||
|
trends,
|
||||||
|
following_count,
|
||||||
|
followers_count,
|
||||||
|
pending_followers,
|
||||||
|
};
|
||||||
|
|
||||||
match query.view {
|
match query.view {
|
||||||
ProfileView::History => {
|
ProfileView::History => {
|
||||||
let all_entries = ctx.diary_repository.get_user_history(&user_id).await?;
|
let all_entries = ctx.diary_repository.get_user_history(&user_id).await?;
|
||||||
let history = group_by_month(all_entries);
|
let history = group_by_month(all_entries);
|
||||||
Ok(UserProfileData {
|
Ok(base(None, Some(history), None))
|
||||||
stats,
|
|
||||||
entries: None,
|
|
||||||
history: Some(history),
|
|
||||||
trends: None,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
ProfileView::Trends => {
|
ProfileView::Trends => {
|
||||||
let trends = ctx.stats_repository.get_user_trends(&user_id).await?;
|
let trends = ctx.stats_repository.get_user_trends(&user_id).await?;
|
||||||
Ok(UserProfileData {
|
Ok(base(None, None, Some(trends)))
|
||||||
stats,
|
|
||||||
entries: None,
|
|
||||||
history: None,
|
|
||||||
trends: Some(trends),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
ProfileView::Ratings => {
|
ProfileView::Ratings | ProfileView::Recent => {
|
||||||
let sort_direction = feed_sort_to_direction(query.sort_by);
|
let sort_direction = feed_sort_to_direction(query.sort_by);
|
||||||
let filter = paged_user_filter(
|
let filter = paged_user_filter(
|
||||||
user_id,
|
user_id,
|
||||||
@@ -57,30 +70,49 @@ pub async fn execute(
|
|||||||
query.search.clone(),
|
query.search.clone(),
|
||||||
)?;
|
)?;
|
||||||
let entries = ctx.diary_repository.query_diary(&filter).await?;
|
let entries = ctx.diary_repository.query_diary(&filter).await?;
|
||||||
Ok(UserProfileData {
|
Ok(base(Some(entries), None, None))
|
||||||
stats,
|
|
||||||
entries: Some(entries),
|
|
||||||
history: None,
|
|
||||||
trends: None,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
ProfileView::Recent => {
|
}
|
||||||
let sort_direction = feed_sort_to_direction(query.sort_by);
|
}
|
||||||
let filter = paged_user_filter(
|
|
||||||
user_id,
|
async fn load_social_counts(
|
||||||
sort_direction,
|
_ctx: &AppContext,
|
||||||
query.limit,
|
_user_id: uuid::Uuid,
|
||||||
query.offset,
|
_is_own_profile: bool,
|
||||||
query.search.clone(),
|
) -> (usize, usize, Vec<PendingFollowerView>) {
|
||||||
)?;
|
#[cfg(not(feature = "federation"))]
|
||||||
let entries = ctx.diary_repository.query_diary(&filter).await?;
|
{
|
||||||
Ok(UserProfileData {
|
(0, 0, vec![])
|
||||||
stats,
|
}
|
||||||
entries: Some(entries),
|
#[cfg(feature = "federation")]
|
||||||
history: None,
|
{
|
||||||
trends: None,
|
if !_is_own_profile {
|
||||||
})
|
return (0, 0, vec![]);
|
||||||
}
|
}
|
||||||
|
let following = _ctx
|
||||||
|
.social_query
|
||||||
|
.count_following(_user_id)
|
||||||
|
.await
|
||||||
|
.unwrap_or(0);
|
||||||
|
let followers = _ctx
|
||||||
|
.social_query
|
||||||
|
.count_accepted_followers(_user_id)
|
||||||
|
.await
|
||||||
|
.unwrap_or(0);
|
||||||
|
let pending = _ctx
|
||||||
|
.social_query
|
||||||
|
.get_pending_followers(_user_id)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default()
|
||||||
|
.into_iter()
|
||||||
|
.map(|p| PendingFollowerView {
|
||||||
|
url: p.url,
|
||||||
|
handle: p.handle,
|
||||||
|
display_name: p.display_name,
|
||||||
|
avatar_url: p.avatar_url,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
(following, followers, pending)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,28 @@
|
|||||||
use crate::{context::AppContext, queries::GetUsersQuery};
|
use crate::{context::AppContext, queries::GetUsersQuery};
|
||||||
use domain::{errors::DomainError, models::UserSummary};
|
use domain::{errors::DomainError, models::UserSummary, ports::RemoteActorInfo};
|
||||||
|
|
||||||
|
pub struct UsersListData {
|
||||||
|
pub users: Vec<UserSummary>,
|
||||||
|
pub remote_actors: Vec<RemoteActorInfo>,
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn execute(
|
pub async fn execute(
|
||||||
ctx: &AppContext,
|
ctx: &AppContext,
|
||||||
_query: GetUsersQuery,
|
_query: GetUsersQuery,
|
||||||
) -> Result<Vec<UserSummary>, DomainError> {
|
) -> Result<UsersListData, DomainError> {
|
||||||
ctx.user_repository.list_with_stats().await
|
#[cfg(feature = "federation")]
|
||||||
|
let (users_result, actors_result) = tokio::join!(
|
||||||
|
ctx.user_repository.list_with_stats(),
|
||||||
|
ctx.social_query.list_all_followed_remote_actors()
|
||||||
|
);
|
||||||
|
#[cfg(not(feature = "federation"))]
|
||||||
|
let (users_result, actors_result) = (
|
||||||
|
ctx.user_repository.list_with_stats().await,
|
||||||
|
Ok::<Vec<RemoteActorInfo>, DomainError>(vec![]),
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(UsersListData {
|
||||||
|
users: users_result?,
|
||||||
|
remote_actors: actors_result?,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
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 execute_import;
|
||||||
pub mod export_diary;
|
pub mod export_diary;
|
||||||
pub mod get_activity_feed;
|
pub mod get_activity_feed;
|
||||||
|
pub mod get_current_profile;
|
||||||
pub mod get_diary;
|
pub mod get_diary;
|
||||||
pub mod get_movie_social_page;
|
pub mod get_movie_social_page;
|
||||||
pub mod get_movies;
|
pub mod get_movies;
|
||||||
@@ -20,11 +21,13 @@ pub mod get_review_history;
|
|||||||
pub mod get_user_profile;
|
pub mod get_user_profile;
|
||||||
pub mod get_users;
|
pub mod get_users;
|
||||||
pub mod get_watchlist;
|
pub mod get_watchlist;
|
||||||
|
pub mod get_watchlist_page;
|
||||||
pub mod is_on_watchlist;
|
pub mod is_on_watchlist;
|
||||||
pub mod list_import_profiles;
|
pub mod list_import_profiles;
|
||||||
pub mod log_review;
|
pub mod log_review;
|
||||||
pub mod login;
|
pub mod login;
|
||||||
pub mod register;
|
pub mod register;
|
||||||
|
pub mod register_and_login;
|
||||||
pub mod remove_from_watchlist;
|
pub mod remove_from_watchlist;
|
||||||
pub mod save_import_profile;
|
pub mod save_import_profile;
|
||||||
pub mod search;
|
pub mod search;
|
||||||
|
|||||||
@@ -54,11 +54,7 @@ mod tests {
|
|||||||
use domain::testing::InMemoryUserRepository;
|
use domain::testing::InMemoryUserRepository;
|
||||||
use domain::value_objects::Email;
|
use domain::value_objects::Email;
|
||||||
|
|
||||||
use crate::{
|
use crate::{commands::RegisterCommand, test_helpers::TestContextBuilder, use_cases::register};
|
||||||
commands::RegisterCommand,
|
|
||||||
test_helpers::TestContextBuilder,
|
|
||||||
use_cases::register,
|
|
||||||
};
|
|
||||||
|
|
||||||
fn cmd(email: &str) -> RegisterCommand {
|
fn cmd(email: &str) -> RegisterCommand {
|
||||||
RegisterCommand {
|
RegisterCommand {
|
||||||
@@ -76,7 +72,9 @@ mod tests {
|
|||||||
.with_users(Arc::clone(&users) as _)
|
.with_users(Arc::clone(&users) as _)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
register::execute(&ctx, cmd("alice@example.com")).await.unwrap();
|
register::execute(&ctx, cmd("alice@example.com"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let email = Email::new("alice@example.com".into()).unwrap();
|
let email = Email::new("alice@example.com".into()).unwrap();
|
||||||
let user = users.find_by_email(&email).await.unwrap().unwrap();
|
let user = users.find_by_email(&email).await.unwrap().unwrap();
|
||||||
@@ -91,7 +89,9 @@ mod tests {
|
|||||||
.with_users(Arc::clone(&users) as _)
|
.with_users(Arc::clone(&users) as _)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
register::execute(&ctx, cmd("bob@example.com")).await.unwrap();
|
register::execute(&ctx, cmd("bob@example.com"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
let result = register::execute(&ctx, cmd("bob@example.com")).await;
|
let result = register::execute(&ctx, cmd("bob@example.com")).await;
|
||||||
assert!(result.is_err(), "duplicate email should fail");
|
assert!(result.is_err(), "duplicate email should fail");
|
||||||
}
|
}
|
||||||
|
|||||||
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,
|
errors::DomainError,
|
||||||
events::DomainEvent,
|
events::DomainEvent,
|
||||||
models::IndexableDocument,
|
models::IndexableDocument,
|
||||||
value_objects::{ExternalMetadataId, MovieId, PosterPath},
|
value_objects::{MovieId, PosterPath},
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::{commands::SyncPosterCommand, context::AppContext};
|
use crate::{commands::SyncPosterCommand, context::AppContext};
|
||||||
|
|
||||||
pub async fn execute(ctx: &AppContext, cmd: SyncPosterCommand) -> Result<(), DomainError> {
|
pub async fn execute(ctx: &AppContext, cmd: SyncPosterCommand) -> Result<(), DomainError> {
|
||||||
let movie_id = MovieId::from_uuid(cmd.movie_id);
|
let movie_id = MovieId::from_uuid(cmd.movie_id);
|
||||||
let external_metadata_id = ExternalMetadataId::new(cmd.external_metadata_id)?;
|
|
||||||
|
|
||||||
let mut movie = match ctx.movie_repository.get_movie_by_id(&movie_id).await? {
|
let mut movie = match ctx.movie_repository.get_movie_by_id(&movie_id).await? {
|
||||||
Some(m) => m,
|
Some(m) => m,
|
||||||
@@ -22,6 +21,15 @@ pub async fn execute(ctx: &AppContext, cmd: SyncPosterCommand) -> Result<(), Dom
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let external_metadata_id = movie
|
||||||
|
.external_metadata_id()
|
||||||
|
.ok_or_else(|| {
|
||||||
|
DomainError::ValidationError(
|
||||||
|
"Movie has no external metadata ID, cannot sync poster".into(),
|
||||||
|
)
|
||||||
|
})?
|
||||||
|
.clone();
|
||||||
|
|
||||||
let poster_url = match ctx
|
let poster_url = match ctx
|
||||||
.metadata_client
|
.metadata_client
|
||||||
.get_poster_url(&external_metadata_id)
|
.get_poster_url(&external_metadata_id)
|
||||||
|
|||||||
@@ -68,10 +68,14 @@ pub async fn execute(ctx: &AppContext, cmd: UpdateProfileCommand) -> Result<(),
|
|||||||
ctx.user_repository
|
ctx.user_repository
|
||||||
.update_profile(
|
.update_profile(
|
||||||
&user_id,
|
&user_id,
|
||||||
cmd.bio,
|
&domain::models::UserProfile {
|
||||||
new_avatar_path,
|
display_name: cmd.display_name,
|
||||||
new_banner_path,
|
bio: cmd.bio,
|
||||||
cmd.also_known_as,
|
avatar_path: new_avatar_path,
|
||||||
|
banner_path: new_banner_path,
|
||||||
|
also_known_as: cmd.also_known_as,
|
||||||
|
profile_fields: vec![],
|
||||||
|
},
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
|||||||
@@ -61,6 +61,10 @@ pub enum DomainEvent {
|
|||||||
remote_actor_url: String,
|
remote_actor_url: String,
|
||||||
outbox_url: String,
|
outbox_url: String,
|
||||||
},
|
},
|
||||||
|
BackfillFollower {
|
||||||
|
owner_user_id: UserId,
|
||||||
|
follower_inbox_url: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
|
|||||||
@@ -163,6 +163,17 @@ pub enum ReviewSource {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub struct PersistedReview {
|
||||||
|
pub id: ReviewId,
|
||||||
|
pub movie_id: MovieId,
|
||||||
|
pub user_id: UserId,
|
||||||
|
pub rating: Rating,
|
||||||
|
pub comment: Option<Comment>,
|
||||||
|
pub watched_at: NaiveDateTime,
|
||||||
|
pub created_at: NaiveDateTime,
|
||||||
|
pub source: ReviewSource,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct Review {
|
pub struct Review {
|
||||||
id: ReviewId,
|
id: ReviewId,
|
||||||
@@ -195,25 +206,16 @@ impl Review {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn from_persistence(
|
pub fn from_persistence(row: PersistedReview) -> Self {
|
||||||
id: ReviewId,
|
|
||||||
movie_id: MovieId,
|
|
||||||
user_id: UserId,
|
|
||||||
rating: Rating,
|
|
||||||
comment: Option<Comment>,
|
|
||||||
watched_at: NaiveDateTime,
|
|
||||||
created_at: NaiveDateTime,
|
|
||||||
source: ReviewSource,
|
|
||||||
) -> Self {
|
|
||||||
Self {
|
Self {
|
||||||
id,
|
id: row.id,
|
||||||
movie_id,
|
movie_id: row.movie_id,
|
||||||
user_id,
|
user_id: row.user_id,
|
||||||
rating,
|
rating: row.rating,
|
||||||
comment,
|
comment: row.comment,
|
||||||
watched_at,
|
watched_at: row.watched_at,
|
||||||
created_at,
|
created_at: row.created_at,
|
||||||
source,
|
source: row.source,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -314,6 +316,16 @@ pub struct ProfileField {
|
|||||||
pub value: String,
|
pub value: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default)]
|
||||||
|
pub struct UserProfile {
|
||||||
|
pub display_name: Option<String>,
|
||||||
|
pub bio: Option<String>,
|
||||||
|
pub avatar_path: Option<String>,
|
||||||
|
pub banner_path: Option<String>,
|
||||||
|
pub also_known_as: Option<String>,
|
||||||
|
pub profile_fields: Vec<ProfileField>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct User {
|
pub struct User {
|
||||||
id: UserId,
|
id: UserId,
|
||||||
@@ -321,11 +333,7 @@ pub struct User {
|
|||||||
username: Username,
|
username: Username,
|
||||||
password_hash: PasswordHash,
|
password_hash: PasswordHash,
|
||||||
role: UserRole,
|
role: UserRole,
|
||||||
bio: Option<String>,
|
profile: UserProfile,
|
||||||
avatar_path: Option<String>,
|
|
||||||
banner_path: Option<String>,
|
|
||||||
also_known_as: Option<String>,
|
|
||||||
profile_fields: Vec<ProfileField>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl User {
|
impl User {
|
||||||
@@ -341,11 +349,7 @@ impl User {
|
|||||||
username,
|
username,
|
||||||
password_hash,
|
password_hash,
|
||||||
role,
|
role,
|
||||||
bio: None,
|
profile: UserProfile::default(),
|
||||||
avatar_path: None,
|
|
||||||
banner_path: None,
|
|
||||||
also_known_as: None,
|
|
||||||
profile_fields: vec![],
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -355,11 +359,7 @@ impl User {
|
|||||||
username: Username,
|
username: Username,
|
||||||
password_hash: PasswordHash,
|
password_hash: PasswordHash,
|
||||||
role: UserRole,
|
role: UserRole,
|
||||||
bio: Option<String>,
|
profile: UserProfile,
|
||||||
avatar_path: Option<String>,
|
|
||||||
banner_path: Option<String>,
|
|
||||||
also_known_as: Option<String>,
|
|
||||||
profile_fields: Vec<ProfileField>,
|
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
id,
|
id,
|
||||||
@@ -367,11 +367,7 @@ impl User {
|
|||||||
username,
|
username,
|
||||||
password_hash,
|
password_hash,
|
||||||
role,
|
role,
|
||||||
bio,
|
profile,
|
||||||
avatar_path,
|
|
||||||
banner_path,
|
|
||||||
also_known_as,
|
|
||||||
profile_fields,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -379,17 +375,8 @@ impl User {
|
|||||||
self.password_hash = new_hash;
|
self.password_hash = new_hash;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn update_profile(
|
pub fn update_profile(&mut self, profile: UserProfile) {
|
||||||
&mut self,
|
self.profile = profile;
|
||||||
bio: Option<String>,
|
|
||||||
avatar_path: Option<String>,
|
|
||||||
banner_path: Option<String>,
|
|
||||||
also_known_as: Option<String>,
|
|
||||||
) {
|
|
||||||
self.bio = bio;
|
|
||||||
self.avatar_path = avatar_path;
|
|
||||||
self.banner_path = banner_path;
|
|
||||||
self.also_known_as = also_known_as;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn email(&self) -> &Email {
|
pub fn email(&self) -> &Email {
|
||||||
@@ -407,24 +394,23 @@ impl User {
|
|||||||
pub fn role(&self) -> &UserRole {
|
pub fn role(&self) -> &UserRole {
|
||||||
&self.role
|
&self.role
|
||||||
}
|
}
|
||||||
|
pub fn display_name(&self) -> Option<&str> {
|
||||||
|
self.profile.display_name.as_deref()
|
||||||
|
}
|
||||||
pub fn bio(&self) -> Option<&str> {
|
pub fn bio(&self) -> Option<&str> {
|
||||||
self.bio.as_deref()
|
self.profile.bio.as_deref()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn avatar_path(&self) -> Option<&str> {
|
pub fn avatar_path(&self) -> Option<&str> {
|
||||||
self.avatar_path.as_deref()
|
self.profile.avatar_path.as_deref()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn banner_path(&self) -> Option<&str> {
|
pub fn banner_path(&self) -> Option<&str> {
|
||||||
self.banner_path.as_deref()
|
self.profile.banner_path.as_deref()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn also_known_as(&self) -> Option<&str> {
|
pub fn also_known_as(&self) -> Option<&str> {
|
||||||
self.also_known_as.as_deref()
|
self.profile.also_known_as.as_deref()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn profile_fields(&self) -> &[ProfileField] {
|
pub fn profile_fields(&self) -> &[ProfileField] {
|
||||||
&self.profile_fields
|
&self.profile.profile_fields
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,23 +8,18 @@ fn make_user() -> User {
|
|||||||
Username::new("alice".to_string()).unwrap(),
|
Username::new("alice".to_string()).unwrap(),
|
||||||
PasswordHash::new("hash".to_string()).unwrap(),
|
PasswordHash::new("hash".to_string()).unwrap(),
|
||||||
UserRole::Standard,
|
UserRole::Standard,
|
||||||
None,
|
UserProfile::default(),
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
vec![],
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn update_profile_sets_fields() {
|
fn update_profile_sets_fields() {
|
||||||
let mut user = make_user();
|
let mut user = make_user();
|
||||||
user.update_profile(
|
user.update_profile(UserProfile {
|
||||||
Some("My bio".to_string()),
|
bio: Some("My bio".to_string()),
|
||||||
Some("avatars/abc".to_string()),
|
avatar_path: Some("avatars/abc".to_string()),
|
||||||
None,
|
..Default::default()
|
||||||
None,
|
});
|
||||||
);
|
|
||||||
assert_eq!(user.bio(), Some("My bio"));
|
assert_eq!(user.bio(), Some("My bio"));
|
||||||
assert_eq!(user.avatar_path(), Some("avatars/abc"));
|
assert_eq!(user.avatar_path(), Some("avatars/abc"));
|
||||||
}
|
}
|
||||||
@@ -32,13 +27,12 @@ fn update_profile_sets_fields() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn update_profile_clears_with_none() {
|
fn update_profile_clears_with_none() {
|
||||||
let mut user = make_user();
|
let mut user = make_user();
|
||||||
user.update_profile(
|
user.update_profile(UserProfile {
|
||||||
Some("bio".to_string()),
|
bio: Some("bio".to_string()),
|
||||||
Some("path".to_string()),
|
avatar_path: Some("path".to_string()),
|
||||||
None,
|
..Default::default()
|
||||||
None,
|
});
|
||||||
);
|
user.update_profile(UserProfile::default());
|
||||||
user.update_profile(None, None, None, None);
|
|
||||||
assert_eq!(user.bio(), None);
|
assert_eq!(user.bio(), None);
|
||||||
assert_eq!(user.avatar_path(), None);
|
assert_eq!(user.avatar_path(), None);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,14 +33,15 @@ pub enum FeedSortBy {
|
|||||||
RatingAsc,
|
RatingAsc,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FeedSortBy {
|
impl std::str::FromStr for FeedSortBy {
|
||||||
pub fn from_str(s: &str) -> Self {
|
type Err = std::convert::Infallible;
|
||||||
match s {
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
|
Ok(match s {
|
||||||
"date_asc" => Self::DateAsc,
|
"date_asc" => Self::DateAsc,
|
||||||
"rating" => Self::Rating,
|
"rating" => Self::Rating,
|
||||||
"rating_asc" => Self::RatingAsc,
|
"rating_asc" => Self::RatingAsc,
|
||||||
_ => Self::Date,
|
_ => Self::Date,
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,17 +58,31 @@ pub struct RemoteActorInfo {
|
|||||||
pub display_name: Option<String>,
|
pub display_name: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// New trait for social/federation read queries
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct PendingFollowerInfo {
|
||||||
|
pub url: String,
|
||||||
|
pub handle: String,
|
||||||
|
pub display_name: Option<String>,
|
||||||
|
pub avatar_url: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait SocialQueryPort: Send + Sync {
|
pub trait SocialQueryPort: Send + Sync {
|
||||||
/// Returns all accepted remote_actor_urls followed by `user_id`.
|
|
||||||
async fn get_accepted_following_urls(
|
async fn get_accepted_following_urls(
|
||||||
&self,
|
&self,
|
||||||
user_id: uuid::Uuid,
|
user_id: uuid::Uuid,
|
||||||
) -> Result<Vec<String>, DomainError>;
|
) -> Result<Vec<String>, DomainError>;
|
||||||
|
|
||||||
/// Returns all distinct remote actors followed by any local user on this instance.
|
|
||||||
async fn list_all_followed_remote_actors(&self) -> Result<Vec<RemoteActorInfo>, DomainError>;
|
async fn list_all_followed_remote_actors(&self) -> Result<Vec<RemoteActorInfo>, DomainError>;
|
||||||
|
|
||||||
|
async fn count_following(&self, user_id: uuid::Uuid) -> Result<usize, DomainError>;
|
||||||
|
|
||||||
|
async fn count_accepted_followers(&self, user_id: uuid::Uuid) -> Result<usize, DomainError>;
|
||||||
|
|
||||||
|
async fn get_pending_followers(
|
||||||
|
&self,
|
||||||
|
user_id: uuid::Uuid,
|
||||||
|
) -> Result<Vec<PendingFollowerInfo>, DomainError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -185,10 +200,7 @@ pub trait UserRepository: Send + Sync {
|
|||||||
async fn update_profile(
|
async fn update_profile(
|
||||||
&self,
|
&self,
|
||||||
user_id: &UserId,
|
user_id: &UserId,
|
||||||
bio: Option<String>,
|
profile: &crate::models::UserProfile,
|
||||||
avatar_path: Option<String>,
|
|
||||||
banner_path: Option<String>,
|
|
||||||
also_known_as: Option<String>,
|
|
||||||
) -> Result<(), DomainError>;
|
) -> Result<(), DomainError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -390,12 +402,16 @@ pub trait LocalApContentQuery: Send + Sync {
|
|||||||
user_id: &UserId,
|
user_id: &UserId,
|
||||||
) -> Result<Vec<WatchlistWithMovie>, DomainError>;
|
) -> Result<Vec<WatchlistWithMovie>, DomainError>;
|
||||||
|
|
||||||
async fn get_review_by_id(
|
async fn get_review_by_id(&self, review_id: &ReviewId) -> Result<Option<Review>, DomainError>;
|
||||||
&self,
|
|
||||||
review_id: &ReviewId,
|
|
||||||
) -> Result<Option<Review>, DomainError>;
|
|
||||||
|
|
||||||
async fn get_movie_by_id(&self, movie_id: &MovieId) -> Result<Option<Movie>, DomainError>;
|
async fn get_movie_by_id(&self, movie_id: &MovieId) -> Result<Option<Movie>, DomainError>;
|
||||||
|
|
||||||
async fn count_local_posts(&self) -> Result<u64, DomainError>;
|
async fn count_local_posts(&self) -> Result<u64, DomainError>;
|
||||||
|
|
||||||
|
async fn get_local_reviews_page(
|
||||||
|
&self,
|
||||||
|
user_id: &UserId,
|
||||||
|
before: Option<chrono::NaiveDateTime>,
|
||||||
|
limit: usize,
|
||||||
|
) -> Result<Vec<DiaryEntry>, DomainError>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,16 +11,16 @@ use crate::{
|
|||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
events::DomainEvent,
|
events::DomainEvent,
|
||||||
models::{
|
models::{
|
||||||
DiaryEntry, DiaryFilter, ExportFormat, FeedEntry, FieldMapping, FileFormat, ImportError,
|
AnnotatedRow, DiaryEntry, DiaryFilter, EntityType, ExportFormat, ExternalPersonId,
|
||||||
ImportProfile, ImportSession, IndexableDocument, Movie, MovieFilter, MovieProfile,
|
FeedEntry, FieldMapping, FileFormat, ImportError, ImportProfile, ImportSession,
|
||||||
MovieStats, MovieSummary, ParsedFile, Person, PersonCredits, PersonId, ExternalPersonId,
|
IndexableDocument, Movie, MovieFilter, MovieProfile, MovieStats, MovieSummary, ParsedFile,
|
||||||
Review, ReviewHistory, SearchQuery, SearchResults, User, UserStats, UserSummary,
|
Person, PersonCredits, PersonId, Review, ReviewHistory, SearchQuery, SearchResults, User,
|
||||||
UserTrends, WatchlistEntry, WatchlistWithMovie, AnnotatedRow, EntityType,
|
UserStats, UserSummary, UserTrends, WatchlistEntry, WatchlistWithMovie,
|
||||||
collections::{PageParams, Paginated},
|
collections::{PageParams, Paginated},
|
||||||
},
|
},
|
||||||
ports::{
|
ports::{
|
||||||
AuthService, DiaryExporter, DiaryRepository, DocumentParser, EventPublisher,
|
AuthService, DiaryExporter, DiaryRepository, DocumentParser, EventPublisher, FeedSortBy,
|
||||||
FeedSortBy, FollowingFilter, GeneratedToken, ImageStorage, ImportProfileRepository,
|
FollowingFilter, GeneratedToken, ImageStorage, ImportProfileRepository,
|
||||||
ImportSessionRepository, MetadataClient, MetadataSearchCriteria, MovieProfileRepository,
|
ImportSessionRepository, MetadataClient, MetadataSearchCriteria, MovieProfileRepository,
|
||||||
MovieRepository, PasswordHasher, PersonCommand, PersonQuery, PosterFetcherClient,
|
MovieRepository, PasswordHasher, PersonCommand, PersonQuery, PosterFetcherClient,
|
||||||
ReviewRepository, SearchCommand, SearchPort, StatsRepository, UserProfileFieldsRepository,
|
ReviewRepository, SearchCommand, SearchPort, StatsRepository, UserProfileFieldsRepository,
|
||||||
@@ -40,7 +40,9 @@ pub struct InMemoryMovieRepository {
|
|||||||
|
|
||||||
impl InMemoryMovieRepository {
|
impl InMemoryMovieRepository {
|
||||||
pub fn new() -> Arc<Self> {
|
pub fn new() -> Arc<Self> {
|
||||||
Arc::new(Self { store: Mutex::new(HashMap::new()) })
|
Arc::new(Self {
|
||||||
|
store: Mutex::new(HashMap::new()),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn count(&self) -> usize {
|
pub fn count(&self) -> usize {
|
||||||
@@ -55,11 +57,14 @@ impl MovieRepository for InMemoryMovieRepository {
|
|||||||
external_metadata_id: &ExternalMetadataId,
|
external_metadata_id: &ExternalMetadataId,
|
||||||
) -> Result<Option<Movie>, DomainError> {
|
) -> Result<Option<Movie>, DomainError> {
|
||||||
let store = self.store.lock().unwrap();
|
let store = self.store.lock().unwrap();
|
||||||
Ok(store.values().find(|m| {
|
Ok(store
|
||||||
m.external_metadata_id()
|
.values()
|
||||||
.map(|e| e.value() == external_metadata_id.value())
|
.find(|m| {
|
||||||
.unwrap_or(false)
|
m.external_metadata_id()
|
||||||
}).cloned())
|
.map(|e| e.value() == external_metadata_id.value())
|
||||||
|
.unwrap_or(false)
|
||||||
|
})
|
||||||
|
.cloned())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_movie_by_id(&self, movie_id: &MovieId) -> Result<Option<Movie>, DomainError> {
|
async fn get_movie_by_id(&self, movie_id: &MovieId) -> Result<Option<Movie>, DomainError> {
|
||||||
@@ -72,11 +77,18 @@ impl MovieRepository for InMemoryMovieRepository {
|
|||||||
year: &ReleaseYear,
|
year: &ReleaseYear,
|
||||||
) -> Result<Vec<Movie>, DomainError> {
|
) -> Result<Vec<Movie>, DomainError> {
|
||||||
let store = self.store.lock().unwrap();
|
let store = self.store.lock().unwrap();
|
||||||
Ok(store.values().filter(|m| m.title() == title && m.release_year() == year).cloned().collect())
|
Ok(store
|
||||||
|
.values()
|
||||||
|
.filter(|m| m.title() == title && m.release_year() == year)
|
||||||
|
.cloned()
|
||||||
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn upsert_movie(&self, movie: &Movie) -> Result<(), DomainError> {
|
async fn upsert_movie(&self, movie: &Movie) -> Result<(), DomainError> {
|
||||||
self.store.lock().unwrap().insert(movie.id().value(), movie.clone());
|
self.store
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.insert(movie.id().value(), movie.clone());
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,7 +102,12 @@ impl MovieRepository for InMemoryMovieRepository {
|
|||||||
_page: &crate::models::collections::PageParams,
|
_page: &crate::models::collections::PageParams,
|
||||||
_filter: &MovieFilter,
|
_filter: &MovieFilter,
|
||||||
) -> Result<Paginated<MovieSummary>, DomainError> {
|
) -> Result<Paginated<MovieSummary>, DomainError> {
|
||||||
Ok(Paginated { items: vec![], total_count: 0, limit: 10, offset: 0 })
|
Ok(Paginated {
|
||||||
|
items: vec![],
|
||||||
|
total_count: 0,
|
||||||
|
limit: 10,
|
||||||
|
offset: 0,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,7 +119,9 @@ pub struct InMemoryReviewRepository {
|
|||||||
|
|
||||||
impl InMemoryReviewRepository {
|
impl InMemoryReviewRepository {
|
||||||
pub fn new() -> Arc<Self> {
|
pub fn new() -> Arc<Self> {
|
||||||
Arc::new(Self { store: Mutex::new(HashMap::new()) })
|
Arc::new(Self {
|
||||||
|
store: Mutex::new(HashMap::new()),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn count(&self) -> usize {
|
pub fn count(&self) -> usize {
|
||||||
@@ -113,7 +132,10 @@ impl InMemoryReviewRepository {
|
|||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl ReviewRepository for InMemoryReviewRepository {
|
impl ReviewRepository for InMemoryReviewRepository {
|
||||||
async fn save_review(&self, review: &Review) -> Result<DomainEvent, DomainError> {
|
async fn save_review(&self, review: &Review) -> Result<DomainEvent, DomainError> {
|
||||||
self.store.lock().unwrap().insert(review.id().value(), review.clone());
|
self.store
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.insert(review.id().value(), review.clone());
|
||||||
Ok(DomainEvent::ReviewLogged {
|
Ok(DomainEvent::ReviewLogged {
|
||||||
review_id: review.id().clone(),
|
review_id: review.id().clone(),
|
||||||
movie_id: review.movie_id().clone(),
|
movie_id: review.movie_id().clone(),
|
||||||
@@ -134,7 +156,11 @@ impl ReviewRepository for InMemoryReviewRepository {
|
|||||||
|
|
||||||
async fn get_all_reviews_for_user(&self, user_id: &UserId) -> Result<Vec<Review>, DomainError> {
|
async fn get_all_reviews_for_user(&self, user_id: &UserId) -> Result<Vec<Review>, DomainError> {
|
||||||
let store = self.store.lock().unwrap();
|
let store = self.store.lock().unwrap();
|
||||||
Ok(store.values().filter(|r| r.user_id() == user_id).cloned().collect())
|
Ok(store
|
||||||
|
.values()
|
||||||
|
.filter(|r| r.user_id() == user_id)
|
||||||
|
.cloned()
|
||||||
|
.collect())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,7 +172,9 @@ pub struct InMemoryUserRepository {
|
|||||||
|
|
||||||
impl InMemoryUserRepository {
|
impl InMemoryUserRepository {
|
||||||
pub fn new() -> Arc<Self> {
|
pub fn new() -> Arc<Self> {
|
||||||
Arc::new(Self { store: Mutex::new(HashMap::new()) })
|
Arc::new(Self {
|
||||||
|
store: Mutex::new(HashMap::new()),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn count(&self) -> usize {
|
pub fn count(&self) -> usize {
|
||||||
@@ -158,16 +186,25 @@ impl InMemoryUserRepository {
|
|||||||
impl UserRepository for InMemoryUserRepository {
|
impl UserRepository for InMemoryUserRepository {
|
||||||
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError> {
|
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError> {
|
||||||
let store = self.store.lock().unwrap();
|
let store = self.store.lock().unwrap();
|
||||||
Ok(store.values().find(|u| u.email().value() == email.value()).cloned())
|
Ok(store
|
||||||
|
.values()
|
||||||
|
.find(|u| u.email().value() == email.value())
|
||||||
|
.cloned())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn find_by_username(&self, username: &Username) -> Result<Option<User>, DomainError> {
|
async fn find_by_username(&self, username: &Username) -> Result<Option<User>, DomainError> {
|
||||||
let store = self.store.lock().unwrap();
|
let store = self.store.lock().unwrap();
|
||||||
Ok(store.values().find(|u| u.username().value() == username.value()).cloned())
|
Ok(store
|
||||||
|
.values()
|
||||||
|
.find(|u| u.username().value() == username.value())
|
||||||
|
.cloned())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn save(&self, user: &User) -> Result<(), DomainError> {
|
async fn save(&self, user: &User) -> Result<(), DomainError> {
|
||||||
self.store.lock().unwrap().insert(user.id().value(), user.clone());
|
self.store
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.insert(user.id().value(), user.clone());
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,10 +219,7 @@ impl UserRepository for InMemoryUserRepository {
|
|||||||
async fn update_profile(
|
async fn update_profile(
|
||||||
&self,
|
&self,
|
||||||
_user_id: &UserId,
|
_user_id: &UserId,
|
||||||
_bio: Option<String>,
|
_profile: &crate::models::UserProfile,
|
||||||
_avatar_path: Option<String>,
|
|
||||||
_banner_path: Option<String>,
|
|
||||||
_also_known_as: Option<String>,
|
|
||||||
) -> Result<(), DomainError> {
|
) -> Result<(), DomainError> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -199,7 +233,9 @@ pub struct InMemoryWatchlistRepository {
|
|||||||
|
|
||||||
impl InMemoryWatchlistRepository {
|
impl InMemoryWatchlistRepository {
|
||||||
pub fn new() -> Arc<Self> {
|
pub fn new() -> Arc<Self> {
|
||||||
Arc::new(Self { store: Mutex::new(HashMap::new()) })
|
Arc::new(Self {
|
||||||
|
store: Mutex::new(HashMap::new()),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn count(&self) -> usize {
|
pub fn count(&self) -> usize {
|
||||||
@@ -211,13 +247,20 @@ impl InMemoryWatchlistRepository {
|
|||||||
impl WatchlistRepository for InMemoryWatchlistRepository {
|
impl WatchlistRepository for InMemoryWatchlistRepository {
|
||||||
async fn add(&self, entry: &WatchlistEntry) -> Result<(), DomainError> {
|
async fn add(&self, entry: &WatchlistEntry) -> Result<(), DomainError> {
|
||||||
let key = (entry.user_id.value(), entry.movie_id.value());
|
let key = (entry.user_id.value(), entry.movie_id.value());
|
||||||
self.store.lock().unwrap().entry(key).or_insert_with(|| entry.clone());
|
self.store
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.entry(key)
|
||||||
|
.or_insert_with(|| entry.clone());
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn remove(&self, user_id: &UserId, movie_id: &MovieId) -> Result<(), DomainError> {
|
async fn remove(&self, user_id: &UserId, movie_id: &MovieId) -> Result<(), DomainError> {
|
||||||
let key = (user_id.value(), movie_id.value());
|
let key = (user_id.value(), movie_id.value());
|
||||||
self.store.lock().unwrap().remove(&key)
|
self.store
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.remove(&key)
|
||||||
.ok_or_else(|| DomainError::NotFound("watchlist entry".into()))?;
|
.ok_or_else(|| DomainError::NotFound("watchlist entry".into()))?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -236,7 +279,12 @@ impl WatchlistRepository for InMemoryWatchlistRepository {
|
|||||||
_user_id: &UserId,
|
_user_id: &UserId,
|
||||||
_page: &PageParams,
|
_page: &PageParams,
|
||||||
) -> Result<Paginated<WatchlistWithMovie>, DomainError> {
|
) -> Result<Paginated<WatchlistWithMovie>, DomainError> {
|
||||||
Ok(Paginated { items: vec![], total_count: 0, limit: 10, offset: 0 })
|
Ok(Paginated {
|
||||||
|
items: vec![],
|
||||||
|
total_count: 0,
|
||||||
|
limit: 10,
|
||||||
|
offset: 0,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn contains(&self, user_id: &UserId, movie_id: &MovieId) -> Result<bool, DomainError> {
|
async fn contains(&self, user_id: &UserId, movie_id: &MovieId) -> Result<bool, DomainError> {
|
||||||
@@ -253,7 +301,9 @@ pub struct NoopEventPublisher {
|
|||||||
|
|
||||||
impl NoopEventPublisher {
|
impl NoopEventPublisher {
|
||||||
pub fn new() -> Arc<Self> {
|
pub fn new() -> Arc<Self> {
|
||||||
Arc::new(Self { events: Mutex::new(vec![]) })
|
Arc::new(Self {
|
||||||
|
events: Mutex::new(vec![]),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn published(&self) -> Vec<DomainEvent> {
|
pub fn published(&self) -> Vec<DomainEvent> {
|
||||||
@@ -333,7 +383,9 @@ impl MetadataClient for FakeMetadataClient {
|
|||||||
&self,
|
&self,
|
||||||
_criteria: &MetadataSearchCriteria,
|
_criteria: &MetadataSearchCriteria,
|
||||||
) -> Result<Movie, DomainError> {
|
) -> Result<Movie, DomainError> {
|
||||||
Err(DomainError::InfrastructureError("fake metadata client".into()))
|
Err(DomainError::InfrastructureError(
|
||||||
|
"fake metadata client".into(),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_poster_url(
|
async fn get_poster_url(
|
||||||
@@ -352,17 +404,25 @@ pub struct FakeDiaryRepository {
|
|||||||
|
|
||||||
impl FakeDiaryRepository {
|
impl FakeDiaryRepository {
|
||||||
pub fn new() -> Arc<Self> {
|
pub fn new() -> Arc<Self> {
|
||||||
Arc::new(Self { histories: Mutex::new(HashMap::new()) })
|
Arc::new(Self {
|
||||||
|
histories: Mutex::new(HashMap::new()),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn seed_history(&self, movie: Movie, reviews: Vec<Review>) {
|
pub fn seed_history(&self, movie: Movie, reviews: Vec<Review>) {
|
||||||
self.histories.lock().unwrap().insert(movie.id().value(), (movie, reviews));
|
self.histories
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.insert(movie.id().value(), (movie, reviews));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl DiaryRepository for FakeDiaryRepository {
|
impl DiaryRepository for FakeDiaryRepository {
|
||||||
async fn query_diary(&self, _filter: &DiaryFilter) -> Result<Paginated<DiaryEntry>, DomainError> {
|
async fn query_diary(
|
||||||
|
&self,
|
||||||
|
_filter: &DiaryFilter,
|
||||||
|
) -> Result<Paginated<DiaryEntry>, DomainError> {
|
||||||
unimplemented!("FakeDiaryRepository::query_diary")
|
unimplemented!("FakeDiaryRepository::query_diary")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -418,7 +478,10 @@ pub struct PanicDiaryRepository;
|
|||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl DiaryRepository for PanicDiaryRepository {
|
impl DiaryRepository for PanicDiaryRepository {
|
||||||
async fn query_diary(&self, _filter: &DiaryFilter) -> Result<Paginated<DiaryEntry>, DomainError> {
|
async fn query_diary(
|
||||||
|
&self,
|
||||||
|
_filter: &DiaryFilter,
|
||||||
|
) -> Result<Paginated<DiaryEntry>, DomainError> {
|
||||||
panic!("PanicDiaryRepository called")
|
panic!("PanicDiaryRepository called")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -605,8 +668,18 @@ pub struct PanicSearchPort;
|
|||||||
impl SearchPort for PanicSearchPort {
|
impl SearchPort for PanicSearchPort {
|
||||||
async fn search(&self, _query: &SearchQuery) -> Result<SearchResults, DomainError> {
|
async fn search(&self, _query: &SearchQuery) -> Result<SearchResults, DomainError> {
|
||||||
Ok(SearchResults {
|
Ok(SearchResults {
|
||||||
movies: Paginated { items: vec![], total_count: 0, limit: 10, offset: 0 },
|
movies: Paginated {
|
||||||
people: Paginated { items: vec![], total_count: 0, limit: 10, offset: 0 },
|
items: vec![],
|
||||||
|
total_count: 0,
|
||||||
|
limit: 10,
|
||||||
|
offset: 0,
|
||||||
|
},
|
||||||
|
people: Paginated {
|
||||||
|
items: vec![],
|
||||||
|
total_count: 0,
|
||||||
|
limit: 10,
|
||||||
|
offset: 0,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -678,13 +751,19 @@ impl crate::ports::RemoteWatchlistRepository for PanicRemoteWatchlistRepository
|
|||||||
async fn remove_by_ap_id(&self, _: &str, _: &str) -> Result<(), DomainError> {
|
async fn remove_by_ap_id(&self, _: &str, _: &str) -> Result<(), DomainError> {
|
||||||
panic!("PanicRemoteWatchlistRepository called")
|
panic!("PanicRemoteWatchlistRepository called")
|
||||||
}
|
}
|
||||||
async fn get_by_actor_url(&self, _: &str) -> Result<Vec<crate::models::RemoteWatchlistEntry>, DomainError> {
|
async fn get_by_actor_url(
|
||||||
|
&self,
|
||||||
|
_: &str,
|
||||||
|
) -> Result<Vec<crate::models::RemoteWatchlistEntry>, DomainError> {
|
||||||
panic!("PanicRemoteWatchlistRepository called")
|
panic!("PanicRemoteWatchlistRepository called")
|
||||||
}
|
}
|
||||||
async fn remove_all_by_actor(&self, _: &str) -> Result<(), DomainError> {
|
async fn remove_all_by_actor(&self, _: &str) -> Result<(), DomainError> {
|
||||||
panic!("PanicRemoteWatchlistRepository called")
|
panic!("PanicRemoteWatchlistRepository called")
|
||||||
}
|
}
|
||||||
async fn get_by_derived_uuid(&self, _: uuid::Uuid) -> Result<Vec<crate::models::RemoteWatchlistEntry>, DomainError> {
|
async fn get_by_derived_uuid(
|
||||||
|
&self,
|
||||||
|
_: uuid::Uuid,
|
||||||
|
) -> Result<Vec<crate::models::RemoteWatchlistEntry>, DomainError> {
|
||||||
panic!("PanicRemoteWatchlistRepository called")
|
panic!("PanicRemoteWatchlistRepository called")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -708,3 +787,55 @@ impl UserProfileFieldsRepository for PanicProfileFieldsRepo {
|
|||||||
panic!("PanicProfileFieldsRepo called")
|
panic!("PanicProfileFieldsRepo called")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub struct PanicSocialQueryPort;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl crate::ports::SocialQueryPort for PanicSocialQueryPort {
|
||||||
|
async fn get_accepted_following_urls(&self, _: uuid::Uuid) -> Result<Vec<String>, DomainError> {
|
||||||
|
panic!("PanicSocialQueryPort called")
|
||||||
|
}
|
||||||
|
async fn list_all_followed_remote_actors(
|
||||||
|
&self,
|
||||||
|
) -> Result<Vec<crate::ports::RemoteActorInfo>, DomainError> {
|
||||||
|
panic!("PanicSocialQueryPort called")
|
||||||
|
}
|
||||||
|
async fn count_following(&self, _: uuid::Uuid) -> Result<usize, DomainError> {
|
||||||
|
panic!("PanicSocialQueryPort called")
|
||||||
|
}
|
||||||
|
async fn count_accepted_followers(&self, _: uuid::Uuid) -> Result<usize, DomainError> {
|
||||||
|
panic!("PanicSocialQueryPort called")
|
||||||
|
}
|
||||||
|
async fn get_pending_followers(
|
||||||
|
&self,
|
||||||
|
_: uuid::Uuid,
|
||||||
|
) -> Result<Vec<crate::ports::PendingFollowerInfo>, DomainError> {
|
||||||
|
panic!("PanicSocialQueryPort called")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct NoopSocialQueryPort;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl crate::ports::SocialQueryPort for NoopSocialQueryPort {
|
||||||
|
async fn get_accepted_following_urls(&self, _: uuid::Uuid) -> Result<Vec<String>, DomainError> {
|
||||||
|
Ok(vec![])
|
||||||
|
}
|
||||||
|
async fn list_all_followed_remote_actors(
|
||||||
|
&self,
|
||||||
|
) -> Result<Vec<crate::ports::RemoteActorInfo>, DomainError> {
|
||||||
|
Ok(vec![])
|
||||||
|
}
|
||||||
|
async fn count_following(&self, _: uuid::Uuid) -> Result<usize, DomainError> {
|
||||||
|
Ok(0)
|
||||||
|
}
|
||||||
|
async fn count_accepted_followers(&self, _: uuid::Uuid) -> Result<usize, DomainError> {
|
||||||
|
Ok(0)
|
||||||
|
}
|
||||||
|
async fn get_pending_followers(
|
||||||
|
&self,
|
||||||
|
_: uuid::Uuid,
|
||||||
|
) -> Result<Vec<crate::ports::PendingFollowerInfo>, DomainError> {
|
||||||
|
Ok(vec![])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,11 +3,10 @@ use std::sync::Arc;
|
|||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
|
|
||||||
use domain::ports::{
|
use domain::ports::{
|
||||||
AuthService, DiaryRepository, ImageStorage, ImportProfileRepository,
|
AuthService, DiaryRepository, ImageStorage, ImportProfileRepository, ImportSessionRepository,
|
||||||
ImportSessionRepository, LocalApContentQuery, MetadataClient, MovieProfileRepository,
|
LocalApContentQuery, MetadataClient, MovieProfileRepository, MovieRepository, PasswordHasher,
|
||||||
MovieRepository, PasswordHasher, PersonCommand, PersonQuery, PosterFetcherClient,
|
PersonCommand, PersonQuery, PosterFetcherClient, ReviewRepository, SearchCommand, SearchPort,
|
||||||
ReviewRepository, SearchCommand, SearchPort, StatsRepository, UserProfileFieldsRepository,
|
StatsRepository, UserProfileFieldsRepository, UserRepository, WatchlistRepository,
|
||||||
UserRepository, WatchlistRepository,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
pub struct DatabaseAdapters {
|
pub struct DatabaseAdapters {
|
||||||
@@ -36,10 +35,7 @@ pub enum DbPool {
|
|||||||
Postgres(sqlx::PgPool),
|
Postgres(sqlx::PgPool),
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn build_database_adapters(
|
pub async fn build_database_adapters(backend: &str, url: &str) -> anyhow::Result<DatabaseAdapters> {
|
||||||
backend: &str,
|
|
||||||
url: &str,
|
|
||||||
) -> anyhow::Result<DatabaseAdapters> {
|
|
||||||
match backend {
|
match backend {
|
||||||
#[cfg(feature = "postgres")]
|
#[cfg(feature = "postgres")]
|
||||||
"postgres" => {
|
"postgres" => {
|
||||||
@@ -118,7 +114,9 @@ pub fn build_image_storage() -> anyhow::Result<Arc<dyn ImageStorage>> {
|
|||||||
image_storage::create()
|
image_storage::create()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn build_profile_fields_repo(pool: &DbPool) -> anyhow::Result<Arc<dyn UserProfileFieldsRepository>> {
|
pub fn build_profile_fields_repo(
|
||||||
|
pool: &DbPool,
|
||||||
|
) -> anyhow::Result<Arc<dyn UserProfileFieldsRepository>> {
|
||||||
match pool {
|
match pool {
|
||||||
#[cfg(feature = "postgres")]
|
#[cfg(feature = "postgres")]
|
||||||
DbPool::Postgres(pool) => Ok(postgres::create_profile_fields_repo(pool.clone())),
|
DbPool::Postgres(pool) => Ok(postgres::create_profile_fields_repo(pool.clone())),
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ use domain::{
|
|||||||
DiaryEntry, ExportFormat, Movie, MovieSummary, PersonId, Review, collections::PageParams,
|
DiaryEntry, ExportFormat, Movie, MovieSummary, PersonId, Review, collections::PageParams,
|
||||||
},
|
},
|
||||||
services::review_history::Trend,
|
services::review_history::Trend,
|
||||||
value_objects::{MovieId, UserId},
|
value_objects::UserId,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
@@ -178,32 +178,7 @@ pub async fn sync_poster(
|
|||||||
_user: AuthenticatedUser,
|
_user: AuthenticatedUser,
|
||||||
Path(movie_id): Path<Uuid>,
|
Path(movie_id): Path<Uuid>,
|
||||||
) -> Result<impl IntoResponse, ApiError> {
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
let movie = state
|
sync_poster::execute(&state.app_ctx, SyncPosterCommand { movie_id }).await?;
|
||||||
.app_ctx
|
|
||||||
.movie_repository
|
|
||||||
.get_movie_by_id(&MovieId::from_uuid(movie_id))
|
|
||||||
.await?
|
|
||||||
.ok_or_else(|| ApiError(DomainError::NotFound(format!("Movie {movie_id}"))))?;
|
|
||||||
|
|
||||||
let external_id = movie
|
|
||||||
.external_metadata_id()
|
|
||||||
.ok_or_else(|| {
|
|
||||||
ApiError(DomainError::ValidationError(
|
|
||||||
"Movie has no external metadata ID, cannot sync poster".into(),
|
|
||||||
))
|
|
||||||
})?
|
|
||||||
.value()
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
sync_poster::execute(
|
|
||||||
&state.app_ctx,
|
|
||||||
SyncPosterCommand {
|
|
||||||
movie_id,
|
|
||||||
external_metadata_id: external_id,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -438,26 +413,26 @@ pub async fn get_profile(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let user = match state.app_ctx.user_repository.find_by_id(&user_id).await {
|
match application::use_cases::get_current_profile::execute(
|
||||||
Ok(Some(u)) => u,
|
&state.app_ctx,
|
||||||
Ok(None) => return StatusCode::NOT_FOUND.into_response(),
|
application::queries::GetCurrentProfileQuery {
|
||||||
|
user_id: user_id.value(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(profile) => Json(ProfileResponse {
|
||||||
|
username: profile.username,
|
||||||
|
bio: profile.bio,
|
||||||
|
avatar_url: profile.avatar_url,
|
||||||
|
})
|
||||||
|
.into_response(),
|
||||||
|
Err(DomainError::NotFound(_)) => StatusCode::NOT_FOUND.into_response(),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!("get_profile user lookup: {:?}", e);
|
tracing::error!("get_profile error: {:?}", e);
|
||||||
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
StatusCode::INTERNAL_SERVER_ERROR.into_response()
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
let base_url = &state.app_ctx.config.base_url;
|
|
||||||
let avatar_url = user
|
|
||||||
.avatar_path()
|
|
||||||
.map(|path| format!("{}/images/{}", base_url, path));
|
|
||||||
|
|
||||||
Json(ProfileResponse {
|
|
||||||
username: user.username().value().to_string(),
|
|
||||||
bio: user.bio().map(|s| s.to_string()),
|
|
||||||
avatar_url,
|
|
||||||
})
|
|
||||||
.into_response()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
@@ -475,6 +450,7 @@ pub async fn update_profile_handler(
|
|||||||
AuthenticatedUser(user_id): AuthenticatedUser,
|
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||||
mut multipart: Multipart,
|
mut multipart: Multipart,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
|
let mut display_name: Option<String> = None;
|
||||||
let mut bio: Option<String> = None;
|
let mut bio: Option<String> = None;
|
||||||
let mut avatar_bytes: Option<Vec<u8>> = None;
|
let mut avatar_bytes: Option<Vec<u8>> = None;
|
||||||
let mut avatar_content_type: Option<String> = None;
|
let mut avatar_content_type: Option<String> = None;
|
||||||
@@ -485,6 +461,11 @@ pub async fn update_profile_handler(
|
|||||||
while let Ok(Some(field)) = multipart.next_field().await {
|
while let Ok(Some(field)) = multipart.next_field().await {
|
||||||
let name = field.name().unwrap_or("").to_string();
|
let name = field.name().unwrap_or("").to_string();
|
||||||
match name.as_str() {
|
match name.as_str() {
|
||||||
|
"display_name" => {
|
||||||
|
if let Ok(text) = field.text().await {
|
||||||
|
display_name = Some(text).filter(|s| !s.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
"bio" => {
|
"bio" => {
|
||||||
if let Ok(text) = field.text().await {
|
if let Ok(text) = field.text().await {
|
||||||
bio = Some(text);
|
bio = Some(text);
|
||||||
@@ -497,20 +478,20 @@ pub async fn update_profile_handler(
|
|||||||
}
|
}
|
||||||
"avatar" => {
|
"avatar" => {
|
||||||
let ct = field.content_type().map(|s| s.to_string());
|
let ct = field.content_type().map(|s| s.to_string());
|
||||||
if let Ok(bytes) = field.bytes().await {
|
if let Ok(bytes) = field.bytes().await
|
||||||
if !bytes.is_empty() {
|
&& !bytes.is_empty()
|
||||||
avatar_bytes = Some(bytes.to_vec());
|
{
|
||||||
avatar_content_type = ct;
|
avatar_bytes = Some(bytes.to_vec());
|
||||||
}
|
avatar_content_type = ct;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"banner" => {
|
"banner" => {
|
||||||
let ct = field.content_type().map(|s| s.to_string());
|
let ct = field.content_type().map(|s| s.to_string());
|
||||||
if let Ok(bytes) = field.bytes().await {
|
if let Ok(bytes) = field.bytes().await
|
||||||
if !bytes.is_empty() {
|
&& !bytes.is_empty()
|
||||||
banner_bytes = Some(bytes.to_vec());
|
{
|
||||||
banner_content_type = ct;
|
banner_bytes = Some(bytes.to_vec());
|
||||||
}
|
banner_content_type = ct;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
@@ -519,6 +500,7 @@ pub async fn update_profile_handler(
|
|||||||
|
|
||||||
let cmd = application::commands::UpdateProfileCommand {
|
let cmd = application::commands::UpdateProfileCommand {
|
||||||
user_id: user_id.value(),
|
user_id: user_id.value(),
|
||||||
|
display_name,
|
||||||
bio,
|
bio,
|
||||||
avatar_bytes,
|
avatar_bytes,
|
||||||
avatar_content_type,
|
avatar_content_type,
|
||||||
@@ -1025,7 +1007,8 @@ pub async fn get_activity_feed(
|
|||||||
offset: params.offset.unwrap_or(0),
|
offset: params.offset.unwrap_or(0),
|
||||||
sort_by: domain::ports::FeedSortBy::Date,
|
sort_by: domain::ports::FeedSortBy::Date,
|
||||||
search: None,
|
search: None,
|
||||||
following: None,
|
viewer_user_id: None,
|
||||||
|
filter_following: false,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -1051,9 +1034,10 @@ pub async fn get_activity_feed(
|
|||||||
responses((status = 200, body = UsersResponse)),
|
responses((status = 200, body = UsersResponse)),
|
||||||
)]
|
)]
|
||||||
pub async fn list_users(State(state): State<AppState>) -> Result<Json<UsersResponse>, ApiError> {
|
pub async fn list_users(State(state): State<AppState>) -> Result<Json<UsersResponse>, ApiError> {
|
||||||
let users = get_users::execute(&state.app_ctx, GetUsersQuery).await?;
|
let result = get_users::execute(&state.app_ctx, GetUsersQuery).await?;
|
||||||
Ok(Json(UsersResponse {
|
Ok(Json(UsersResponse {
|
||||||
users: users
|
users: result
|
||||||
|
.users
|
||||||
.iter()
|
.iter()
|
||||||
.map(|u| UserSummaryDto {
|
.map(|u| UserSummaryDto {
|
||||||
id: u.user_id.value(),
|
id: u.user_id.value(),
|
||||||
@@ -1110,6 +1094,7 @@ pub async fn get_user_profile(
|
|||||||
offset: params.offset,
|
offset: params.offset,
|
||||||
sort_by: domain::ports::FeedSortBy::Date,
|
sort_by: domain::ports::FeedSortBy::Date,
|
||||||
search: None,
|
search: None,
|
||||||
|
is_own_profile: false,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -1121,20 +1106,6 @@ pub async fn get_user_profile(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
#[cfg(feature = "federation")]
|
|
||||||
let following_count = state.ap_service.count_following(user_id).await.unwrap_or(0);
|
|
||||||
#[cfg(not(feature = "federation"))]
|
|
||||||
let following_count = 0usize;
|
|
||||||
|
|
||||||
#[cfg(feature = "federation")]
|
|
||||||
let followers_count = state
|
|
||||||
.ap_service
|
|
||||||
.count_accepted_followers(user_id)
|
|
||||||
.await
|
|
||||||
.unwrap_or(0);
|
|
||||||
#[cfg(not(feature = "federation"))]
|
|
||||||
let followers_count = 0usize;
|
|
||||||
|
|
||||||
let entries = profile.entries.map(|p| DiaryResponse {
|
let entries = profile.entries.map(|p| DiaryResponse {
|
||||||
items: p.items.iter().map(entry_to_dto).collect(),
|
items: p.items.iter().map(entry_to_dto).collect(),
|
||||||
total_count: p.total_count,
|
total_count: p.total_count,
|
||||||
@@ -1185,8 +1156,8 @@ pub async fn get_user_profile(
|
|||||||
favorite_director: profile.stats.favorite_director,
|
favorite_director: profile.stats.favorite_director,
|
||||||
most_active_month: profile.stats.most_active_month,
|
most_active_month: profile.stats.most_active_month,
|
||||||
},
|
},
|
||||||
following_count,
|
following_count: profile.following_count,
|
||||||
followers_count,
|
followers_count: profile.followers_count,
|
||||||
entries,
|
entries,
|
||||||
history,
|
history,
|
||||||
trends,
|
trends,
|
||||||
|
|||||||
@@ -9,33 +9,26 @@ use axum::{
|
|||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[cfg(feature = "federation")]
|
||||||
|
use application::ports::{
|
||||||
|
BlockedActorEntry, BlockedActorsPageData, BlockedDomainEntry, BlockedDomainsPageData,
|
||||||
|
FollowersPageData, FollowingPageData,
|
||||||
|
};
|
||||||
use application::{
|
use application::{
|
||||||
commands::{
|
commands::{
|
||||||
AddToWatchlistCommand, DeleteReviewCommand, MovieInput, RegisterCommand,
|
AddToWatchlistCommand, DeleteReviewCommand, MovieInput, RemoveFromWatchlistCommand,
|
||||||
RemoveFromWatchlistCommand,
|
|
||||||
},
|
},
|
||||||
ports::{
|
ports::{
|
||||||
HtmlPageContext, LoginPageData, MovieDetailPageData, NewReviewPageData,
|
HtmlPageContext, LoginPageData, MovieDetailPageData, NewReviewPageData,
|
||||||
ProfileSettingsPageData, RegisterPageData, RemoteActorView, WatchlistDisplayEntry,
|
ProfileSettingsPageData, RegisterPageData, RemoteActorView, WatchlistPageData,
|
||||||
WatchlistPageData,
|
|
||||||
},
|
|
||||||
queries::{
|
|
||||||
ExportQuery, GetMovieSocialPageQuery, GetWatchlistQuery, IsOnWatchlistQuery, LoginQuery,
|
|
||||||
},
|
},
|
||||||
|
queries::{ExportQuery, GetMovieSocialPageQuery, IsOnWatchlistQuery, LoginQuery},
|
||||||
use_cases::{
|
use_cases::{
|
||||||
add_to_watchlist, delete_review, export_diary as export_diary_uc, get_movie_social_page,
|
add_to_watchlist, delete_review, export_diary as export_diary_uc, get_movie_social_page,
|
||||||
get_watchlist, is_on_watchlist, log_review, login as login_uc, register as register_uc,
|
is_on_watchlist, log_review, login as login_uc, remove_from_watchlist, update_profile,
|
||||||
remove_from_watchlist, update_profile, update_profile_fields,
|
update_profile_fields,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
#[cfg(feature = "federation")]
|
|
||||||
use application::{
|
|
||||||
ports::{
|
|
||||||
BlockedActorEntry, BlockedActorsPageData, BlockedDomainEntry, BlockedDomainsPageData,
|
|
||||||
FollowersPageData, FollowingPageData,
|
|
||||||
},
|
|
||||||
use_cases::get_remote_watchlist,
|
|
||||||
};
|
|
||||||
use domain::models::ExportFormat;
|
use domain::models::ExportFormat;
|
||||||
use domain::{errors::DomainError, value_objects::UserId};
|
use domain::{errors::DomainError, value_objects::UserId};
|
||||||
|
|
||||||
@@ -216,27 +209,21 @@ pub async fn post_register(
|
|||||||
if crate::csrf::mismatch(&csrf, &form.csrf_token) {
|
if crate::csrf::mismatch(&csrf, &form.csrf_token) {
|
||||||
return StatusCode::FORBIDDEN.into_response();
|
return StatusCode::FORBIDDEN.into_response();
|
||||||
}
|
}
|
||||||
let email = form.email.clone();
|
match application::use_cases::register_and_login::execute(
|
||||||
let password = form.password.clone();
|
|
||||||
match register_uc::execute(
|
|
||||||
&state.app_ctx,
|
&state.app_ctx,
|
||||||
RegisterCommand {
|
application::commands::RegisterAndLoginCommand {
|
||||||
email: form.email,
|
email: form.email,
|
||||||
username: form.username,
|
username: form.username,
|
||||||
password: form.password,
|
password: form.password,
|
||||||
role: domain::models::UserRole::Standard,
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(_) => match login_uc::execute(&state.app_ctx, LoginQuery { email, password }).await {
|
Ok(result) => {
|
||||||
Ok(result) => {
|
let max_age = (result.expires_at - Utc::now()).num_seconds().max(0);
|
||||||
let max_age = (result.expires_at - Utc::now()).num_seconds().max(0);
|
let cookie = set_cookie_header(&result.token, max_age);
|
||||||
let cookie = set_cookie_header(&result.token, max_age);
|
([cookie], Redirect::to("/")).into_response()
|
||||||
([cookie], Redirect::to("/")).into_response()
|
}
|
||||||
}
|
|
||||||
Err(_) => Redirect::to("/login").into_response(),
|
|
||||||
},
|
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
Redirect::to("/register?error=Registration+failed.+Please+try+again.").into_response()
|
Redirect::to("/register?error=Registration+failed.+Please+try+again.").into_response()
|
||||||
}
|
}
|
||||||
@@ -369,14 +356,9 @@ pub async fn get_activity_feed(
|
|||||||
let limit = params.limit.unwrap_or(20);
|
let limit = params.limit.unwrap_or(20);
|
||||||
let offset = params.offset.unwrap_or(0);
|
let offset = params.offset.unwrap_or(0);
|
||||||
|
|
||||||
#[cfg(feature = "federation")]
|
let filter_following =
|
||||||
let filter_str = if params.filter == "following" && user_id.is_some() {
|
cfg!(feature = "federation") && params.filter == "following" && user_id.is_some();
|
||||||
"following"
|
let filter_str = if filter_following { "following" } else { "all" };
|
||||||
} else {
|
|
||||||
"all"
|
|
||||||
};
|
|
||||||
#[cfg(not(feature = "federation"))]
|
|
||||||
let filter_str = "all";
|
|
||||||
|
|
||||||
let sort_by_str = match params.sort_by.as_str() {
|
let sort_by_str = match params.sort_by.as_str() {
|
||||||
"date_asc" => "date_asc",
|
"date_asc" => "date_asc",
|
||||||
@@ -385,52 +367,17 @@ pub async fn get_activity_feed(
|
|||||||
_ => "date",
|
_ => "date",
|
||||||
};
|
};
|
||||||
|
|
||||||
#[cfg(feature = "federation")]
|
|
||||||
let following = if filter_str == "following" {
|
|
||||||
if let Some(uid) = user_id {
|
|
||||||
let urls = state
|
|
||||||
.social_query
|
|
||||||
.get_accepted_following_urls(uid.value())
|
|
||||||
.await
|
|
||||||
.unwrap_or_default();
|
|
||||||
let base_url = &state.app_ctx.config.base_url;
|
|
||||||
let mut local_ids = vec![uid.value()];
|
|
||||||
let mut remote_urls = Vec::new();
|
|
||||||
for url in urls {
|
|
||||||
if let Some(suffix) = url.strip_prefix(&format!("{}/users/", base_url))
|
|
||||||
&& let Ok(parsed_id) = uuid::Uuid::parse_str(suffix)
|
|
||||||
{
|
|
||||||
local_ids.push(parsed_id);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
remote_urls.push(url);
|
|
||||||
}
|
|
||||||
Some(domain::ports::FollowingFilter {
|
|
||||||
local_user_ids: local_ids,
|
|
||||||
remote_actor_urls: remote_urls,
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
#[cfg(not(feature = "federation"))]
|
|
||||||
let following: Option<domain::ports::FollowingFilter> = None;
|
|
||||||
|
|
||||||
let search_opt = if params.search.is_empty() {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(params.search.clone())
|
|
||||||
};
|
|
||||||
|
|
||||||
let query = application::queries::GetActivityFeedQuery {
|
let query = application::queries::GetActivityFeedQuery {
|
||||||
limit,
|
limit,
|
||||||
offset,
|
offset,
|
||||||
sort_by: domain::ports::FeedSortBy::from_str(sort_by_str),
|
sort_by: sort_by_str.parse().unwrap_or_default(),
|
||||||
search: search_opt,
|
search: if params.search.is_empty() {
|
||||||
following,
|
None
|
||||||
|
} else {
|
||||||
|
Some(params.search.clone())
|
||||||
|
},
|
||||||
|
viewer_user_id: user_id.map(|u| u.value()),
|
||||||
|
filter_following,
|
||||||
};
|
};
|
||||||
|
|
||||||
match application::use_cases::get_activity_feed::execute(&state.app_ctx, query).await {
|
match application::use_cases::get_activity_feed::execute(&state.app_ctx, query).await {
|
||||||
@@ -467,27 +414,15 @@ pub async fn get_users_list(
|
|||||||
ctx.page_title = "Members — Movies Diary".to_string();
|
ctx.page_title = "Members — Movies Diary".to_string();
|
||||||
ctx.canonical_url = format!("{}/users", state.app_ctx.config.base_url);
|
ctx.canonical_url = format!("{}/users", state.app_ctx.config.base_url);
|
||||||
|
|
||||||
#[cfg(feature = "federation")]
|
match application::use_cases::get_users::execute(
|
||||||
let (users_result, actors_result) = tokio::join!(
|
&state.app_ctx,
|
||||||
application::use_cases::get_users::execute(
|
application::queries::GetUsersQuery,
|
||||||
&state.app_ctx,
|
)
|
||||||
application::queries::GetUsersQuery,
|
.await
|
||||||
),
|
{
|
||||||
state.social_query.list_all_followed_remote_actors()
|
Ok(result) => {
|
||||||
);
|
let actor_views = result
|
||||||
#[cfg(not(feature = "federation"))]
|
.remote_actors
|
||||||
let (users_result, actors_result) = (
|
|
||||||
application::use_cases::get_users::execute(
|
|
||||||
&state.app_ctx,
|
|
||||||
application::queries::GetUsersQuery,
|
|
||||||
)
|
|
||||||
.await,
|
|
||||||
Ok::<Vec<domain::ports::RemoteActorInfo>, domain::errors::DomainError>(vec![]),
|
|
||||||
);
|
|
||||||
|
|
||||||
match (users_result, actors_result) {
|
|
||||||
(Ok(users), Ok(remote_actors)) => {
|
|
||||||
let actor_views = remote_actors
|
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|a| application::ports::RemoteActorView {
|
.map(|a| application::ports::RemoteActorView {
|
||||||
handle: a.handle,
|
handle: a.handle,
|
||||||
@@ -498,7 +433,7 @@ pub async fn get_users_list(
|
|||||||
.collect();
|
.collect();
|
||||||
let data = application::ports::UsersPageData {
|
let data = application::ports::UsersPageData {
|
||||||
ctx,
|
ctx,
|
||||||
users,
|
users: result.users,
|
||||||
remote_actors: actor_views,
|
remote_actors: actor_views,
|
||||||
};
|
};
|
||||||
match state.html_renderer.render_users_page(data) {
|
match state.html_renderer.render_users_page(data) {
|
||||||
@@ -506,8 +441,7 @@ pub async fn get_users_list(
|
|||||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
|
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
(Err(e), _) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||||
(_, Err(e)) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -605,68 +539,18 @@ pub async fn get_user_profile(
|
|||||||
.map(|u| u.value() == profile_user_uuid)
|
.map(|u| u.value() == profile_user_uuid)
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
|
|
||||||
#[cfg(feature = "federation")]
|
|
||||||
let following_count = if is_own_profile {
|
|
||||||
if let Some(ref uid) = user_id {
|
|
||||||
state
|
|
||||||
.ap_service
|
|
||||||
.count_following(uid.value())
|
|
||||||
.await
|
|
||||||
.unwrap_or(0)
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
};
|
|
||||||
#[cfg(not(feature = "federation"))]
|
|
||||||
let following_count = 0usize;
|
|
||||||
|
|
||||||
#[cfg(feature = "federation")]
|
|
||||||
let followers_count = if is_own_profile {
|
|
||||||
state
|
|
||||||
.ap_service
|
|
||||||
.count_accepted_followers(profile_user_uuid)
|
|
||||||
.await
|
|
||||||
.unwrap_or(0)
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
};
|
|
||||||
#[cfg(not(feature = "federation"))]
|
|
||||||
let followers_count = 0usize;
|
|
||||||
|
|
||||||
#[cfg(feature = "federation")]
|
|
||||||
let pending_followers: Vec<application::ports::RemoteActorView> = if is_own_profile {
|
|
||||||
state
|
|
||||||
.ap_service
|
|
||||||
.get_pending_followers(profile_user_uuid)
|
|
||||||
.await
|
|
||||||
.unwrap_or_default()
|
|
||||||
.into_iter()
|
|
||||||
.map(|a| application::ports::RemoteActorView {
|
|
||||||
handle: a.handle,
|
|
||||||
url: a.url,
|
|
||||||
display_name: a.display_name,
|
|
||||||
avatar_url: a.avatar_url.clone(),
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
} else {
|
|
||||||
vec![]
|
|
||||||
};
|
|
||||||
#[cfg(not(feature = "federation"))]
|
|
||||||
let pending_followers: Vec<application::ports::RemoteActorView> = vec![];
|
|
||||||
|
|
||||||
let query = application::queries::GetUserProfileQuery {
|
let query = application::queries::GetUserProfileQuery {
|
||||||
user_id: profile_user_uuid,
|
user_id: profile_user_uuid,
|
||||||
view: profile_view,
|
view: profile_view,
|
||||||
limit: params.limit,
|
limit: params.limit,
|
||||||
offset: params.offset,
|
offset: params.offset,
|
||||||
sort_by: domain::ports::FeedSortBy::from_str(sort_by_str),
|
sort_by: sort_by_str.parse().unwrap_or_default(),
|
||||||
search: if params.search.is_empty() {
|
search: if params.search.is_empty() {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
Some(params.search.clone())
|
Some(params.search.clone())
|
||||||
},
|
},
|
||||||
|
is_own_profile,
|
||||||
};
|
};
|
||||||
|
|
||||||
match application::use_cases::get_user_profile::execute(&state.app_ctx, query).await {
|
match application::use_cases::get_user_profile::execute(&state.app_ctx, query).await {
|
||||||
@@ -682,6 +566,16 @@ pub async fn get_user_profile(
|
|||||||
if !is_own_profile {
|
if !is_own_profile {
|
||||||
ctx.page_rss_url = Some(format!("/users/{}/feed.rss", profile_user_uuid));
|
ctx.page_rss_url = Some(format!("/users/{}/feed.rss", profile_user_uuid));
|
||||||
}
|
}
|
||||||
|
let pending_followers: Vec<application::ports::RemoteActorView> = profile
|
||||||
|
.pending_followers
|
||||||
|
.into_iter()
|
||||||
|
.map(|p| application::ports::RemoteActorView {
|
||||||
|
handle: p.handle,
|
||||||
|
url: p.url,
|
||||||
|
display_name: p.display_name,
|
||||||
|
avatar_url: p.avatar_url,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
let data = application::ports::ProfilePageData {
|
let data = application::ports::ProfilePageData {
|
||||||
ctx,
|
ctx,
|
||||||
profile_user_id: profile_user_uuid,
|
profile_user_id: profile_user_uuid,
|
||||||
@@ -696,8 +590,8 @@ pub async fn get_user_profile(
|
|||||||
trends: profile.trends,
|
trends: profile.trends,
|
||||||
is_own_profile,
|
is_own_profile,
|
||||||
error: params.error,
|
error: params.error,
|
||||||
following_count,
|
following_count: profile.following_count,
|
||||||
followers_count,
|
followers_count: profile.followers_count,
|
||||||
pending_followers,
|
pending_followers,
|
||||||
sort_by: sort_by_str.to_string(),
|
sort_by: sort_by_str.to_string(),
|
||||||
search: params.search.clone(),
|
search: params.search.clone(),
|
||||||
@@ -834,6 +728,70 @@ pub async fn reject_follower(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "federation")]
|
||||||
|
pub async fn get_followers_collection(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(user_id): Path<Uuid>,
|
||||||
|
headers: axum::http::HeaderMap,
|
||||||
|
Query(params): Query<std::collections::HashMap<String, String>>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let accept = headers
|
||||||
|
.get(axum::http::header::ACCEPT)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.unwrap_or("");
|
||||||
|
if accept.contains("application/activity+json") || accept.contains("application/ld+json") {
|
||||||
|
let page = params.get("page").and_then(|p| p.parse::<u32>().ok());
|
||||||
|
return match state
|
||||||
|
.ap_service
|
||||||
|
.followers_collection_json(user_id, page)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(json) => (
|
||||||
|
[(
|
||||||
|
axum::http::header::CONTENT_TYPE,
|
||||||
|
"application/activity+json",
|
||||||
|
)],
|
||||||
|
json,
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
Err(_) => StatusCode::NOT_FOUND.into_response(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
axum::response::Redirect::to(&format!("/users/{}/followers-list", user_id)).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "federation")]
|
||||||
|
pub async fn get_following_collection(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(user_id): Path<Uuid>,
|
||||||
|
headers: axum::http::HeaderMap,
|
||||||
|
Query(params): Query<std::collections::HashMap<String, String>>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let accept = headers
|
||||||
|
.get(axum::http::header::ACCEPT)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.unwrap_or("");
|
||||||
|
if accept.contains("application/activity+json") || accept.contains("application/ld+json") {
|
||||||
|
let page = params.get("page").and_then(|p| p.parse::<u32>().ok());
|
||||||
|
return match state
|
||||||
|
.ap_service
|
||||||
|
.following_collection_json(user_id, page)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(json) => (
|
||||||
|
[(
|
||||||
|
axum::http::header::CONTENT_TYPE,
|
||||||
|
"application/activity+json",
|
||||||
|
)],
|
||||||
|
json,
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
Err(_) => StatusCode::NOT_FOUND.into_response(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
axum::response::Redirect::to(&format!("/users/{}/following-list", user_id)).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(feature = "federation")]
|
#[cfg(feature = "federation")]
|
||||||
pub async fn get_following_page(
|
pub async fn get_following_page(
|
||||||
RequiredCookieUser(user_id): RequiredCookieUser,
|
RequiredCookieUser(user_id): RequiredCookieUser,
|
||||||
@@ -1051,94 +1009,33 @@ pub async fn get_watchlist_page(
|
|||||||
Extension(csrf): Extension<CsrfToken>,
|
Extension(csrf): Extension<CsrfToken>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let ctx = build_page_context(&state, viewer_id.clone(), csrf.0).await;
|
let ctx = build_page_context(&state, viewer_id.clone(), csrf.0).await;
|
||||||
let limit = params.limit.unwrap_or(20);
|
|
||||||
let offset = params.offset.unwrap_or(0);
|
|
||||||
let is_owner = viewer_id.map(|u| u.value() == owner_id).unwrap_or(false);
|
let is_owner = viewer_id.map(|u| u.value() == owner_id).unwrap_or(false);
|
||||||
|
|
||||||
// Try local user first
|
let result = match application::use_cases::get_watchlist_page::execute(
|
||||||
let local_user = state
|
&state.app_ctx,
|
||||||
.app_ctx
|
application::queries::GetWatchlistQuery {
|
||||||
.user_repository
|
user_id: owner_id,
|
||||||
.find_by_id(&domain::value_objects::UserId::from_uuid(owner_id))
|
limit: params.limit.or(Some(20)),
|
||||||
.await
|
offset: params.offset.or(Some(0)),
|
||||||
.ok()
|
},
|
||||||
.flatten();
|
is_owner,
|
||||||
|
)
|
||||||
let (display_entries, has_more, current_offset, page_limit) = if local_user.is_some() {
|
.await
|
||||||
match get_watchlist::execute(
|
{
|
||||||
&state.app_ctx,
|
Ok(r) => r,
|
||||||
GetWatchlistQuery {
|
Err(e) => {
|
||||||
user_id: owner_id,
|
tracing::error!("watchlist error: {:?}", e);
|
||||||
limit: Some(limit),
|
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
||||||
offset: Some(offset),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Err(e) => {
|
|
||||||
tracing::error!("watchlist error: {:?}", e);
|
|
||||||
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
|
||||||
}
|
|
||||||
Ok(entries) => {
|
|
||||||
let has_more = entries.offset + entries.limit < entries.total_count as u32;
|
|
||||||
let display: Vec<WatchlistDisplayEntry> = entries
|
|
||||||
.items
|
|
||||||
.iter()
|
|
||||||
.map(|w| {
|
|
||||||
let remove_url = if is_owner {
|
|
||||||
Some(format!("/watchlist/{}/remove", w.movie.id().value()))
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
WatchlistDisplayEntry {
|
|
||||||
poster_url: w
|
|
||||||
.movie
|
|
||||||
.poster_path()
|
|
||||||
.map(|p| format!("/images/{}", p.value())),
|
|
||||||
movie_title: w.movie.title().value().to_string(),
|
|
||||||
release_year: w.movie.release_year().value(),
|
|
||||||
movie_url: Some(format!("/movies/{}", w.movie.id().value())),
|
|
||||||
added_at: w.entry.added_at.format("%b %-d, %Y").to_string(),
|
|
||||||
remove_url,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
(display, has_more, entries.offset, entries.limit)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
#[cfg(feature = "federation")]
|
|
||||||
{
|
|
||||||
let remote_entries = get_remote_watchlist::execute(&state.app_ctx, owner_id)
|
|
||||||
.await
|
|
||||||
.unwrap_or_default();
|
|
||||||
let display: Vec<WatchlistDisplayEntry> = remote_entries
|
|
||||||
.into_iter()
|
|
||||||
.map(|e| WatchlistDisplayEntry {
|
|
||||||
poster_url: e.poster_url,
|
|
||||||
movie_title: e.movie_title,
|
|
||||||
release_year: e.release_year,
|
|
||||||
movie_url: None,
|
|
||||||
added_at: e.added_at.format("%b %-d, %Y").to_string(),
|
|
||||||
remove_url: None,
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
let len = display.len() as u32;
|
|
||||||
(display, false, 0u32, len)
|
|
||||||
}
|
|
||||||
#[cfg(not(feature = "federation"))]
|
|
||||||
{
|
|
||||||
(vec![], false, 0u32, 0u32)
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let data = WatchlistPageData {
|
let data = WatchlistPageData {
|
||||||
ctx,
|
ctx,
|
||||||
owner_id,
|
owner_id,
|
||||||
display_entries,
|
display_entries: result.display_entries,
|
||||||
current_offset,
|
current_offset: result.current_offset,
|
||||||
has_more,
|
has_more: result.has_more,
|
||||||
limit: page_limit,
|
limit: result.limit,
|
||||||
is_owner,
|
is_owner,
|
||||||
error: params.error,
|
error: params.error,
|
||||||
};
|
};
|
||||||
@@ -1503,6 +1400,7 @@ pub async fn post_profile_settings(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
mut multipart: Multipart,
|
mut multipart: Multipart,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
|
let mut display_name: Option<String> = None;
|
||||||
let mut bio: Option<String> = None;
|
let mut bio: Option<String> = None;
|
||||||
let mut avatar_bytes: Option<Vec<u8>> = None;
|
let mut avatar_bytes: Option<Vec<u8>> = None;
|
||||||
let mut avatar_content_type: Option<String> = None;
|
let mut avatar_content_type: Option<String> = None;
|
||||||
@@ -1517,6 +1415,11 @@ pub async fn post_profile_settings(
|
|||||||
while let Ok(Some(field)) = multipart.next_field().await {
|
while let Ok(Some(field)) = multipart.next_field().await {
|
||||||
let name = field.name().unwrap_or("").to_string();
|
let name = field.name().unwrap_or("").to_string();
|
||||||
match name.as_str() {
|
match name.as_str() {
|
||||||
|
"display_name" => {
|
||||||
|
if let Ok(text) = field.text().await {
|
||||||
|
display_name = Some(text).filter(|s| !s.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
"bio" => {
|
"bio" => {
|
||||||
if let Ok(text) = field.text().await {
|
if let Ok(text) = field.text().await {
|
||||||
bio = Some(text);
|
bio = Some(text);
|
||||||
@@ -1529,38 +1432,36 @@ pub async fn post_profile_settings(
|
|||||||
}
|
}
|
||||||
"avatar" => {
|
"avatar" => {
|
||||||
let ct = field.content_type().map(|s| s.to_string());
|
let ct = field.content_type().map(|s| s.to_string());
|
||||||
if let Ok(bytes) = field.bytes().await {
|
if let Ok(bytes) = field.bytes().await
|
||||||
if !bytes.is_empty() {
|
&& !bytes.is_empty()
|
||||||
avatar_bytes = Some(bytes.to_vec());
|
{
|
||||||
avatar_content_type = ct;
|
avatar_bytes = Some(bytes.to_vec());
|
||||||
}
|
avatar_content_type = ct;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"banner" => {
|
"banner" => {
|
||||||
let ct = field.content_type().map(|s| s.to_string());
|
let ct = field.content_type().map(|s| s.to_string());
|
||||||
if let Ok(bytes) = field.bytes().await {
|
if let Ok(bytes) = field.bytes().await
|
||||||
if !bytes.is_empty() {
|
&& !bytes.is_empty()
|
||||||
banner_bytes = Some(bytes.to_vec());
|
{
|
||||||
banner_content_type = ct;
|
banner_bytes = Some(bytes.to_vec());
|
||||||
}
|
banner_content_type = ct;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
n if n.starts_with("field_name_") => {
|
n if n.starts_with("field_name_") => {
|
||||||
if let Ok(idx) = n["field_name_".len()..].parse::<usize>() {
|
if let Ok(idx) = n["field_name_".len()..].parse::<usize>()
|
||||||
if let Ok(text) = field.text().await {
|
&& let Ok(text) = field.text().await
|
||||||
if !text.is_empty() {
|
&& !text.is_empty()
|
||||||
field_names.insert(idx, text);
|
{
|
||||||
}
|
field_names.insert(idx, text);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
n if n.starts_with("field_value_") => {
|
n if n.starts_with("field_value_") => {
|
||||||
if let Ok(idx) = n["field_value_".len()..].parse::<usize>() {
|
if let Ok(idx) = n["field_value_".len()..].parse::<usize>()
|
||||||
if let Ok(text) = field.text().await {
|
&& let Ok(text) = field.text().await
|
||||||
if !text.is_empty() {
|
&& !text.is_empty()
|
||||||
field_values.insert(idx, text);
|
{
|
||||||
}
|
field_values.insert(idx, text);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
@@ -1569,6 +1470,7 @@ pub async fn post_profile_settings(
|
|||||||
|
|
||||||
let cmd = application::commands::UpdateProfileCommand {
|
let cmd = application::commands::UpdateProfileCommand {
|
||||||
user_id: user_id.value(),
|
user_id: user_id.value(),
|
||||||
|
display_name,
|
||||||
bio,
|
bio,
|
||||||
avatar_bytes,
|
avatar_bytes,
|
||||||
avatar_content_type,
|
avatar_content_type,
|
||||||
|
|||||||
@@ -82,17 +82,24 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
|
|||||||
|
|
||||||
#[cfg(feature = "federation")]
|
#[cfg(feature = "federation")]
|
||||||
let (event_publisher_arc, ap_router, ap_service, social_query, remote_watchlist_repo) = {
|
let (event_publisher_arc, ap_router, ap_service, social_query, remote_watchlist_repo) = {
|
||||||
let (federation_repo, social_query_arc, review_store, remote_watchlist_repo) =
|
let (
|
||||||
match &db_pool {
|
activity_repo,
|
||||||
#[cfg(feature = "postgres-federation")]
|
follow_repo,
|
||||||
factory::DbPool::Postgres(pool) => postgres_federation::wire(pool.clone()),
|
actor_repo,
|
||||||
#[cfg(feature = "sqlite-federation")]
|
blocklist_repo,
|
||||||
factory::DbPool::Sqlite(pool) => sqlite_federation::wire(pool.clone()),
|
social_query_arc,
|
||||||
#[cfg(not(feature = "sqlite-federation"))]
|
review_store,
|
||||||
_ => anyhow::bail!(
|
remote_watchlist_repo,
|
||||||
"DATABASE_BACKEND={backend} federation is not supported by this build"
|
) = match &db_pool {
|
||||||
),
|
#[cfg(feature = "postgres-federation")]
|
||||||
};
|
factory::DbPool::Postgres(pool) => postgres_federation::wire(pool.clone()),
|
||||||
|
#[cfg(feature = "sqlite-federation")]
|
||||||
|
factory::DbPool::Sqlite(pool) => sqlite_federation::wire(pool.clone()),
|
||||||
|
#[cfg(not(feature = "sqlite-federation"))]
|
||||||
|
_ => anyhow::bail!(
|
||||||
|
"DATABASE_BACKEND={backend} federation is not supported by this build"
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
let ep: Arc<dyn EventPublisher> = match event_bus {
|
let ep: Arc<dyn EventPublisher> = match event_bus {
|
||||||
EventBusBackend::Db => {
|
EventBusBackend::Db => {
|
||||||
@@ -118,16 +125,19 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let ap = activitypub::wire(
|
let ap = activitypub::wire(activitypub::ActivityPubDeps {
|
||||||
federation_repo,
|
activity_repo,
|
||||||
|
follow_repo,
|
||||||
|
actor_repo,
|
||||||
|
blocklist_repo,
|
||||||
review_store,
|
review_store,
|
||||||
remote_watchlist_repo.clone(),
|
remote_watchlist_repo: remote_watchlist_repo.clone(),
|
||||||
Arc::clone(&ap_content_repo),
|
local_ap_content: Arc::clone(&ap_content_repo),
|
||||||
Arc::clone(&user_repository),
|
user_repo: Arc::clone(&user_repository),
|
||||||
app_config.base_url.clone(),
|
base_url: app_config.base_url.clone(),
|
||||||
app_config.allow_registration,
|
allow_registration: app_config.allow_registration,
|
||||||
Arc::clone(&ep),
|
event_publisher: Arc::clone(&ep),
|
||||||
)
|
})
|
||||||
.await?;
|
.await?;
|
||||||
let ap_router = ap.router;
|
let ap_router = ap.router;
|
||||||
let ap_service_arc = ap.service;
|
let ap_service_arc = ap.service;
|
||||||
@@ -192,6 +202,8 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
|
|||||||
profile_fields_repository: profile_fields_repo,
|
profile_fields_repository: profile_fields_repo,
|
||||||
#[cfg(feature = "federation")]
|
#[cfg(feature = "federation")]
|
||||||
remote_watchlist_repository: remote_watchlist_repo,
|
remote_watchlist_repository: remote_watchlist_repo,
|
||||||
|
#[cfg(feature = "federation")]
|
||||||
|
social_query: social_query.clone(),
|
||||||
person_command,
|
person_command,
|
||||||
person_query,
|
person_query,
|
||||||
search_port,
|
search_port,
|
||||||
|
|||||||
@@ -166,6 +166,14 @@ fn federation_html_routes() -> Router<AppState> {
|
|||||||
"/users/{id}/followers/reject",
|
"/users/{id}/followers/reject",
|
||||||
routing::post(handlers::html::reject_follower),
|
routing::post(handlers::html::reject_follower),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/users/{id}/followers",
|
||||||
|
routing::get(handlers::html::get_followers_collection),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/users/{id}/following",
|
||||||
|
routing::get(handlers::html::get_following_collection),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/users/{id}/following-list",
|
"/users/{id}/following-list",
|
||||||
routing::get(handlers::html::get_following_page),
|
routing::get(handlers::html::get_following_page),
|
||||||
|
|||||||
@@ -135,6 +135,18 @@ impl domain::ports::SocialQueryPort for Panic {
|
|||||||
) -> Result<Vec<domain::ports::RemoteActorInfo>, DomainError> {
|
) -> Result<Vec<domain::ports::RemoteActorInfo>, DomainError> {
|
||||||
panic!()
|
panic!()
|
||||||
}
|
}
|
||||||
|
async fn count_following(&self, _: uuid::Uuid) -> Result<usize, DomainError> {
|
||||||
|
panic!()
|
||||||
|
}
|
||||||
|
async fn count_accepted_followers(&self, _: uuid::Uuid) -> Result<usize, DomainError> {
|
||||||
|
panic!()
|
||||||
|
}
|
||||||
|
async fn get_pending_followers(
|
||||||
|
&self,
|
||||||
|
_: uuid::Uuid,
|
||||||
|
) -> Result<Vec<domain::ports::PendingFollowerInfo>, DomainError> {
|
||||||
|
panic!()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
impl StatsRepository for Panic {
|
impl StatsRepository for Panic {
|
||||||
@@ -219,10 +231,7 @@ impl UserRepository for Panic {
|
|||||||
async fn update_profile(
|
async fn update_profile(
|
||||||
&self,
|
&self,
|
||||||
_: &UserId,
|
_: &UserId,
|
||||||
_: Option<String>,
|
_: &domain::models::UserProfile,
|
||||||
_: Option<String>,
|
|
||||||
_: Option<String>,
|
|
||||||
_: Option<String>,
|
|
||||||
) -> Result<(), DomainError> {
|
) -> Result<(), DomainError> {
|
||||||
panic!()
|
panic!()
|
||||||
}
|
}
|
||||||
@@ -587,6 +596,8 @@ pub fn make_test_state(auth_service: Arc<dyn AuthService>) -> crate::state::AppS
|
|||||||
profile_fields_repository: Arc::clone(&repo) as _,
|
profile_fields_repository: Arc::clone(&repo) as _,
|
||||||
#[cfg(feature = "federation")]
|
#[cfg(feature = "federation")]
|
||||||
remote_watchlist_repository: Arc::clone(&repo) as _,
|
remote_watchlist_repository: Arc::clone(&repo) as _,
|
||||||
|
#[cfg(feature = "federation")]
|
||||||
|
social_query: Arc::clone(&repo) as _,
|
||||||
person_command: Arc::clone(&repo) as _,
|
person_command: Arc::clone(&repo) as _,
|
||||||
person_query: Arc::clone(&repo) as _,
|
person_query: Arc::clone(&repo) as _,
|
||||||
search_port: Arc::clone(&repo) as _,
|
search_port: Arc::clone(&repo) as _,
|
||||||
|
|||||||
@@ -119,10 +119,7 @@ impl UserRepository for NobodyUserRepo {
|
|||||||
async fn update_profile(
|
async fn update_profile(
|
||||||
&self,
|
&self,
|
||||||
_: &UserId,
|
_: &UserId,
|
||||||
_: Option<String>,
|
_: &domain::models::UserProfile,
|
||||||
_: Option<String>,
|
|
||||||
_: Option<String>,
|
|
||||||
_: Option<String>,
|
|
||||||
) -> Result<(), DomainError> {
|
) -> Result<(), DomainError> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -373,6 +370,18 @@ impl domain::ports::SocialQueryPort for PanicSocialQuery {
|
|||||||
) -> Result<Vec<domain::ports::RemoteActorInfo>, DomainError> {
|
) -> Result<Vec<domain::ports::RemoteActorInfo>, DomainError> {
|
||||||
panic!()
|
panic!()
|
||||||
}
|
}
|
||||||
|
async fn count_following(&self, _: uuid::Uuid) -> Result<usize, DomainError> {
|
||||||
|
panic!()
|
||||||
|
}
|
||||||
|
async fn count_accepted_followers(&self, _: uuid::Uuid) -> Result<usize, DomainError> {
|
||||||
|
panic!()
|
||||||
|
}
|
||||||
|
async fn get_pending_followers(
|
||||||
|
&self,
|
||||||
|
_: uuid::Uuid,
|
||||||
|
) -> Result<Vec<domain::ports::PendingFollowerInfo>, DomainError> {
|
||||||
|
panic!()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn test_app() -> Router {
|
async fn test_app() -> Router {
|
||||||
@@ -405,6 +414,8 @@ async fn test_app() -> Router {
|
|||||||
profile_fields_repository: Arc::new(PanicProfileFields),
|
profile_fields_repository: Arc::new(PanicProfileFields),
|
||||||
#[cfg(feature = "federation")]
|
#[cfg(feature = "federation")]
|
||||||
remote_watchlist_repository: Arc::new(PanicRemoteWatchlist),
|
remote_watchlist_repository: Arc::new(PanicRemoteWatchlist),
|
||||||
|
#[cfg(feature = "federation")]
|
||||||
|
social_query: Arc::new(PanicSocialQuery),
|
||||||
person_command: Arc::new(PanicPersonCommand),
|
person_command: Arc::new(PanicPersonCommand),
|
||||||
person_query: Arc::new(PanicPersonQuery),
|
person_query: Arc::new(PanicPersonQuery),
|
||||||
search_port: Arc::new(PanicSearchPort),
|
search_port: Arc::new(PanicSearchPort),
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
#![allow(clippy::large_enum_variant)]
|
||||||
pub mod app;
|
pub mod app;
|
||||||
pub mod client;
|
pub mod client;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
|
|||||||
@@ -10,18 +10,29 @@ pub struct FollowBackfillHandler {
|
|||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl EventHandler for FollowBackfillHandler {
|
impl EventHandler for FollowBackfillHandler {
|
||||||
async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError> {
|
async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError> {
|
||||||
let DomainEvent::FollowAccepted {
|
match event {
|
||||||
remote_actor_url,
|
DomainEvent::FollowAccepted {
|
||||||
outbox_url,
|
remote_actor_url,
|
||||||
..
|
outbox_url,
|
||||||
} = event
|
..
|
||||||
else {
|
} => {
|
||||||
return Ok(());
|
tracing::info!(actor = %remote_actor_url, outbox = %outbox_url, "importing remote outbox");
|
||||||
};
|
self.ap_service
|
||||||
tracing::info!(actor = %remote_actor_url, outbox = %outbox_url, "starting outbox backfill");
|
.import_remote_outbox(outbox_url, remote_actor_url)
|
||||||
self.ap_service
|
.await
|
||||||
.backfill_outbox(outbox_url, remote_actor_url)
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))
|
||||||
.await
|
}
|
||||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))
|
DomainEvent::BackfillFollower {
|
||||||
|
owner_user_id,
|
||||||
|
follower_inbox_url,
|
||||||
|
} => {
|
||||||
|
tracing::info!(owner = %owner_user_id.value(), inbox = %follower_inbox_url, "backfilling local content to new follower");
|
||||||
|
self.ap_service
|
||||||
|
.run_backfill_for_follower(owner_user_id.value(), follower_inbox_url.clone())
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))
|
||||||
|
}
|
||||||
|
_ => Ok(()),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,12 +47,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
|
|
||||||
// Clone refs federation handler needs before ctx consumes them.
|
// Clone refs federation handler needs before ctx consumes them.
|
||||||
#[cfg(feature = "federation")]
|
#[cfg(feature = "federation")]
|
||||||
let (
|
let (fed_ap_content, fed_user_repo, base_url, allow_registration) = (
|
||||||
fed_ap_content,
|
|
||||||
fed_user_repo,
|
|
||||||
base_url,
|
|
||||||
allow_registration,
|
|
||||||
) = (
|
|
||||||
Arc::clone(&repos.ap_content),
|
Arc::clone(&repos.ap_content),
|
||||||
Arc::clone(&repos.user),
|
Arc::clone(&repos.user),
|
||||||
app_config.base_url.clone(),
|
app_config.base_url.clone(),
|
||||||
@@ -60,13 +55,20 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
);
|
);
|
||||||
// Wire federation repos early to get remote_watchlist_repo for AppContext.
|
// Wire federation repos early to get remote_watchlist_repo for AppContext.
|
||||||
#[cfg(feature = "federation")]
|
#[cfg(feature = "federation")]
|
||||||
let (fed_federation_repo, _fed_social_query, fed_review_store, fed_remote_watchlist_repo) =
|
let (
|
||||||
match &db_pool {
|
fed_activity_repo,
|
||||||
#[cfg(feature = "sqlite-federation")]
|
fed_follow_repo,
|
||||||
db::DbPool::Sqlite(pool) => sqlite_federation::wire(pool.clone()),
|
fed_actor_repo,
|
||||||
#[cfg(feature = "postgres-federation")]
|
fed_blocklist_repo,
|
||||||
db::DbPool::Postgres(pool) => postgres_federation::wire(pool.clone()),
|
fed_social_query,
|
||||||
};
|
fed_review_store,
|
||||||
|
fed_remote_watchlist_repo,
|
||||||
|
) = match &db_pool {
|
||||||
|
#[cfg(feature = "sqlite-federation")]
|
||||||
|
db::DbPool::Sqlite(pool) => sqlite_federation::wire(pool.clone()),
|
||||||
|
#[cfg(feature = "postgres-federation")]
|
||||||
|
db::DbPool::Postgres(pool) => postgres_federation::wire(pool.clone()),
|
||||||
|
};
|
||||||
|
|
||||||
let ctx = AppContext {
|
let ctx = AppContext {
|
||||||
movie_repository: repos.movie,
|
movie_repository: repos.movie,
|
||||||
@@ -89,6 +91,8 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
profile_fields_repository: Arc::clone(&profile_fields_repo),
|
profile_fields_repository: Arc::clone(&profile_fields_repo),
|
||||||
#[cfg(feature = "federation")]
|
#[cfg(feature = "federation")]
|
||||||
remote_watchlist_repository: fed_remote_watchlist_repo.clone(),
|
remote_watchlist_repository: fed_remote_watchlist_repo.clone(),
|
||||||
|
#[cfg(feature = "federation")]
|
||||||
|
social_query: fed_social_query,
|
||||||
person_command: Arc::clone(&person_command),
|
person_command: Arc::clone(&person_command),
|
||||||
person_query: Arc::clone(&person_query),
|
person_query: Arc::clone(&person_query),
|
||||||
search_port: Arc::clone(&search_port),
|
search_port: Arc::clone(&search_port),
|
||||||
@@ -100,28 +104,27 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
// Both the event handler and the staleness job are gated on TMDB_API_KEY.
|
// Both the event handler and the staleness job are gated on TMDB_API_KEY.
|
||||||
// Without a key, no MovieEnrichmentRequested events are produced or handled.
|
// Without a key, no MovieEnrichmentRequested events are produced or handled.
|
||||||
|
|
||||||
let (enrichment_handler, enrichment_job): (
|
type OptionalPair = (Option<Arc<dyn EventHandler>>, Option<Arc<dyn PeriodicJob>>);
|
||||||
Option<Arc<dyn EventHandler>>,
|
let (enrichment_handler, enrichment_job): OptionalPair =
|
||||||
Option<Arc<dyn PeriodicJob>>,
|
match tmdb_enrichment::TmdbEnrichmentClient::from_env() {
|
||||||
) = match tmdb_enrichment::TmdbEnrichmentClient::from_env() {
|
Ok(client) => {
|
||||||
Ok(client) => {
|
tracing::info!("TMDb enrichment enabled");
|
||||||
tracing::info!("TMDb enrichment enabled");
|
let handler = Arc::new(tmdb_enrichment::EnrichmentHandler {
|
||||||
let handler = Arc::new(tmdb_enrichment::EnrichmentHandler {
|
enrichment_client: Arc::new(client),
|
||||||
enrichment_client: Arc::new(client),
|
movie_repository: Arc::clone(&ctx.movie_repository),
|
||||||
movie_repository: Arc::clone(&ctx.movie_repository),
|
profile_repo: Arc::clone(&ctx.movie_profile_repository),
|
||||||
profile_repo: Arc::clone(&ctx.movie_profile_repository),
|
person_command: Arc::clone(&ctx.person_command),
|
||||||
person_command: Arc::clone(&ctx.person_command),
|
search_command: Arc::clone(&ctx.search_command),
|
||||||
search_command: Arc::clone(&ctx.search_command),
|
}) as Arc<dyn EventHandler>;
|
||||||
}) as Arc<dyn EventHandler>;
|
let job = Arc::new(application::jobs::EnrichmentStalenessJob::new(ctx.clone()))
|
||||||
let job = Arc::new(application::jobs::EnrichmentStalenessJob::new(ctx.clone()))
|
as Arc<dyn PeriodicJob>;
|
||||||
as Arc<dyn PeriodicJob>;
|
(Some(handler), Some(job))
|
||||||
(Some(handler), Some(job))
|
}
|
||||||
}
|
Err(e) => {
|
||||||
Err(e) => {
|
tracing::warn!("TMDb enrichment disabled: {e}");
|
||||||
tracing::warn!("TMDb enrichment disabled: {e}");
|
(None, None)
|
||||||
(None, None)
|
}
|
||||||
}
|
};
|
||||||
};
|
|
||||||
|
|
||||||
// ── Image conversion ──────────────────────────────────────────────────────
|
// ── Image conversion ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -194,16 +197,19 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
|
|
||||||
#[cfg(feature = "federation")]
|
#[cfg(feature = "federation")]
|
||||||
{
|
{
|
||||||
let ap_wire = activitypub::wire(
|
let ap_wire = activitypub::wire(activitypub::ActivityPubDeps {
|
||||||
fed_federation_repo,
|
activity_repo: fed_activity_repo,
|
||||||
fed_review_store,
|
follow_repo: fed_follow_repo,
|
||||||
fed_remote_watchlist_repo,
|
actor_repo: fed_actor_repo,
|
||||||
fed_ap_content,
|
blocklist_repo: fed_blocklist_repo,
|
||||||
fed_user_repo,
|
review_store: fed_review_store,
|
||||||
|
remote_watchlist_repo: fed_remote_watchlist_repo,
|
||||||
|
local_ap_content: fed_ap_content,
|
||||||
|
user_repo: fed_user_repo,
|
||||||
base_url,
|
base_url,
|
||||||
allow_registration,
|
allow_registration,
|
||||||
Arc::clone(&ctx.event_publisher),
|
event_publisher: Arc::clone(&ctx.event_publisher),
|
||||||
)
|
})
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let ap_event_handler = ap_wire.event_handler;
|
let ap_event_handler = ap_wire.event_handler;
|
||||||
|
|||||||
Reference in New Issue
Block a user