Compare commits

...

2 Commits

Author SHA1 Message Date
51f4770c23 feat: discoverability (NodeInfo, hashtags) and moderation (domain/actor blocking)
- NodeInfo at /.well-known/nodeinfo + /nodeinfo/2.0
- Hashtags #MoviesDiary + #MovieTitle on review posts; /tags/{tag} redirect
- Domain blocking: blocked_domains table, admin API + HTML, inbox enforcement
- Per-actor blocking: blocked_actors table, user API + HTML, BlockActivity send/receive
- Delivery filter excludes blocked actors and blocked-domain inboxes
2026-05-12 01:05:46 +02:00
5da979649b feat: image storage generalization, user profile, and federation polish
- Replace PosterStorage with generic ImageStorage port (IMAGE_STORAGE_BACKEND/PATH env vars)
- Rename poster-storage crate to image-storage; serve at /images/{*key}
- Add bio and avatar_path to User model (migration 0009_user_profile)
- update_profile use case with avatar upload, mime validation, old avatar cleanup
- GET/PUT /api/v1/profile and GET/POST /settings/profile HTML page
- Enrich AP Person actor with summary, icon, url, discoverable fields
- Store remote actor avatar_url (migration 0010_ap_remote_actor_avatar)
- Shared inbox delivery via collect_inboxes deduplication
- Broadcast Update(Person) to followers on UserUpdated event
- Paginated outbox: OrderedCollection + OrderedCollectionPage with cursor
- Announce/boost tracking in ap_announces table (migration 0011_ap_announces)
2026-05-11 22:59:52 +02:00
106 changed files with 3582 additions and 983 deletions

32
Cargo.lock generated
View File

@@ -2399,6 +2399,20 @@ dependencies = [
"icu_properties", "icu_properties",
] ]
[[package]]
name = "image-storage"
version = "0.1.0"
dependencies = [
"anyhow",
"async-trait",
"domain",
"infer",
"object_store",
"tokio",
"tracing",
"uuid",
]
[[package]] [[package]]
name = "impl-more" name = "impl-more"
version = "0.1.9" version = "0.1.9"
@@ -3424,20 +3438,6 @@ dependencies = [
"reqwest 0.13.3", "reqwest 0.13.3",
] ]
[[package]]
name = "poster-storage"
version = "0.1.0"
dependencies = [
"anyhow",
"async-trait",
"domain",
"infer",
"object_store",
"tokio",
"tracing",
"uuid",
]
[[package]] [[package]]
name = "poster-sync" name = "poster-sync"
version = "0.1.0" version = "0.1.0"
@@ -3539,13 +3539,13 @@ dependencies = [
"dotenvy", "dotenvy",
"export", "export",
"http-body-util", "http-body-util",
"image-storage",
"importer", "importer",
"infer", "infer",
"metadata", "metadata",
"nats", "nats",
"percent-encoding", "percent-encoding",
"poster-fetcher", "poster-fetcher",
"poster-storage",
"postgres", "postgres",
"postgres-event-queue", "postgres-event-queue",
"postgres-federation", "postgres-federation",
@@ -6340,11 +6340,11 @@ dependencies = [
"dotenvy", "dotenvy",
"export", "export",
"futures", "futures",
"image-storage",
"importer", "importer",
"metadata", "metadata",
"nats", "nats",
"poster-fetcher", "poster-fetcher",
"poster-storage",
"poster-sync", "poster-sync",
"postgres", "postgres",
"postgres-event-queue", "postgres-event-queue",

View File

@@ -4,7 +4,7 @@ members = [
"crates/adapters/event-publisher", "crates/adapters/event-publisher",
"crates/adapters/metadata", "crates/adapters/metadata",
"crates/adapters/poster-fetcher", "crates/adapters/poster-fetcher",
"crates/adapters/poster-storage", "crates/adapters/image-storage",
"crates/adapters/poster-sync", "crates/adapters/poster-sync",
"crates/adapters/rss", "crates/adapters/rss",
"crates/adapters/sqlite", "crates/adapters/sqlite",
@@ -59,7 +59,7 @@ presentation = { path = "crates/presentation" }
auth = { path = "crates/adapters/auth" } auth = { path = "crates/adapters/auth" }
metadata = { path = "crates/adapters/metadata" } metadata = { path = "crates/adapters/metadata" }
poster-fetcher = { path = "crates/adapters/poster-fetcher" } poster-fetcher = { path = "crates/adapters/poster-fetcher" }
poster-storage = { path = "crates/adapters/poster-storage" } image-storage = { path = "crates/adapters/image-storage" }
poster-sync = { path = "crates/adapters/poster-sync" } poster-sync = { path = "crates/adapters/poster-sync" }
event-publisher = { path = "crates/adapters/event-publisher" } event-publisher = { path = "crates/adapters/event-publisher" }
rss = { path = "crates/adapters/rss" } rss = { path = "crates/adapters/rss" }

View File

@@ -15,7 +15,7 @@ COPY crates/adapters/event-publisher/Cargo.toml crates/adapters/event-publishe
COPY crates/adapters/nats/Cargo.toml crates/adapters/nats/Cargo.toml COPY crates/adapters/nats/Cargo.toml crates/adapters/nats/Cargo.toml
COPY crates/adapters/metadata/Cargo.toml crates/adapters/metadata/Cargo.toml COPY crates/adapters/metadata/Cargo.toml crates/adapters/metadata/Cargo.toml
COPY crates/adapters/poster-fetcher/Cargo.toml crates/adapters/poster-fetcher/Cargo.toml COPY crates/adapters/poster-fetcher/Cargo.toml crates/adapters/poster-fetcher/Cargo.toml
COPY crates/adapters/poster-storage/Cargo.toml crates/adapters/poster-storage/Cargo.toml COPY crates/adapters/image-storage/Cargo.toml crates/adapters/image-storage/Cargo.toml
COPY crates/adapters/poster-sync/Cargo.toml crates/adapters/poster-sync/Cargo.toml COPY crates/adapters/poster-sync/Cargo.toml crates/adapters/poster-sync/Cargo.toml
COPY crates/adapters/export/Cargo.toml crates/adapters/export/Cargo.toml COPY crates/adapters/export/Cargo.toml crates/adapters/export/Cargo.toml
COPY crates/adapters/importer/Cargo.toml crates/adapters/importer/Cargo.toml COPY crates/adapters/importer/Cargo.toml crates/adapters/importer/Cargo.toml

View File

@@ -9,7 +9,8 @@ A self-hosted, server-side rendered movie logging system with a full REST API. B
- Background poster fetching and storage (local filesystem or S3-compatible) - Background poster fetching and storage (local filesystem or S3-compatible)
- RSS/Atom feed for public subscription (global and per-user) - RSS/Atom feed for public subscription (global and per-user)
- JWT authentication via cookie (HTML) or Bearer token (REST API) - JWT authentication via cookie (HTML) or Bearer token (REST API)
- ActivityPub federation — follow/unfollow remote users on any compatible server, accept/reject/remove followers, pending follow request management - ActivityPub federation — follow/unfollow remote users, accept/reject/remove followers, federated reviews broadcast as `Note` objects with `#MoviesDiary` + `#MovieTitle` hashtags, paginated outbox, boost/Announce tracking, NodeInfo discovery endpoint, shared inbox delivery, actor profile sync (bio, avatar, discoverable)
- Federation moderation — instance-level domain blocking (admin-managed), per-user actor blocking with `Block` activity, delivery filter excludes blocked actors and blocked-domain inboxes
- CSV and JSON diary export - CSV and JSON diary export
- File importer: upload CSV, TSV, JSON, or XLSX from any source (Letterboxd, IMDb, etc.), map columns to domain fields via a step-by-step wizard or REST API, save mapping profiles for repeat imports - File importer: upload CSV, TSV, JSON, or XLSX from any source (Letterboxd, IMDb, etc.), map columns to domain fields via a step-by-step wizard or REST API, save mapping profiles for repeat imports
- REST API v1 (`/api/v1/`) with full feature parity with the HTML interface - REST API v1 (`/api/v1/`) with full feature parity with the HTML interface
@@ -33,7 +34,7 @@ adapters/
postgres — PostgreSQL repository + connection factory postgres — PostgreSQL repository + connection factory
metadata — TMDB / OMDb HTTP client metadata — TMDB / OMDb HTTP client
poster-fetcher — downloads poster images poster-fetcher — downloads poster images
poster-storage — stores posters on local filesystem or S3-compatible storage image-storage — stores images (posters + user avatars) on local filesystem or S3-compatible storage
poster-sync — event handler: triggers poster fetch+store on MovieDiscovered poster-sync — event handler: triggers poster fetch+store on MovieDiscovered
template-askama — Askama HTML rendering template-askama — Askama HTML rendering
rss — RSS/Atom feed generation rss — RSS/Atom feed generation
@@ -76,14 +77,14 @@ OMDB_API_KEY=your-key
# Public base URL (used for ActivityPub actor URLs and canonical links) # Public base URL (used for ActivityPub actor URLs and canonical links)
BASE_URL=https://yourdomain.example.com BASE_URL=https://yourdomain.example.com
# Poster storage — pick one backend: # Image storage — pick one backend:
# Option A: local filesystem (zero deps) # Option A: local filesystem (zero deps)
POSTER_STORAGE_BACKEND=local IMAGE_STORAGE_BACKEND=local
POSTER_STORAGE_PATH=./posters IMAGE_STORAGE_PATH=./images
# Option B: S3-compatible (MinIO, AWS S3, etc.) # Option B: S3-compatible (MinIO, AWS S3, etc.)
# POSTER_STORAGE_BACKEND=s3 # IMAGE_STORAGE_BACKEND=s3
# MINIO_ENDPOINT=http://localhost:9000 # MINIO_ENDPOINT=http://localhost:9000
# MINIO_BUCKET=posters # MINIO_BUCKET=posters
# MINIO_REGION=minio # MINIO_REGION=minio

View File

@@ -9,6 +9,10 @@ use activitypub_federation::{
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use url::Url; use url::Url;
#[derive(Clone, Default, Debug, Serialize, Deserialize)]
#[serde(rename = "Announce")]
pub struct AnnounceType;
use crate::actors::DbActor; use crate::actors::DbActor;
use crate::data::FederationData; use crate::data::FederationData;
use crate::error::Error; use crate::error::Error;
@@ -59,9 +63,22 @@ impl Activity for FollowActivity {
} }
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> { async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
let domain = self.actor().host_str().unwrap_or("");
if data.federation_repo.is_domain_blocked(domain).await? {
tracing::info!(actor = %self.actor(), "ignoring activity from blocked domain");
return Ok(());
}
let _follower = self.actor.dereference(data).await?; let _follower = self.actor.dereference(data).await?;
let local_actor = self.object.dereference(data).await?; let local_actor = self.object.dereference(data).await?;
if data.federation_repo
.is_actor_blocked(local_actor.user_id, self.actor.inner().as_str())
.await?
{
tracing::info!(actor = %self.actor.inner(), "ignoring follow from blocked actor");
return Ok(());
}
data.federation_repo data.federation_repo
.add_follower( .add_follower(
local_actor.user_id, local_actor.user_id,
@@ -110,6 +127,11 @@ impl Activity for AcceptActivity {
} }
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> { async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
let domain = self.actor().host_str().unwrap_or("");
if data.federation_repo.is_domain_blocked(domain).await? {
tracing::info!(actor = %self.actor(), "ignoring activity from blocked domain");
return Ok(());
}
let local_user_id = crate::urls::extract_user_id_from_url(self.object.actor.inner()) let local_user_id = crate::urls::extract_user_id_from_url(self.object.actor.inner())
.ok_or_else(|| Error::bad_request(anyhow::anyhow!("invalid actor URL in Follow")))?; .ok_or_else(|| Error::bad_request(anyhow::anyhow!("invalid actor URL in Follow")))?;
data.federation_repo data.federation_repo
@@ -154,6 +176,11 @@ impl Activity for RejectActivity {
} }
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> { async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
let domain = self.actor().host_str().unwrap_or("");
if data.federation_repo.is_domain_blocked(domain).await? {
tracing::info!(actor = %self.actor(), "ignoring activity from blocked domain");
return Ok(());
}
if let Some(user_id) = crate::urls::extract_user_id_from_url(self.object.actor.inner()) { if let Some(user_id) = crate::urls::extract_user_id_from_url(self.object.actor.inner()) {
data.federation_repo data.federation_repo
.remove_following(user_id, self.actor.inner().as_str()) .remove_following(user_id, self.actor.inner().as_str())
@@ -194,6 +221,11 @@ impl Activity for UndoActivity {
} }
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> { async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
let domain = self.actor().host_str().unwrap_or("");
if data.federation_repo.is_domain_blocked(domain).await? {
tracing::info!(actor = %self.actor(), "ignoring activity from blocked domain");
return Ok(());
}
if let Some(user_id) = crate::urls::extract_user_id_from_url(self.object.object.inner()) { if let Some(user_id) = crate::urls::extract_user_id_from_url(self.object.object.inner()) {
data.federation_repo data.federation_repo
.remove_follower(user_id, self.actor.inner().as_str()) .remove_follower(user_id, self.actor.inner().as_str())
@@ -238,6 +270,11 @@ impl Activity for CreateActivity {
} }
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> { async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
let domain = self.actor().host_str().unwrap_or("");
if data.federation_repo.is_domain_blocked(domain).await? {
tracing::info!(actor = %self.actor(), "ignoring activity from blocked domain");
return Ok(());
}
let ap_id = self.id.clone(); let ap_id = self.id.clone();
let actor_url = self.actor.inner().clone(); let actor_url = self.actor.inner().clone();
data.object_handler data.object_handler
@@ -279,6 +316,11 @@ impl Activity for DeleteActivity {
} }
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> { async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
let domain = self.actor().host_str().unwrap_or("");
if data.federation_repo.is_domain_blocked(domain).await? {
tracing::info!(actor = %self.actor(), "ignoring activity from blocked domain");
return Ok(());
}
let actor_url = self.actor.inner().clone(); let actor_url = self.actor.inner().clone();
data.object_handler data.object_handler
.on_delete(&self.object, &actor_url) .on_delete(&self.object, &actor_url)
@@ -319,6 +361,11 @@ impl Activity for UpdateActivity {
} }
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> { async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
let domain = self.actor().host_str().unwrap_or("");
if data.federation_repo.is_domain_blocked(domain).await? {
tracing::info!(actor = %self.actor(), "ignoring activity from blocked domain");
return Ok(());
}
let ap_id = self.id.clone(); let ap_id = self.id.clone();
let actor_url = self.actor.inner().clone(); let actor_url = self.actor.inner().clone();
data.object_handler data.object_handler
@@ -330,6 +377,110 @@ impl Activity for UpdateActivity {
} }
} }
// --- Announce ---
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AnnounceActivity {
pub(crate) id: Url,
#[serde(rename = "type", default)]
pub(crate) kind: AnnounceType,
pub(crate) actor: ObjectId<DbActor>,
pub(crate) object: Url,
pub(crate) published: Option<chrono::DateTime<chrono::Utc>>,
}
#[async_trait::async_trait]
impl Activity for AnnounceActivity {
type DataType = FederationData;
type Error = Error;
fn id(&self) -> &Url {
&self.id
}
fn actor(&self) -> &Url {
self.actor.inner()
}
async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> {
Ok(())
}
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
let domain = self.actor().host_str().unwrap_or("");
if data.federation_repo.is_domain_blocked(domain).await? {
tracing::info!(actor = %self.actor(), "ignoring activity from blocked domain");
return Ok(());
}
let object_domain = self.object.host_str().unwrap_or("");
if object_domain != data.domain {
return Ok(());
}
data.federation_repo
.add_announce(
self.id.as_str(),
self.object.as_str(),
self.actor.inner().as_str(),
self.published.unwrap_or_else(chrono::Utc::now),
)
.await?;
tracing::info!(actor = %self.actor.inner(), object = %self.object, "received announce");
Ok(())
}
}
// --- Block ---
#[derive(Clone, Default, Debug, Serialize, Deserialize)]
#[serde(rename = "Block")]
pub struct BlockType;
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct BlockActivity {
pub(crate) id: Url,
#[serde(rename = "type", default)]
pub(crate) kind: BlockType,
pub(crate) actor: ObjectId<DbActor>,
pub(crate) object: Url,
}
#[async_trait::async_trait]
impl Activity for BlockActivity {
type DataType = FederationData;
type Error = Error;
fn id(&self) -> &Url {
&self.id
}
fn actor(&self) -> &Url {
self.actor.inner()
}
async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> {
Ok(())
}
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
let domain = self.actor().host_str().unwrap_or("");
if data.federation_repo.is_domain_blocked(domain).await? {
tracing::info!(actor = %self.actor(), "ignoring activity from blocked domain");
return Ok(());
}
// They blocked us — remove them from our following list
if let Some(local_user_id) = crate::urls::extract_user_id_from_url(&self.object) {
let _ = data
.federation_repo
.remove_following(local_user_id, self.actor.inner().as_str())
.await;
}
tracing::info!(actor = %self.actor.inner(), "received block");
Ok(())
}
}
// --- Inbox dispatch enum --- // --- Inbox dispatch enum ---
#[derive(Debug, Deserialize, Serialize)] #[derive(Debug, Deserialize, Serialize)]
@@ -350,4 +501,8 @@ pub enum InboxActivities {
Delete(DeleteActivity), Delete(DeleteActivity),
#[serde(rename = "Update")] #[serde(rename = "Update")]
Update(UpdateActivity), Update(UpdateActivity),
#[serde(rename = "Announce")]
Announce(AnnounceActivity),
#[serde(rename = "Block")]
Block(BlockActivity),
} }

View File

@@ -26,6 +26,16 @@ pub struct DbActor {
pub following_url: Url, pub following_url: Url,
pub ap_id: Url, pub ap_id: Url,
pub last_refreshed_at: DateTime<Utc>, pub last_refreshed_at: DateTime<Utc>,
pub bio: Option<String>,
pub avatar_url: Option<Url>,
pub profile_url: Option<Url>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ApImageObject {
#[serde(rename = "type")]
pub kind: String,
pub url: Url,
} }
#[derive(Debug, Clone, Deserialize, Serialize)] #[derive(Debug, Clone, Deserialize, Serialize)]
@@ -41,6 +51,15 @@ pub struct Person {
following: Url, following: Url,
public_key: PublicKey, public_key: PublicKey,
name: Option<String>, name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
summary: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
icon: Option<ApImageObject>,
#[serde(skip_serializing_if = "Option::is_none")]
url: Option<Url>,
#[serde(skip_serializing_if = "Option::is_none")]
discoverable: Option<bool>,
manually_approves_followers: bool,
} }
pub async fn get_local_actor( pub async fn get_local_actor(
@@ -86,6 +105,9 @@ pub async fn get_local_actor(
following_url, following_url,
ap_id, ap_id,
last_refreshed_at: Utc::now(), last_refreshed_at: Utc::now(),
bio: user.bio,
avatar_url: user.avatar_url,
profile_url: user.profile_url,
}) })
} }
@@ -143,16 +165,25 @@ impl Object for DbActor {
following_url, following_url,
ap_id, ap_id,
last_refreshed_at: Utc::now(), last_refreshed_at: Utc::now(),
bio: None,
avatar_url: None,
profile_url: None,
})) }))
} }
async fn into_json(self, _data: &Data<Self::DataType>) -> Result<Self::Kind, Self::Error> { async fn into_json(self, data: &Data<Self::DataType>) -> Result<Self::Kind, Self::Error> {
let public_key = PublicKey { let public_key = PublicKey {
id: format!("{}#main-key", &self.ap_id), id: format!("{}#main-key", &self.ap_id),
owner: self.ap_id.clone(), owner: self.ap_id.clone(),
public_key_pem: self.public_key_pem.clone(), public_key_pem: self.public_key_pem.clone(),
}; };
let icon = self.avatar_url.map(|url| ApImageObject {
kind: "Image".to_string(),
url,
});
let profile_url = self.profile_url;
Ok(Person { Ok(Person {
kind: Default::default(), kind: Default::default(),
id: self.ap_id.clone().into(), id: self.ap_id.clone().into(),
@@ -163,6 +194,11 @@ impl Object for DbActor {
following: self.following_url.clone(), following: self.following_url.clone(),
public_key, public_key,
name: Some(self.username.clone()), name: Some(self.username.clone()),
summary: self.bio.clone(),
icon,
url: profile_url,
discoverable: Some(true),
manually_approves_followers: false,
}) })
} }
@@ -182,6 +218,7 @@ impl Object for DbActor {
inbox_url: json.inbox.to_string(), inbox_url: json.inbox.to_string(),
shared_inbox_url: None, shared_inbox_url: None,
display_name: json.name.clone(), display_name: json.name.clone(),
avatar_url: json.icon.as_ref().map(|i| i.url.to_string()),
}; };
data.federation_repo.upsert_remote_actor(actor).await?; data.federation_repo.upsert_remote_actor(actor).await?;
@@ -204,6 +241,9 @@ impl Object for DbActor {
following_url, following_url,
ap_id, ap_id,
last_refreshed_at: Utc::now(), last_refreshed_at: Utc::now(),
bio: None,
avatar_url: None,
profile_url: None,
}) })
} }
} }
@@ -221,3 +261,40 @@ impl Actor for DbActor {
self.inbox_url.clone() self.inbox_url.clone()
} }
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn person_serializes_with_enriched_fields() {
let person = Person {
kind: Default::default(),
id: "https://example.com/users/1".parse::<url::Url>().unwrap().into(),
preferred_username: "alice".to_string(),
inbox: "https://example.com/users/1/inbox".parse().unwrap(),
outbox: "https://example.com/users/1/outbox".parse().unwrap(),
followers: "https://example.com/users/1/followers".parse().unwrap(),
following: "https://example.com/users/1/following".parse().unwrap(),
public_key: PublicKey {
id: "https://example.com/users/1#main-key".to_string(),
owner: "https://example.com/users/1".parse().unwrap(),
public_key_pem: "pem".to_string(),
},
name: Some("Alice".to_string()),
summary: Some("Bio text".to_string()),
icon: Some(ApImageObject {
kind: "Image".to_string(),
url: "https://example.com/images/avatars/1".parse().unwrap(),
}),
url: Some("https://example.com/u/alice".parse().unwrap()),
discoverable: Some(true),
manually_approves_followers: false,
};
let json = serde_json::to_value(&person).unwrap();
assert_eq!(json["discoverable"], true);
assert_eq!(json["summary"], "Bio text");
assert_eq!(json["icon"]["type"], "Image");
assert!(json.get("manuallyApprovesFollowers").is_some());
}
}

View File

@@ -1,4 +1,5 @@
use async_trait::async_trait; use async_trait::async_trait;
use chrono::{DateTime, Utc};
use url::Url; use url::Url;
#[async_trait] #[async_trait]
@@ -10,6 +11,15 @@ pub trait ApObjectHandler: Send + Sync {
user_id: uuid::Uuid, user_id: uuid::Uuid,
) -> anyhow::Result<Vec<(Url, serde_json::Value)>>; ) -> anyhow::Result<Vec<(Url, serde_json::Value)>>;
/// Returns up to `limit` objects ordered newest-first, published before `before`.
/// Returns (ap_id, object_json, published_at).
async fn get_local_objects_page(
&self,
user_id: uuid::Uuid,
before: Option<DateTime<Utc>>,
limit: usize,
) -> anyhow::Result<Vec<(Url, serde_json::Value, DateTime<Utc>)>>;
/// Incoming Create activity — persist remote content. /// Incoming Create activity — persist remote content.
async fn on_create( async fn on_create(
&self, &self,
@@ -31,4 +41,7 @@ pub trait ApObjectHandler: Send + Sync {
/// Actor unfollowed/was removed — clean up all their remote content. /// Actor unfollowed/was removed — clean up all their remote content.
async fn on_actor_removed(&self, actor_url: &Url) -> anyhow::Result<()>; async fn on_actor_removed(&self, actor_url: &Url) -> anyhow::Result<()>;
/// Total number of locally-authored posts across all users.
async fn count_local_posts(&self) -> anyhow::Result<u64>;
} }

View File

@@ -11,6 +11,8 @@ pub struct FederationData {
pub(crate) object_handler: Arc<dyn ApObjectHandler>, pub(crate) object_handler: Arc<dyn ApObjectHandler>,
pub(crate) base_url: String, pub(crate) base_url: String,
pub(crate) domain: String, pub(crate) domain: String,
pub(crate) allow_registration: bool,
pub(crate) software_name: String,
} }
impl FederationData { impl FederationData {
@@ -19,6 +21,8 @@ impl FederationData {
user_repo: Arc<dyn ApUserRepository>, user_repo: Arc<dyn ApUserRepository>,
object_handler: Arc<dyn ApObjectHandler>, object_handler: Arc<dyn ApObjectHandler>,
base_url: String, base_url: String,
allow_registration: bool,
software_name: String,
) -> Self { ) -> Self {
let domain = base_url let domain = base_url
.trim_start_matches("https://") .trim_start_matches("https://")
@@ -33,6 +37,8 @@ impl FederationData {
object_handler, object_handler,
base_url, base_url,
domain, domain,
allow_registration,
software_name,
} }
} }
} }

View File

@@ -7,6 +7,7 @@ pub mod error;
pub mod federation; pub mod federation;
pub mod followers_handler; pub mod followers_handler;
pub mod inbox; pub mod inbox;
pub mod nodeinfo;
pub mod outbox; pub mod outbox;
pub mod repository; pub mod repository;
pub mod service; pub mod service;
@@ -19,7 +20,7 @@ pub use data::FederationData;
pub use error::Error; pub use error::Error;
pub use federation::ApFederationConfig; pub use federation::ApFederationConfig;
pub use repository::{ pub use repository::{
FederationRepository, Follower, FollowerStatus, FollowingStatus, RemoteActor, BlockedDomain, FederationRepository, Follower, FollowerStatus, FollowingStatus, RemoteActor,
}; };
pub use service::ActivityPubService; pub use service::ActivityPubService;
pub use user::{ApUser, ApUserRepository}; pub use user::{ApUser, ApUserRepository};

View File

@@ -0,0 +1,119 @@
use activitypub_federation::config::Data;
use axum::Json;
use serde::Serialize;
use crate::data::FederationData;
use crate::error::Error;
#[derive(Serialize)]
pub struct NodeInfoWellKnown {
pub links: Vec<NodeInfoLink>,
}
#[derive(Serialize)]
pub struct NodeInfoLink {
pub rel: String,
pub href: String,
}
#[derive(Serialize)]
pub struct NodeInfoSoftware {
pub name: String,
pub version: String,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct NodeInfoUsage {
pub users: NodeInfoUsers,
pub local_posts: u64,
}
#[derive(Serialize)]
pub struct NodeInfoUsers {
pub total: usize,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct NodeInfo {
pub version: String,
pub software: NodeInfoSoftware,
pub protocols: Vec<String>,
pub usage: NodeInfoUsage,
pub open_registrations: bool,
}
pub async fn nodeinfo_well_known_handler(
data: Data<FederationData>,
) -> Result<Json<NodeInfoWellKnown>, Error> {
let href = format!("{}/nodeinfo/2.0", data.base_url);
Ok(Json(NodeInfoWellKnown {
links: vec![NodeInfoLink {
rel: "http://nodeinfo.diaspora.software/ns/schema/2.0".to_string(),
href,
}],
}))
}
pub async fn nodeinfo_handler(
data: Data<FederationData>,
) -> Result<Json<NodeInfo>, Error> {
let user_count = data.user_repo.count_users().await.unwrap_or(0);
let local_posts = data.object_handler.count_local_posts().await.unwrap_or(0);
Ok(Json(NodeInfo {
version: "2.0".to_string(),
software: NodeInfoSoftware {
name: data.software_name.clone(),
version: env!("CARGO_PKG_VERSION").to_string(),
},
protocols: vec!["activitypub".to_string()],
usage: NodeInfoUsage {
users: NodeInfoUsers { total: user_count },
local_posts,
},
open_registrations: data.allow_registration,
}))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn nodeinfo_well_known_serializes_correctly() {
let doc = NodeInfoWellKnown {
links: vec![NodeInfoLink {
rel: "http://nodeinfo.diaspora.software/ns/schema/2.0".to_string(),
href: "https://example.com/nodeinfo/2.0".to_string(),
}],
};
let json = serde_json::to_value(&doc).unwrap();
assert_eq!(json["links"][0]["rel"], "http://nodeinfo.diaspora.software/ns/schema/2.0");
assert_eq!(json["links"][0]["href"], "https://example.com/nodeinfo/2.0");
}
#[test]
fn nodeinfo_serializes_camel_case() {
let doc = NodeInfo {
version: "2.0".to_string(),
software: NodeInfoSoftware {
name: "my-app".to_string(),
version: "0.1.0".to_string(),
},
protocols: vec!["activitypub".to_string()],
usage: NodeInfoUsage {
users: NodeInfoUsers { total: 3 },
local_posts: 42,
},
open_registrations: false,
};
let json = serde_json::to_value(&doc).unwrap();
assert_eq!(json["version"], "2.0");
assert_eq!(json["software"]["name"], "my-app");
assert_eq!(json["usage"]["users"]["total"], 3);
assert_eq!(json["usage"]["localPosts"], 42);
assert_eq!(json["openRegistrations"], false);
}
}

View File

@@ -1,9 +1,20 @@
use activitypub_federation::{axum::json::FederationJson, config::Data}; use axum::extract::{Path, Query};
use axum::extract::Path; use axum::response::IntoResponse;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use url::Url;
use crate::data::FederationData; use activitypub_federation::{config::Data, fetch::object_id::ObjectId, kinds::activity::CreateType};
use crate::error::Error;
use crate::{activities::CreateActivity, data::FederationData, error::Error};
const PAGE_SIZE: usize = 20;
#[derive(Deserialize)]
pub struct OutboxQuery {
page: Option<bool>,
before: Option<String>,
}
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
@@ -14,13 +25,28 @@ pub struct OrderedCollection {
kind: String, kind: String,
id: String, id: String,
total_items: u64, total_items: u64,
first: String,
}
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OrderedCollectionPage {
#[serde(rename = "@context")]
context: String,
#[serde(rename = "type")]
kind: String,
id: String,
part_of: String,
ordered_items: Vec<serde_json::Value>, ordered_items: Vec<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
next: Option<String>,
} }
pub async fn outbox_handler( pub async fn outbox_handler(
Path(user_id_str): Path<String>, Path(user_id_str): Path<String>,
Query(query): Query<OutboxQuery>,
data: Data<FederationData>, data: Data<FederationData>,
) -> Result<FederationJson<OrderedCollection>, Error> { ) -> Result<axum::response::Response, Error> {
let uuid = uuid::Uuid::parse_str(&user_id_str) let uuid = uuid::Uuid::parse_str(&user_id_str)
.map_err(|_| Error::bad_request(anyhow::anyhow!("invalid user id")))?; .map_err(|_| Error::bad_request(anyhow::anyhow!("invalid user id")))?;
@@ -30,19 +56,80 @@ pub async fn outbox_handler(
.map_err(Error::from)? .map_err(Error::from)?
.ok_or_else(|| Error::not_found(anyhow::anyhow!("user not found")))?; .ok_or_else(|| Error::not_found(anyhow::anyhow!("user not found")))?;
let objects = data
.object_handler
.get_local_objects_for_user(uuid)
.await
.map_err(|e| Error::from(anyhow::anyhow!(e)))?;
let outbox_url = format!("{}/users/{}/outbox", data.base_url, user_id_str); let outbox_url = format!("{}/users/{}/outbox", data.base_url, user_id_str);
Ok(FederationJson(OrderedCollection { if query.page.unwrap_or(false) {
context: "https://www.w3.org/ns/activitystreams".to_string(), let before: Option<DateTime<Utc>> = query.before.as_deref().and_then(|s| s.parse().ok());
kind: "OrderedCollection".to_string(),
id: outbox_url, let items = data
total_items: objects.len() as u64, .object_handler
ordered_items: vec![], .get_local_objects_page(uuid, before, PAGE_SIZE)
})) .await
.map_err(|e| Error::from(anyhow::anyhow!("{}", e)))?;
let actor_url: Url = format!("{}/users/{}", data.base_url, user_id_str)
.parse()
.expect("valid url");
let has_more = items.len() == PAGE_SIZE;
let oldest_ts = items.last().map(|(_, _, ts)| *ts);
let ordered_items: Vec<serde_json::Value> = items
.into_iter()
.map(|(ap_id, object, _)| {
let create_id =
Url::parse(&format!("{}/activity", ap_id)).expect("valid url");
serde_json::to_value(CreateActivity {
id: create_id,
kind: CreateType::default(),
actor: ObjectId::from(actor_url.clone()),
object,
})
.expect("serializable")
})
.collect();
let page_id = match &query.before {
Some(b) => format!("{}?page=true&before={}", outbox_url, b),
None => format!("{}?page=true", outbox_url),
};
let next = if has_more {
oldest_ts.map(|ts| {
// Use RFC 3339 with Z suffix (no + sign) to avoid percent-encoding
let ts_str = ts
.format("%Y-%m-%dT%H:%M:%S%.3fZ")
.to_string();
format!("{}?page=true&before={}", outbox_url, ts_str)
})
} else {
None
};
Ok(axum::Json(OrderedCollectionPage {
context: "https://www.w3.org/ns/activitystreams".to_string(),
kind: "OrderedCollectionPage".to_string(),
id: page_id,
part_of: outbox_url,
ordered_items,
next,
})
.into_response())
} else {
let total = data
.object_handler
.get_local_objects_for_user(uuid)
.await
.map_err(|e| Error::from(anyhow::anyhow!("{}", e)))?
.len() as u64;
Ok(axum::Json(OrderedCollection {
context: "https://www.w3.org/ns/activitystreams".to_string(),
kind: "OrderedCollection".to_string(),
id: outbox_url.clone(),
total_items: total,
first: format!("{}?page=true", outbox_url),
})
.into_response())
}
} }

View File

@@ -21,6 +21,7 @@ pub struct RemoteActor {
pub inbox_url: String, pub inbox_url: String,
pub shared_inbox_url: Option<String>, pub shared_inbox_url: Option<String>,
pub display_name: Option<String>, pub display_name: Option<String>,
pub avatar_url: Option<String>,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -29,6 +30,13 @@ pub struct Follower {
pub status: FollowerStatus, pub status: FollowerStatus,
} }
#[derive(Debug, Clone)]
pub struct BlockedDomain {
pub domain: String,
pub reason: Option<String>,
pub blocked_at: String,
}
#[async_trait] #[async_trait]
pub trait FederationRepository: Send + Sync { pub trait FederationRepository: Send + Sync {
async fn add_follower( async fn add_follower(
@@ -88,4 +96,20 @@ pub trait FederationRepository: Send + Sync {
remote_actor_url: &str, remote_actor_url: &str,
status: FollowingStatus, status: FollowingStatus,
) -> Result<()>; ) -> Result<()>;
async fn add_announce(
&self,
activity_id: &str,
object_url: &str,
actor_url: &str,
announced_at: chrono::DateTime<chrono::Utc>,
) -> Result<()>;
async fn count_announces(&self, object_url: &str) -> Result<usize>;
async fn add_blocked_domain(&self, domain: &str, reason: Option<&str>) -> Result<()>;
async fn remove_blocked_domain(&self, domain: &str) -> Result<()>;
async fn get_blocked_domains(&self) -> Result<Vec<BlockedDomain>>;
async fn is_domain_blocked(&self, domain: &str) -> Result<bool>;
async fn add_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<()>;
async fn get_blocked_actors(&self, local_user_id: uuid::Uuid) -> Result<Vec<String>>;
async fn is_actor_blocked(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<bool>;
} }

View File

@@ -10,7 +10,7 @@ use axum::{Router, routing::get, routing::post};
use url::Url; use url::Url;
use crate::{ use crate::{
activities::{AcceptActivity, CreateActivity, FollowActivity, RejectActivity, UndoActivity}, activities::{AcceptActivity, CreateActivity, FollowActivity, RejectActivity, UndoActivity, UpdateActivity},
actors::{DbActor, get_local_actor}, actors::{DbActor, get_local_actor},
content::ApObjectHandler, content::ApObjectHandler,
data::FederationData, data::FederationData,
@@ -18,12 +18,31 @@ use crate::{
followers_handler::{followers_handler, following_handler}, followers_handler::{followers_handler, following_handler},
inbox::inbox_handler, inbox::inbox_handler,
outbox::outbox_handler, outbox::outbox_handler,
repository::{FederationRepository, FollowerStatus, FollowingStatus, RemoteActor}, repository::{BlockedDomain, FederationRepository, FollowerStatus, FollowingStatus, RemoteActor},
urls::activity_url, urls::activity_url,
user::ApUserRepository, user::ApUserRepository,
nodeinfo::{nodeinfo_handler, nodeinfo_well_known_handler},
webfinger::webfinger_handler, webfinger::webfinger_handler,
}; };
fn collect_inboxes(followers: &[crate::repository::Follower]) -> Vec<Url> {
let mut seen = std::collections::HashSet::new();
let mut inboxes = Vec::new();
for f in followers {
let inbox_str = f
.actor
.shared_inbox_url
.as_deref()
.unwrap_or(&f.actor.inbox_url);
if seen.insert(inbox_str.to_string()) {
if let Ok(url) = Url::parse(inbox_str) {
inboxes.push(url);
}
}
}
inboxes
}
pub(crate) async fn send_with_retry( pub(crate) async fn send_with_retry(
sends: Vec<SendActivityTask>, sends: Vec<SendActivityTask>,
data: &activitypub_federation::config::Data<FederationData>, data: &activitypub_federation::config::Data<FederationData>,
@@ -60,9 +79,11 @@ impl ActivityPubService {
user_repo: Arc<dyn ApUserRepository>, user_repo: Arc<dyn ApUserRepository>,
object_handler: Arc<dyn ApObjectHandler>, object_handler: Arc<dyn ApObjectHandler>,
base_url: String, base_url: String,
allow_registration: bool,
software_name: String,
debug: bool, debug: bool,
) -> anyhow::Result<Self> { ) -> anyhow::Result<Self> {
let data = FederationData::new(repo, user_repo, object_handler, base_url.clone()); let data = FederationData::new(repo, user_repo, object_handler, base_url.clone(), allow_registration, software_name);
let federation_config = ApFederationConfig::new(data, debug).await?; let federation_config = ApFederationConfig::new(data, debug).await?;
Ok(Self { Ok(Self {
federation_config, federation_config,
@@ -94,6 +115,8 @@ impl ActivityPubService {
pub fn router(&self) -> Router { pub fn router(&self) -> Router {
Router::new() Router::new()
.route("/.well-known/nodeinfo", get(nodeinfo_well_known_handler))
.route("/nodeinfo/2.0", get(nodeinfo_handler))
.route("/.well-known/webfinger", get(webfinger_handler)) .route("/.well-known/webfinger", get(webfinger_handler))
.route("/users/{id}/inbox", post(inbox_handler)) .route("/users/{id}/inbox", post(inbox_handler))
.route("/users/{id}/outbox", get(outbox_handler)) .route("/users/{id}/outbox", get(outbox_handler))
@@ -150,6 +173,7 @@ impl ActivityPubService {
inbox_url: remote_actor.inbox_url.to_string(), inbox_url: remote_actor.inbox_url.to_string(),
shared_inbox_url: None, shared_inbox_url: None,
display_name: Some(remote_actor.username.clone()), display_name: Some(remote_actor.username.clone()),
avatar_url: None,
}; };
data.federation_repo data.federation_repo
.add_following(local_user_id, remote, &follow_id_str) .add_following(local_user_id, remote, &follow_id_str)
@@ -289,7 +313,11 @@ impl ActivityPubService {
); );
} }
self.spawn_backfill(local_user_id, remote_actor.inbox_url.clone()); let target_inbox = remote_actor
.shared_inbox_url
.clone()
.unwrap_or_else(|| remote_actor.inbox_url.clone());
self.spawn_backfill(local_user_id, target_inbox);
Ok(()) Ok(())
} }
@@ -420,9 +448,30 @@ impl ActivityPubService {
.map_err(|e| anyhow::anyhow!("{e}"))?; .map_err(|e| anyhow::anyhow!("{e}"))?;
let followers = data.federation_repo.get_followers(local_user_id).await?; let followers = data.federation_repo.get_followers(local_user_id).await?;
let blocked = data
.federation_repo
.get_blocked_actors(local_user_id)
.await
.unwrap_or_default();
let blocked_set: std::collections::HashSet<String> = blocked.into_iter().collect();
let blocked_domains = data
.federation_repo
.get_blocked_domains()
.await
.unwrap_or_default();
let blocked_domain_set: std::collections::HashSet<String> =
blocked_domains.into_iter().map(|d| d.domain).collect();
let accepted: Vec<_> = followers let accepted: Vec<_> = followers
.into_iter() .into_iter()
.filter(|f| f.status == FollowerStatus::Accepted) .filter(|f| f.status == FollowerStatus::Accepted)
.filter(|f| !blocked_set.contains(&f.actor.url))
.filter(|f| {
let domain = url::Url::parse(&f.actor.inbox_url)
.ok()
.and_then(|u| u.host_str().map(|s| s.to_string()))
.unwrap_or_default();
!blocked_domain_set.contains(&domain)
})
.collect(); .collect();
if accepted.is_empty() { if accepted.is_empty() {
@@ -437,10 +486,7 @@ impl ActivityPubService {
}; };
let create_with_ctx = WithContext::new_default(create); let create_with_ctx = WithContext::new_default(create);
let inboxes: Vec<Url> = accepted let inboxes = collect_inboxes(&accepted);
.iter()
.filter_map(|f| Url::parse(&f.actor.inbox_url).ok())
.collect();
let sends = let sends =
SendActivityTask::prepare(&create_with_ctx, &local_actor, inboxes, &data).await?; SendActivityTask::prepare(&create_with_ctx, &local_actor, inboxes, &data).await?;
@@ -455,6 +501,139 @@ impl ActivityPubService {
Ok(()) Ok(())
} }
pub async fn broadcast_actor_update(&self, user_id: uuid::Uuid) -> anyhow::Result<()> {
use activitypub_federation::traits::Object;
let data = self.federation_config.to_request_data();
let local_actor = get_local_actor(user_id, &data)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
let person = local_actor.clone().into_json(&data).await
.map_err(|e| anyhow::anyhow!("{e}"))?;
let person_json = serde_json::to_value(&person)?;
let update_id = Url::parse(&format!(
"{}/activities/update/{}",
self.base_url,
uuid::Uuid::new_v4()
))?;
let update = UpdateActivity {
id: update_id,
kind: Default::default(),
actor: ObjectId::from(local_actor.ap_id.clone()),
object: person_json,
};
let followers = data.federation_repo.get_followers(user_id).await?;
let accepted: Vec<_> = followers
.into_iter()
.filter(|f| f.status == FollowerStatus::Accepted)
.collect();
if accepted.is_empty() {
return Ok(());
}
let inboxes = collect_inboxes(&accepted);
let sends = SendActivityTask::prepare(
&WithContext::new_default(update),
&local_actor,
inboxes,
&data,
)
.await?;
let failures = send_with_retry(sends, &data).await;
if !failures.is_empty() {
tracing::warn!(count = failures.len(), "actor update delivery failures");
}
Ok(())
}
pub async fn block_actor(&self, local_user_id: uuid::Uuid, actor_url: &str) -> anyhow::Result<()> {
let data = self.federation_config.to_request_data();
data.federation_repo
.add_blocked_actor(local_user_id, actor_url)
.await?;
let _ = data.federation_repo.remove_follower(local_user_id, actor_url).await;
let _ = data.federation_repo.remove_following(local_user_id, actor_url).await;
let local_actor = get_local_actor(local_user_id, &data)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
if let Ok(Some(remote_actor)) = data.federation_repo.get_remote_actor(actor_url).await {
let block_id = crate::urls::activity_url(&self.base_url)
.map_err(|e| anyhow::anyhow!("{e}"))?;
let block = crate::activities::BlockActivity {
id: block_id,
kind: Default::default(),
actor: ObjectId::from(local_actor.ap_id.clone()),
object: Url::parse(actor_url)?,
};
let inbox = Url::parse(&remote_actor.inbox_url)?;
let sends = SendActivityTask::prepare(
&WithContext::new_default(block),
&local_actor,
vec![inbox],
&data,
)
.await?;
let failures = send_with_retry(sends, &data).await;
if !failures.is_empty() {
tracing::warn!(actor = %actor_url, "failed to deliver Block activity");
}
}
Ok(())
}
pub async fn unblock_actor(&self, local_user_id: uuid::Uuid, actor_url: &str) -> anyhow::Result<()> {
let data = self.federation_config.to_request_data();
data.federation_repo
.remove_blocked_actor(local_user_id, actor_url)
.await
}
pub async fn get_blocked_actors(&self, local_user_id: uuid::Uuid) -> anyhow::Result<Vec<RemoteActor>> {
let data = self.federation_config.to_request_data();
let actor_urls = data.federation_repo.get_blocked_actors(local_user_id).await?;
let mut actors = Vec::new();
for url in actor_urls {
let actor = match data.federation_repo.get_remote_actor(&url).await {
Ok(Some(a)) => a,
_ => RemoteActor {
url: url.clone(),
handle: url.clone(),
inbox_url: url.clone(),
shared_inbox_url: None,
display_name: None,
avatar_url: None,
},
};
actors.push(actor);
}
Ok(actors)
}
pub async fn add_blocked_domain(&self, domain: &str, reason: Option<&str>) -> anyhow::Result<()> {
let data = self.federation_config.to_request_data();
data.federation_repo.add_blocked_domain(domain, reason).await
}
pub async fn remove_blocked_domain(&self, domain: &str) -> anyhow::Result<()> {
let data = self.federation_config.to_request_data();
data.federation_repo.remove_blocked_domain(domain).await
}
pub async fn get_blocked_domains(&self) -> anyhow::Result<Vec<BlockedDomain>> {
let data = self.federation_config.to_request_data();
data.federation_repo.get_blocked_domains().await
}
async fn follow_local( async fn follow_local(
&self, &self,
local_user_id: uuid::Uuid, local_user_id: uuid::Uuid,
@@ -493,6 +672,7 @@ impl ActivityPubService {
inbox_url: target_inbox_url, inbox_url: target_inbox_url,
shared_inbox_url: None, shared_inbox_url: None,
display_name: Some(target.username), display_name: Some(target.username),
avatar_url: None,
}; };
data.federation_repo data.federation_repo
.add_following(local_user_id, target_as_remote, &follow_id) .add_following(local_user_id, target_as_remote, &follow_id)
@@ -618,3 +798,47 @@ impl ActivityPubService {
Ok(()) Ok(())
} }
} }
#[cfg(test)]
mod tests {
use super::*;
use crate::repository::{Follower, FollowerStatus, RemoteActor};
fn make_follower(inbox: &str, shared: Option<&str>) -> Follower {
Follower {
actor: RemoteActor {
url: format!("https://remote/{}", inbox),
handle: "user".to_string(),
inbox_url: inbox.to_string(),
shared_inbox_url: shared.map(|s| s.to_string()),
display_name: None,
avatar_url: None,
},
status: FollowerStatus::Accepted,
}
}
#[test]
fn collect_inboxes_deduplicates_shared() {
let followers = vec![
make_follower("https://mastodon.social/users/a/inbox", Some("https://mastodon.social/inbox")),
make_follower("https://mastodon.social/users/b/inbox", Some("https://mastodon.social/inbox")),
make_follower("https://other.instance/users/c/inbox", None),
];
let inboxes = collect_inboxes(&followers);
assert_eq!(inboxes.len(), 2);
let strs: Vec<_> = inboxes.iter().map(|u| u.as_str()).collect();
assert!(strs.contains(&"https://mastodon.social/inbox"));
assert!(strs.contains(&"https://other.instance/users/c/inbox"));
}
#[test]
fn collect_inboxes_falls_back_to_individual_inbox() {
let followers = vec![
make_follower("https://example.com/users/x/inbox", None),
];
let inboxes = collect_inboxes(&followers);
assert_eq!(inboxes.len(), 1);
assert_eq!(inboxes[0].as_str(), "https://example.com/users/x/inbox");
}
}

View File

@@ -1,13 +1,18 @@
use async_trait::async_trait; use async_trait::async_trait;
use url::Url;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct ApUser { pub struct ApUser {
pub id: uuid::Uuid, pub id: uuid::Uuid,
pub username: String, pub username: String,
pub bio: Option<String>,
pub avatar_url: Option<Url>,
pub profile_url: Option<Url>,
} }
#[async_trait] #[async_trait]
pub trait ApUserRepository: Send + Sync { pub trait ApUserRepository: Send + Sync {
async fn find_by_id(&self, id: uuid::Uuid) -> anyhow::Result<Option<ApUser>>; async fn find_by_id(&self, id: uuid::Uuid) -> anyhow::Result<Option<ApUser>>;
async fn find_by_username(&self, username: &str) -> anyhow::Result<Option<ApUser>>; async fn find_by_username(&self, username: &str) -> anyhow::Result<Option<ApUser>>;
async fn count_users(&self) -> anyhow::Result<usize>;
} }

View File

@@ -46,6 +46,11 @@ impl EventHandler for ActivityPubEventHandler {
.on_review_logged(user_id, review_id) .on_review_logged(user_id, review_id)
.await .await
.map_err(|e| DomainError::InfrastructureError(e.to_string())), .map_err(|e| DomainError::InfrastructureError(e.to_string())),
DomainEvent::UserUpdated { user_id } => self
.ap_service
.broadcast_actor_update(user_id.value())
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string())),
_ => Ok(()), _ => Ok(()),
} }
} }
@@ -78,7 +83,7 @@ impl ActivityPubEventHandler {
let poster_url = movie let poster_url = movie
.as_ref() .as_ref()
.and_then(|m| m.poster_path()) .and_then(|m| m.poster_path())
.map(|p| format!("{}/posters/{}", self.base_url, p.value())); .map(|p| format!("{}/images/{}", self.base_url, p.value()));
let obj = review_to_ap_object( let obj = review_to_ap_object(
&review, &review,
@@ -87,6 +92,7 @@ impl ActivityPubEventHandler {
movie_title, movie_title,
release_year, release_year,
poster_url, poster_url,
&self.base_url,
); );
let json = serde_json::to_value(obj)?; let json = serde_json::to_value(obj)?;

View File

@@ -25,18 +25,19 @@ pub struct ActivityPubWire {
} }
pub async fn wire( pub async fn wire(
federation_repo: std::sync::Arc<dyn FederationRepository>, federation_repo: std::sync::Arc<dyn FederationRepository>,
review_store: std::sync::Arc<dyn RemoteReviewRepository>, review_store: std::sync::Arc<dyn RemoteReviewRepository>,
user_repo: std::sync::Arc<dyn domain::ports::UserRepository>, user_repo: std::sync::Arc<dyn domain::ports::UserRepository>,
movie_repo: std::sync::Arc<dyn domain::ports::MovieRepository>, movie_repo: std::sync::Arc<dyn domain::ports::MovieRepository>,
review_repo: std::sync::Arc<dyn domain::ports::ReviewRepository>, review_repo: std::sync::Arc<dyn domain::ports::ReviewRepository>,
diary_repo: std::sync::Arc<dyn domain::ports::DiaryRepository>, diary_repo: std::sync::Arc<dyn domain::ports::DiaryRepository>,
base_url: String, base_url: String,
allow_registration: bool,
) -> anyhow::Result<ActivityPubWire> { ) -> anyhow::Result<ActivityPubWire> {
let concrete = std::sync::Arc::new( let concrete = std::sync::Arc::new(
ActivityPubService::new( ActivityPubService::new(
federation_repo, federation_repo,
std::sync::Arc::new(DomainUserRepoAdapter(user_repo)), std::sync::Arc::new(DomainUserRepoAdapter::new(user_repo, base_url.clone())),
std::sync::Arc::new(ReviewObjectHandler { std::sync::Arc::new(ReviewObjectHandler {
movie_repository: std::sync::Arc::clone(&movie_repo), movie_repository: std::sync::Arc::clone(&movie_repo),
diary_repository: diary_repo, diary_repository: diary_repo,
@@ -44,6 +45,8 @@ pub async fn wire(
base_url: base_url.clone(), base_url: base_url.clone(),
}), }),
base_url.clone(), base_url.clone(),
allow_registration,
"movies-diary".to_string(),
cfg!(debug_assertions), cfg!(debug_assertions),
) )
.await?, .await?,

View File

@@ -5,6 +5,18 @@ use url::Url;
use domain::models::Review; use domain::models::Review;
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ApHashtag {
#[serde(rename = "type")]
pub(crate) kind: String,
pub(crate) href: Url,
pub(crate) name: String,
}
pub(crate) fn normalize_hashtag(title: &str) -> String {
title.chars().filter(|c| c.is_alphanumeric()).collect()
}
#[derive(Debug, Clone, Deserialize, Serialize)] #[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct ReviewObject { pub struct ReviewObject {
@@ -22,6 +34,8 @@ pub struct ReviewObject {
pub(crate) rating: u8, pub(crate) rating: u8,
pub(crate) comment: Option<String>, pub(crate) comment: Option<String>,
pub(crate) watched_at: DateTime<Utc>, pub(crate) watched_at: DateTime<Utc>,
#[serde(default)]
pub(crate) tag: Vec<ApHashtag>,
} }
/// Serialize a local Review into a ReviewObject for AP delivery. /// Serialize a local Review into a ReviewObject for AP delivery.
@@ -33,6 +47,7 @@ pub fn review_to_ap_object(
movie_title: String, movie_title: String,
release_year: u16, release_year: u16,
poster_url: Option<String>, poster_url: Option<String>,
base_url: &str,
) -> ReviewObject { ) -> ReviewObject {
let stars: String = "\u{2B50}".repeat(review.rating().value() as usize); let stars: String = "\u{2B50}".repeat(review.rating().value() as usize);
let comment_text = review.comment().map(|c| c.value().to_string()); let comment_text = review.comment().map(|c| c.value().to_string());
@@ -50,6 +65,22 @@ pub fn review_to_ap_object(
None => format!("{} {}{}\n{}", stars, movie_title, year_str, watched_str), None => format!("{} {}{}\n{}", stars, movie_title, year_str, watched_str),
}; };
let normalized = normalize_hashtag(&movie_title);
let tag = vec![
ApHashtag {
kind: "Hashtag".to_string(),
href: Url::parse(&format!("{}/tags/moviesdiary", base_url))
.expect("valid base_url"),
name: "#MoviesDiary".to_string(),
},
ApHashtag {
kind: "Hashtag".to_string(),
href: Url::parse(&format!("{}/tags/{}", base_url, normalized.to_lowercase()))
.expect("valid base_url"),
name: format!("#{}", normalized),
},
];
ReviewObject { ReviewObject {
kind: NoteType::default(), kind: NoteType::default(),
id: ap_id, id: ap_id,
@@ -62,5 +93,51 @@ pub fn review_to_ap_object(
rating: review.rating().value(), rating: review.rating().value(),
comment: comment_text, comment: comment_text,
watched_at: DateTime::from_naive_utc_and_offset(*review.watched_at(), Utc), watched_at: DateTime::from_naive_utc_and_offset(*review.watched_at(), Utc),
tag,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalize_hashtag_strips_non_alphanumeric() {
assert_eq!(normalize_hashtag("The Dark Knight"), "TheDarkKnight");
assert_eq!(normalize_hashtag("Schindler's List"), "SchindlersList");
assert_eq!(normalize_hashtag("2001: A Space Odyssey"), "2001ASpaceOdyssey");
}
#[test]
fn review_to_ap_object_includes_two_hashtags() {
use chrono::NaiveDateTime;
use domain::{
models::{Review, ReviewSource},
value_objects::{MovieId, Rating, ReviewId, UserId},
};
let review = Review::from_persistence(
ReviewId::generate(),
MovieId::from_uuid(uuid::Uuid::new_v4()),
UserId::from_uuid(uuid::Uuid::new_v4()),
Rating::new(4).unwrap(),
None,
NaiveDateTime::parse_from_str("2024-01-01 00:00:00", "%Y-%m-%d %H:%M:%S").unwrap(),
NaiveDateTime::parse_from_str("2024-01-01 00:00:00", "%Y-%m-%d %H:%M:%S").unwrap(),
ReviewSource::Local,
);
let obj = review_to_ap_object(
&review,
"https://example.com/reviews/1".parse().unwrap(),
"https://example.com/users/1".parse().unwrap(),
"Dune".to_string(),
2021,
None,
"https://example.com",
);
assert_eq!(obj.tag.len(), 2);
let names: Vec<&str> = obj.tag.iter().map(|t| t.name.as_str()).collect();
assert!(names.contains(&"#MoviesDiary"));
assert!(names.contains(&"#Dune"));
} }
} }

View File

@@ -1,7 +1,7 @@
use async_trait::async_trait; use async_trait::async_trait;
use uuid::Uuid; use uuid::Uuid;
use activitypub_base::{ActivityPubService, RemoteActor}; use activitypub_base::{ActivityPubService, BlockedDomain, RemoteActor};
#[async_trait] #[async_trait]
pub trait ActivityPubPort: Send + Sync { pub trait ActivityPubPort: Send + Sync {
@@ -25,6 +25,12 @@ pub trait ActivityPubPort: Send + Sync {
async fn get_accepted_followers(&self, local_user_id: Uuid) async fn get_accepted_followers(&self, local_user_id: Uuid)
-> anyhow::Result<Vec<RemoteActor>>; -> anyhow::Result<Vec<RemoteActor>>;
async fn remove_follower(&self, local_user_id: Uuid, actor_url: &str) -> anyhow::Result<()>; async fn remove_follower(&self, local_user_id: Uuid, actor_url: &str) -> anyhow::Result<()>;
async fn block_actor(&self, local_user_id: Uuid, actor_url: &str) -> anyhow::Result<()>;
async fn unblock_actor(&self, local_user_id: Uuid, actor_url: &str) -> anyhow::Result<()>;
async fn get_blocked_actors(&self, local_user_id: Uuid) -> anyhow::Result<Vec<RemoteActor>>;
async fn add_blocked_domain(&self, domain: &str, reason: Option<&str>) -> anyhow::Result<()>;
async fn remove_blocked_domain(&self, domain: &str) -> anyhow::Result<()>;
async fn get_blocked_domains(&self) -> anyhow::Result<Vec<BlockedDomain>>;
} }
#[async_trait] #[async_trait]
@@ -73,6 +79,24 @@ impl ActivityPubPort for ActivityPubService {
async fn remove_follower(&self, local_user_id: Uuid, actor_url: &str) -> anyhow::Result<()> { async fn remove_follower(&self, local_user_id: Uuid, actor_url: &str) -> anyhow::Result<()> {
self.remove_follower(local_user_id, actor_url).await self.remove_follower(local_user_id, actor_url).await
} }
async fn block_actor(&self, local_user_id: Uuid, actor_url: &str) -> anyhow::Result<()> {
self.block_actor(local_user_id, actor_url).await
}
async fn unblock_actor(&self, local_user_id: Uuid, actor_url: &str) -> anyhow::Result<()> {
self.unblock_actor(local_user_id, actor_url).await
}
async fn get_blocked_actors(&self, local_user_id: Uuid) -> anyhow::Result<Vec<RemoteActor>> {
self.get_blocked_actors(local_user_id).await
}
async fn add_blocked_domain(&self, domain: &str, reason: Option<&str>) -> anyhow::Result<()> {
self.add_blocked_domain(domain, reason).await
}
async fn remove_blocked_domain(&self, domain: &str) -> anyhow::Result<()> {
self.remove_blocked_domain(domain).await
}
async fn get_blocked_domains(&self) -> anyhow::Result<Vec<BlockedDomain>> {
self.get_blocked_domains().await
}
} }
pub struct NoopActivityPubService; pub struct NoopActivityPubService;
@@ -112,4 +136,22 @@ impl ActivityPubPort for NoopActivityPubService {
async fn remove_follower(&self, _: Uuid, _: &str) -> anyhow::Result<()> { async fn remove_follower(&self, _: Uuid, _: &str) -> anyhow::Result<()> {
Ok(()) Ok(())
} }
async fn block_actor(&self, _: Uuid, _: &str) -> anyhow::Result<()> {
Ok(())
}
async fn unblock_actor(&self, _: Uuid, _: &str) -> anyhow::Result<()> {
Ok(())
}
async fn get_blocked_actors(&self, _: Uuid) -> anyhow::Result<Vec<RemoteActor>> {
Ok(vec![])
}
async fn add_blocked_domain(&self, _: &str, _: Option<&str>) -> anyhow::Result<()> {
Ok(())
}
async fn remove_blocked_domain(&self, _: &str) -> anyhow::Result<()> {
Ok(())
}
async fn get_blocked_domains(&self) -> anyhow::Result<Vec<BlockedDomain>> {
Ok(vec![])
}
} }

View File

@@ -59,7 +59,7 @@ impl ApObjectHandler for ReviewObjectHandler {
let poster_url = movie let poster_url = movie
.as_ref() .as_ref()
.and_then(|m| m.poster_path()) .and_then(|m| m.poster_path())
.map(|p| format!("{}/posters/{}", self.base_url, p.value())); .map(|p| format!("{}/images/{}", self.base_url, p.value()));
let obj = review_to_ap_object( let obj = review_to_ap_object(
review, review,
@@ -68,6 +68,7 @@ impl ApObjectHandler for ReviewObjectHandler {
movie_title, movie_title,
release_year, release_year,
poster_url, poster_url,
&self.base_url,
); );
let json = serde_json::to_value(obj)?; let json = serde_json::to_value(obj)?;
results.push((ap_id, json)); results.push((ap_id, json));
@@ -75,6 +76,74 @@ impl ApObjectHandler for ReviewObjectHandler {
Ok(results) Ok(results)
} }
async fn get_local_objects_page(
&self,
user_id: uuid::Uuid,
before: Option<chrono::DateTime<chrono::Utc>>,
limit: usize,
) -> anyhow::Result<Vec<(url::Url, serde_json::Value, chrono::DateTime<chrono::Utc>)>> {
use domain::value_objects::UserId;
let domain_user_id = UserId::from_uuid(user_id);
let history = self
.diary_repository
.get_user_history(&domain_user_id)
.await?;
let mut results = Vec::new();
for entry in history {
let review = entry.review();
if !matches!(review.source(), ReviewSource::Local) {
continue;
}
let published =
chrono::DateTime::from_naive_utc_and_offset(*review.watched_at(), chrono::Utc);
if let Some(cutoff) = before {
if published >= cutoff {
continue;
}
}
let ap_id = review_url(&self.base_url, review.id());
let actor_url = actor_url(&self.base_url, user_id);
let movie = self
.movie_repository
.get_movie_by_id(review.movie_id())
.await
.ok()
.flatten();
let movie_title = movie
.as_ref()
.map(|m| m.title().value().to_string())
.unwrap_or_else(|| "Unknown".to_string());
let release_year = movie.as_ref().map(|m| m.release_year().value()).unwrap_or(0);
let poster_url = movie
.as_ref()
.and_then(|m| m.poster_path())
.map(|p| format!("{}/images/{}", self.base_url, p.value()));
let obj = review_to_ap_object(
review,
ap_id.clone(),
actor_url,
movie_title,
release_year,
poster_url,
&self.base_url,
);
let json = serde_json::to_value(obj)?;
results.push((ap_id, json, published));
if results.len() >= limit {
break;
}
}
Ok(results)
}
async fn on_create( async fn on_create(
&self, &self,
_ap_id: &Url, _ap_id: &Url,
@@ -168,4 +237,11 @@ impl ApObjectHandler for ReviewObjectHandler {
async fn on_actor_removed(&self, actor_url: &Url) -> anyhow::Result<()> { async fn on_actor_removed(&self, actor_url: &Url) -> anyhow::Result<()> {
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.diary_repository
.count_local_posts()
.await
.map_err(|e| anyhow::anyhow!(e.to_string()))
}
} }

View File

@@ -3,26 +3,49 @@ use std::sync::Arc;
use activitypub_base::{ApUser, ApUserRepository}; use activitypub_base::{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 url::Url;
pub struct DomainUserRepoAdapter(pub Arc<dyn UserRepository>); pub struct DomainUserRepoAdapter {
pub repo: Arc<dyn UserRepository>,
pub base_url: String,
}
impl DomainUserRepoAdapter {
pub fn new(repo: Arc<dyn UserRepository>, base_url: String) -> Self {
Self { repo, base_url }
}
fn build_user(&self, u: &domain::models::User) -> ApUser {
let avatar_url = u.avatar_path().and_then(|p| {
Url::parse(&format!("{}/images/{}", self.base_url, p)).ok()
});
let profile_url = Url::parse(&format!("{}/u/{}", self.base_url, u.username().value())).ok();
ApUser {
id: u.id().value(),
username: u.username().value().to_string(),
bio: u.bio().map(|s| s.to_string()),
avatar_url,
profile_url,
}
}
}
#[async_trait] #[async_trait]
impl ApUserRepository for DomainUserRepoAdapter { impl ApUserRepository for DomainUserRepoAdapter {
async fn find_by_id(&self, id: uuid::Uuid) -> anyhow::Result<Option<ApUser>> { async fn find_by_id(&self, id: uuid::Uuid) -> anyhow::Result<Option<ApUser>> {
let user_id = UserId::from_uuid(id); let user_id = UserId::from_uuid(id);
Ok(self.0.find_by_id(&user_id).await?.map(|u| ApUser { Ok(self.repo.find_by_id(&user_id).await?.as_ref().map(|u| self.build_user(u)))
id: u.id().value(),
username: u.username().value().to_string(),
}))
} }
async fn find_by_username(&self, username: &str) -> anyhow::Result<Option<ApUser>> { async fn find_by_username(&self, username: &str) -> anyhow::Result<Option<ApUser>> {
use domain::value_objects::Username; use domain::value_objects::Username;
let uname = let uname = Username::new(username.to_string()).map_err(|e| anyhow::anyhow!(e.to_string()))?;
Username::new(username.to_string()).map_err(|e| anyhow::anyhow!(e.to_string()))?; Ok(self.repo.find_by_username(&uname).await?.as_ref().map(|u| self.build_user(u)))
Ok(self.0.find_by_username(&uname).await?.map(|u| ApUser { }
id: u.id().value(),
username: u.username().value().to_string(), async fn count_users(&self) -> anyhow::Result<usize> {
})) Ok(self.repo.list_with_stats().await
.map_err(|e| anyhow::anyhow!(e.to_string()))?
.len())
} }
} }

View File

@@ -32,6 +32,9 @@ pub enum EventPayload {
movie_id: String, movie_id: String,
poster_path: Option<String>, poster_path: Option<String>,
}, },
UserUpdated {
user_id: String,
},
} }
impl EventPayload { impl EventPayload {
@@ -41,6 +44,7 @@ impl EventPayload {
EventPayload::ReviewUpdated { .. } => "ReviewUpdated", EventPayload::ReviewUpdated { .. } => "ReviewUpdated",
EventPayload::MovieDiscovered { .. } => "MovieDiscovered", EventPayload::MovieDiscovered { .. } => "MovieDiscovered",
EventPayload::MovieDeleted { .. } => "MovieDeleted", EventPayload::MovieDeleted { .. } => "MovieDeleted",
EventPayload::UserUpdated { .. } => "UserUpdated",
} }
} }
} }
@@ -87,6 +91,9 @@ impl From<&DomainEvent> for EventPayload {
movie_id: movie_id.value().to_string(), movie_id: movie_id.value().to_string(),
poster_path: poster_path.as_ref().map(|p| p.value().to_string()), poster_path: poster_path.as_ref().map(|p| p.value().to_string()),
}, },
DomainEvent::UserUpdated { user_id } => EventPayload::UserUpdated {
user_id: user_id.value().to_string(),
},
} }
} }
} }
@@ -127,6 +134,11 @@ impl TryFrom<EventPayload> for DomainEvent {
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?; .map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
Ok(DomainEvent::MovieDeleted { movie_id, poster_path }) Ok(DomainEvent::MovieDeleted { movie_id, poster_path })
} }
EventPayload::UserUpdated { user_id } => {
Ok(DomainEvent::UserUpdated {
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
})
}
} }
} }
} }

View File

@@ -1,5 +1,5 @@
[package] [package]
name = "poster-storage" name = "image-storage"
version = "0.1.0" version = "0.1.0"
edition = "2024" edition = "2024"

View File

@@ -6,8 +6,8 @@ pub struct StorageConfig(Arc<dyn ObjectStore>);
impl StorageConfig { impl StorageConfig {
pub fn from_env() -> anyhow::Result<Self> { pub fn from_env() -> anyhow::Result<Self> {
let backend = std::env::var("POSTER_STORAGE_BACKEND") let backend = std::env::var("IMAGE_STORAGE_BACKEND")
.context("POSTER_STORAGE_BACKEND required (valid values: s3, local)")?; .context("IMAGE_STORAGE_BACKEND required (valid values: s3, local)")?;
let store: Arc<dyn ObjectStore> = match backend.as_str() { let store: Arc<dyn ObjectStore> = match backend.as_str() {
"s3" => build_s3_store( "s3" => build_s3_store(
@@ -19,11 +19,11 @@ impl StorageConfig {
&std::env::var("MINIO_REGION").unwrap_or_else(|_| "minio".to_string()), &std::env::var("MINIO_REGION").unwrap_or_else(|_| "minio".to_string()),
)?, )?,
"local" => build_local_store( "local" => build_local_store(
&std::env::var("POSTER_STORAGE_PATH") &std::env::var("IMAGE_STORAGE_PATH")
.context("POSTER_STORAGE_PATH required when POSTER_STORAGE_BACKEND=local")?, .context("IMAGE_STORAGE_PATH required when IMAGE_STORAGE_BACKEND=local")?,
)?, )?,
other => { other => {
anyhow::bail!("Unknown POSTER_STORAGE_BACKEND: {other:?}. Valid values: s3, local") anyhow::bail!("Unknown IMAGE_STORAGE_BACKEND: {other:?}. Valid values: s3, local")
} }
}; };
@@ -55,7 +55,7 @@ fn build_s3_store(
} }
fn build_local_store(path: &str) -> anyhow::Result<Arc<dyn ObjectStore>> { fn build_local_store(path: &str) -> anyhow::Result<Arc<dyn ObjectStore>> {
std::fs::create_dir_all(path).context("Failed to create poster storage directory")?; std::fs::create_dir_all(path).context("Failed to create image storage directory")?;
let store = LocalFileSystem::new_with_prefix(path) let store = LocalFileSystem::new_with_prefix(path)
.context("Failed to initialise local file system store")?; .context("Failed to initialise local file system store")?;
Ok(Arc::new(store)) Ok(Arc::new(store))
@@ -67,7 +67,7 @@ mod tests {
#[test] #[test]
fn local_store_creates_dir_and_succeeds() { fn local_store_creates_dir_and_succeeds() {
let dir = std::env::temp_dir().join(format!("poster_test_{}", uuid::Uuid::new_v4())); let dir = std::env::temp_dir().join(format!("image_test_{}", uuid::Uuid::new_v4()));
let result = build_local_store(dir.to_str().unwrap()); let result = build_local_store(dir.to_str().unwrap());
assert!(result.is_ok(), "expected Ok, got: {:?}", result.err()); assert!(result.is_ok(), "expected Ok, got: {:?}", result.err());
assert!(dir.exists(), "directory should have been created"); assert!(dir.exists(), "directory should have been created");
@@ -75,7 +75,7 @@ mod tests {
#[test] #[test]
fn local_store_succeeds_if_dir_already_exists() { fn local_store_succeeds_if_dir_already_exists() {
let dir = std::env::temp_dir().join(format!("poster_test_{}", uuid::Uuid::new_v4())); let dir = std::env::temp_dir().join(format!("image_test_{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&dir).unwrap(); std::fs::create_dir_all(&dir).unwrap();
let result = build_local_store(dir.to_str().unwrap()); let result = build_local_store(dir.to_str().unwrap());
assert!(result.is_ok()); assert!(result.is_ok());

View File

@@ -0,0 +1,157 @@
mod config;
pub use config::StorageConfig;
use async_trait::async_trait;
use domain::{
errors::DomainError,
events::DomainEvent,
ports::{EventHandler, ImageStorage},
};
use object_store::{Attribute, Attributes, ObjectStore, PutOptions, path::Path};
use std::sync::Arc;
fn detect_mime(bytes: &[u8]) -> &'static str {
infer::get(bytes)
.map(|t| t.mime_type())
.unwrap_or("application/octet-stream")
}
pub struct ImageStorageAdapter {
store: Arc<dyn ObjectStore>,
}
impl ImageStorageAdapter {
pub fn new(store: Arc<dyn ObjectStore>) -> Self {
Self { store }
}
pub fn from_config(config: StorageConfig) -> Self {
Self::new(config.build_store())
}
}
#[async_trait]
impl ImageStorage for ImageStorageAdapter {
async fn store(&self, key: &str, image_bytes: &[u8]) -> Result<String, DomainError> {
let path = Path::from(key);
let mime = detect_mime(image_bytes);
let mut attributes = Attributes::new();
attributes.insert(Attribute::ContentType, mime.into());
let opts = PutOptions { attributes, ..Default::default() };
self.store
.put_opts(&path, image_bytes.to_vec().into(), opts)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
Ok(key.to_string())
}
async fn get(&self, key: &str) -> Result<Vec<u8>, DomainError> {
let path = Path::from(key);
let result = self.store.get(&path).await.map_err(|e| match e {
object_store::Error::NotFound { .. } => DomainError::NotFound("Image not found".into()),
_ => DomainError::InfrastructureError(e.to_string()),
})?;
result
.bytes()
.await
.map(|b| b.to_vec())
.map_err(|e| DomainError::InfrastructureError(e.to_string()))
}
async fn delete(&self, key: &str) -> Result<(), DomainError> {
let path = Path::from(key);
match self.store.delete(&path).await {
Ok(()) => Ok(()),
Err(object_store::Error::NotFound { .. }) => Ok(()),
Err(e) => Err(DomainError::InfrastructureError(e.to_string())),
}
}
}
pub struct ImageCleanupHandler {
image_storage: Arc<dyn ImageStorage>,
}
impl ImageCleanupHandler {
pub fn new(image_storage: Arc<dyn ImageStorage>) -> Self {
Self { image_storage }
}
}
#[async_trait]
impl EventHandler for ImageCleanupHandler {
async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError> {
let poster_path = match event {
DomainEvent::MovieDeleted { poster_path, .. } => poster_path,
_ => return Ok(()),
};
let Some(path) = poster_path else { return Ok(()) };
if let Err(e) = self.image_storage.delete(path.value()).await {
tracing::warn!("image cleanup failed for {}: {e}", path.value());
}
Ok(())
}
}
pub fn create() -> anyhow::Result<Arc<dyn ImageStorage>> {
Ok(Arc::new(ImageStorageAdapter::from_config(StorageConfig::from_env()?)))
}
#[cfg(test)]
mod tests {
use super::*;
use object_store::memory::InMemory;
fn adapter() -> ImageStorageAdapter {
ImageStorageAdapter::new(Arc::new(InMemory::new()))
}
#[tokio::test]
async fn store_and_retrieve_round_trip() {
let adapter = adapter();
let bytes = b"fake-image-bytes";
let path = adapter.store("posters/abc123", bytes).await.unwrap();
assert_eq!(path, "posters/abc123");
let retrieved = adapter.get("posters/abc123").await.unwrap();
assert_eq!(retrieved, bytes);
}
#[tokio::test]
async fn get_missing_returns_not_found() {
let adapter = adapter();
let result = adapter.get("nonexistent").await;
assert!(matches!(result, Err(DomainError::NotFound(_))));
}
#[tokio::test]
async fn delete_removes_key() {
let adapter = adapter();
adapter.store("avatars/user1", b"img").await.unwrap();
adapter.delete("avatars/user1").await.unwrap();
let result = adapter.get("avatars/user1").await;
assert!(matches!(result, Err(DomainError::NotFound(_))));
}
#[tokio::test]
async fn delete_missing_returns_ok() {
let adapter = adapter();
assert!(adapter.delete("does-not-exist").await.is_ok());
}
#[tokio::test]
async fn cleanup_handler_deletes_on_movie_deleted() {
use domain::{events::DomainEvent, value_objects::{MovieId, PosterPath}};
let inner = Arc::new(adapter());
inner.store("some-uuid", b"img").await.unwrap();
let path = PosterPath::new("some-uuid".to_string()).unwrap();
let handler = ImageCleanupHandler::new(Arc::clone(&inner) as Arc<dyn ImageStorage>);
handler
.handle(&DomainEvent::MovieDeleted {
movie_id: MovieId::from_uuid(uuid::Uuid::new_v4()),
poster_path: Some(path.clone()),
})
.await
.unwrap();
assert!(matches!(inner.get("some-uuid").await, Err(DomainError::NotFound(_))));
}
}

View File

@@ -6,6 +6,7 @@ pub fn event_to_subject(prefix: &str, event: &DomainEvent) -> String {
DomainEvent::ReviewUpdated { .. } => "review.updated", DomainEvent::ReviewUpdated { .. } => "review.updated",
DomainEvent::MovieDiscovered { .. } => "movie.discovered", DomainEvent::MovieDiscovered { .. } => "movie.discovered",
DomainEvent::MovieDeleted { .. } => "movie.deleted", DomainEvent::MovieDeleted { .. } => "movie.deleted",
DomainEvent::UserUpdated { .. } => "user.updated",
}; };
format!("{prefix}.{suffix}") format!("{prefix}.{suffix}")
} }

View File

@@ -1,204 +0,0 @@
mod config;
pub use config::StorageConfig;
use async_trait::async_trait;
use domain::{
errors::DomainError,
events::DomainEvent,
ports::{EventHandler, PosterStorage},
value_objects::{MovieId, PosterPath},
};
use object_store::{Attribute, Attributes, ObjectStore, PutOptions, path::Path};
use std::sync::Arc;
fn detect_mime(bytes: &[u8]) -> &'static str {
infer::get(bytes)
.map(|t| t.mime_type())
.unwrap_or("application/octet-stream")
}
pub struct PosterStorageAdapter {
store: Arc<dyn ObjectStore>,
}
impl PosterStorageAdapter {
pub fn new(store: Arc<dyn ObjectStore>) -> Self {
Self { store }
}
pub fn from_config(config: StorageConfig) -> Self {
Self::new(config.build_store())
}
}
#[async_trait]
impl PosterStorage for PosterStorageAdapter {
async fn store_poster(
&self,
movie_id: &MovieId,
image_bytes: &[u8],
) -> Result<PosterPath, DomainError> {
let path = Path::from(movie_id.value().to_string());
let mime = detect_mime(image_bytes);
let mut attributes = Attributes::new();
attributes.insert(Attribute::ContentType, mime.into());
let opts = PutOptions {
attributes,
..Default::default()
};
self.store
.put_opts(&path, image_bytes.to_vec().into(), opts)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
PosterPath::new(path.to_string())
}
async fn delete_poster(&self, path: &PosterPath) -> Result<(), DomainError> {
let p = Path::from(path.value().to_string());
match self.store.delete(&p).await {
Ok(()) => Ok(()),
Err(object_store::Error::NotFound { .. }) => Ok(()),
Err(e) => Err(DomainError::InfrastructureError(e.to_string())),
}
}
async fn get_poster(&self, poster_path: &PosterPath) -> Result<Vec<u8>, DomainError> {
let path = Path::from(poster_path.value().to_string());
let result = self.store.get(&path).await.map_err(|e| match e {
object_store::Error::NotFound { .. } => {
DomainError::NotFound("Poster not found".into())
}
_ => DomainError::InfrastructureError(e.to_string()),
})?;
result
.bytes()
.await
.map(|b| b.to_vec())
.map_err(|e| DomainError::InfrastructureError(e.to_string()))
}
}
pub struct PosterCleanupHandler {
poster_storage: Arc<dyn PosterStorage>,
}
impl PosterCleanupHandler {
pub fn new(poster_storage: Arc<dyn PosterStorage>) -> Self {
Self { poster_storage }
}
}
#[async_trait]
impl EventHandler for PosterCleanupHandler {
async fn handle(&self, event: &DomainEvent) -> Result<(), DomainError> {
let poster_path = match event {
DomainEvent::MovieDeleted { poster_path, .. } => poster_path,
_ => return Ok(()),
};
let Some(path) = poster_path else { return Ok(()) };
if let Err(e) = self.poster_storage.delete_poster(path).await {
tracing::warn!("poster cleanup failed for {}: {e}", path.value());
}
Ok(())
}
}
pub fn create() -> anyhow::Result<std::sync::Arc<dyn domain::ports::PosterStorage>> {
Ok(std::sync::Arc::new(PosterStorageAdapter::from_config(StorageConfig::from_env()?)))
}
#[cfg(test)]
mod tests {
use super::*;
use object_store::memory::InMemory;
use uuid::Uuid;
fn adapter() -> PosterStorageAdapter {
PosterStorageAdapter::new(Arc::new(InMemory::new()))
}
#[tokio::test]
async fn store_and_retrieve_round_trip() {
let adapter = adapter();
let movie_id = MovieId::from_uuid(Uuid::new_v4());
let bytes = b"fake-image-bytes";
let path = adapter.store_poster(&movie_id, bytes).await.unwrap();
let retrieved = adapter.get_poster(&path).await.unwrap();
assert_eq!(retrieved, bytes);
}
#[tokio::test]
async fn get_missing_returns_not_found() {
let adapter = adapter();
let path = PosterPath::new("nonexistent".into()).unwrap();
let result = adapter.get_poster(&path).await;
assert!(matches!(result, Err(DomainError::NotFound(_))));
}
#[tokio::test]
async fn delete_poster_removes_file() {
let adapter = adapter();
let movie_id = MovieId::from_uuid(Uuid::new_v4());
let path = adapter.store_poster(&movie_id, b"img").await.unwrap();
adapter.delete_poster(&path).await.unwrap();
let result = adapter.get_poster(&path).await;
assert!(matches!(result, Err(DomainError::NotFound(_))));
}
#[tokio::test]
async fn delete_poster_missing_file_returns_ok() {
let adapter = adapter();
let path = PosterPath::new("does-not-exist".into()).unwrap();
assert!(adapter.delete_poster(&path).await.is_ok());
}
#[tokio::test]
async fn cleanup_handler_deletes_poster_on_movie_deleted() {
use domain::{events::DomainEvent, ports::EventHandler};
let inner = Arc::new(adapter());
let path = inner
.store_poster(&MovieId::from_uuid(Uuid::new_v4()), b"img")
.await
.unwrap();
let movie_id = MovieId::from_uuid(Uuid::new_v4());
let handler = PosterCleanupHandler::new(Arc::clone(&inner) as Arc<dyn PosterStorage>);
handler
.handle(&DomainEvent::MovieDeleted { movie_id, poster_path: Some(path.clone()) })
.await
.unwrap();
assert!(matches!(inner.get_poster(&path).await, Err(DomainError::NotFound(_))));
}
#[tokio::test]
async fn cleanup_handler_ignores_none_poster_path() {
use domain::{events::DomainEvent, ports::EventHandler};
let inner = Arc::new(adapter());
let handler = PosterCleanupHandler::new(Arc::clone(&inner) as Arc<dyn PosterStorage>);
let event = DomainEvent::MovieDeleted {
movie_id: MovieId::from_uuid(Uuid::new_v4()),
poster_path: None,
};
handler.handle(&event).await.unwrap();
}
#[tokio::test]
async fn cleanup_handler_ignores_other_events() {
use domain::{events::DomainEvent, ports::EventHandler, value_objects::ExternalMetadataId};
let inner = Arc::new(adapter());
let handler = PosterCleanupHandler::new(Arc::clone(&inner) as Arc<dyn PosterStorage>);
let event = DomainEvent::MovieDiscovered {
movie_id: MovieId::from_uuid(Uuid::new_v4()),
external_metadata_id: ExternalMetadataId::new("tt1234567".to_string()).unwrap(),
};
handler.handle(&event).await.unwrap();
}
}

View File

@@ -4,15 +4,15 @@ use async_trait::async_trait;
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
events::DomainEvent, events::DomainEvent,
ports::{EventHandler, MetadataClient, MovieRepository, PosterFetcherClient, PosterStorage}, ports::{EventHandler, ImageStorage, MetadataClient, MovieRepository, PosterFetcherClient},
value_objects::{ExternalMetadataId, MovieId}, value_objects::{ExternalMetadataId, MovieId, PosterPath},
}; };
pub struct PosterSyncHandler { pub struct PosterSyncHandler {
movie_repository: Arc<dyn MovieRepository>, movie_repository: Arc<dyn MovieRepository>,
metadata_client: Arc<dyn MetadataClient>, metadata_client: Arc<dyn MetadataClient>,
poster_fetcher: Arc<dyn PosterFetcherClient>, poster_fetcher: Arc<dyn PosterFetcherClient>,
poster_storage: Arc<dyn PosterStorage>, image_storage: Arc<dyn ImageStorage>,
max_retries: u32, max_retries: u32,
} }
@@ -21,10 +21,10 @@ impl PosterSyncHandler {
movie_repository: Arc<dyn MovieRepository>, movie_repository: Arc<dyn MovieRepository>,
metadata_client: Arc<dyn MetadataClient>, metadata_client: Arc<dyn MetadataClient>,
poster_fetcher: Arc<dyn PosterFetcherClient>, poster_fetcher: Arc<dyn PosterFetcherClient>,
poster_storage: Arc<dyn PosterStorage>, image_storage: Arc<dyn ImageStorage>,
max_retries: u32, max_retries: u32,
) -> Self { ) -> Self {
Self { movie_repository, metadata_client, poster_fetcher, poster_storage, max_retries } Self { movie_repository, metadata_client, poster_fetcher, image_storage, max_retries }
} }
async fn sync(&self, movie_id: MovieId, external_metadata_id: ExternalMetadataId) -> Result<(), DomainError> { async fn sync(&self, movie_id: MovieId, external_metadata_id: ExternalMetadataId) -> Result<(), DomainError> {
@@ -46,9 +46,10 @@ impl PosterSyncHandler {
}; };
let image_bytes = self.poster_fetcher.fetch_poster_bytes(&poster_url).await?; let image_bytes = self.poster_fetcher.fetch_poster_bytes(&poster_url).await?;
let stored_path = self.poster_storage.store_poster(&movie_id, &image_bytes).await?; let stored_path = self.image_storage.store(&movie_id.value().to_string(), &image_bytes).await?;
let poster_path = PosterPath::new(stored_path)?;
movie.update_poster(stored_path); movie.update_poster(poster_path);
self.movie_repository.upsert_movie(&movie).await self.movie_repository.upsert_movie(&movie).await
} }
} }

View File

@@ -5,7 +5,7 @@ use sqlx::{PgPool, Row};
use activitypub::RemoteReviewRepository; use activitypub::RemoteReviewRepository;
use activitypub_base::{ use activitypub_base::{
FederationRepository, Follower, FollowerStatus, FollowingStatus, RemoteActor, BlockedDomain, FederationRepository, Follower, FollowerStatus, FollowingStatus, RemoteActor,
}; };
use domain::models::{Review, ReviewSource}; use domain::models::{Review, ReviewSource};
@@ -103,7 +103,7 @@ impl FederationRepository for PostgresFederationRepository {
let uid = local_user_id.to_string(); let uid = local_user_id.to_string();
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.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.avatar_url
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 = $1", WHERE f.local_user_id = $1",
@@ -118,8 +118,9 @@ impl FederationRepository for PostgresFederationRepository {
let inbox_url: String = row.try_get("inbox_url").unwrap_or_default(); let 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 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 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 { url, handle, inbox_url, shared_inbox_url, display_name }, actor: RemoteActor { url, handle, inbox_url, shared_inbox_url, display_name, avatar_url },
status: str_to_status(&status_str), status: str_to_status(&status_str),
} }
}).collect()) }).collect())
@@ -200,7 +201,7 @@ 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 rows = sqlx::query(
"SELECT a.url, a.handle, a.inbox_url, a.shared_inbox_url, a.display_name "SELECT a.url, a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.avatar_url
FROM ap_following f 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'",
@@ -214,6 +215,7 @@ impl FederationRepository for PostgresFederationRepository {
inbox_url: row.get("inbox_url"), inbox_url: row.get("inbox_url"),
shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(), shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(),
display_name: row.try_get("display_name").ok().flatten(), display_name: row.try_get("display_name").ok().flatten(),
avatar_url: row.try_get("avatar_url").ok().flatten(),
}).collect()) }).collect())
} }
@@ -232,13 +234,14 @@ impl FederationRepository for PostgresFederationRepository {
let now = Utc::now().naive_utc(); let now = Utc::now().naive_utc();
let fetched_at = datetime_to_str(&now); let fetched_at = datetime_to_str(&now);
sqlx::query( sqlx::query(
"INSERT INTO ap_remote_actors (url, handle, inbox_url, shared_inbox_url, display_name, fetched_at) "INSERT INTO ap_remote_actors (url, handle, inbox_url, shared_inbox_url, display_name, avatar_url, fetched_at)
VALUES ($1, $2, $3, $4, $5, $6::timestamptz) VALUES ($1, $2, $3, $4, $5, $6, $7::timestamptz)
ON CONFLICT(url) DO UPDATE SET ON CONFLICT(url) DO UPDATE SET
handle = EXCLUDED.handle, handle = EXCLUDED.handle,
inbox_url = EXCLUDED.inbox_url, inbox_url = EXCLUDED.inbox_url,
shared_inbox_url = EXCLUDED.shared_inbox_url, shared_inbox_url = EXCLUDED.shared_inbox_url,
display_name = EXCLUDED.display_name, display_name = EXCLUDED.display_name,
avatar_url = EXCLUDED.avatar_url,
fetched_at = EXCLUDED.fetched_at", fetched_at = EXCLUDED.fetched_at",
) )
.bind(&actor.url) .bind(&actor.url)
@@ -246,6 +249,7 @@ impl FederationRepository for PostgresFederationRepository {
.bind(&actor.inbox_url) .bind(&actor.inbox_url)
.bind(&actor.shared_inbox_url) .bind(&actor.shared_inbox_url)
.bind(&actor.display_name) .bind(&actor.display_name)
.bind(&actor.avatar_url)
.bind(&fetched_at) .bind(&fetched_at)
.execute(&self.pool) .execute(&self.pool)
.await?; .await?;
@@ -254,7 +258,7 @@ impl FederationRepository for PostgresFederationRepository {
async fn get_remote_actor(&self, actor_url: &str) -> Result<Option<RemoteActor>> { async fn get_remote_actor(&self, actor_url: &str) -> Result<Option<RemoteActor>> {
let row = sqlx::query( let row = sqlx::query(
"SELECT url, handle, inbox_url, shared_inbox_url, display_name "SELECT url, handle, inbox_url, shared_inbox_url, display_name, avatar_url
FROM ap_remote_actors WHERE url = $1", FROM ap_remote_actors WHERE url = $1",
) )
.bind(actor_url) .bind(actor_url)
@@ -266,6 +270,7 @@ impl FederationRepository for PostgresFederationRepository {
inbox_url: row.get("inbox_url"), inbox_url: row.get("inbox_url"),
shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(), shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(),
display_name: row.try_get("display_name").ok().flatten(), display_name: row.try_get("display_name").ok().flatten(),
avatar_url: row.try_get("avatar_url").ok().flatten(),
})) }))
} }
@@ -306,7 +311,7 @@ impl FederationRepository for PostgresFederationRepository {
async fn get_pending_followers(&self, local_user_id: uuid::Uuid) -> Result<Vec<RemoteActor>> { async fn get_pending_followers(&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 rows = sqlx::query(
"SELECT f.remote_actor_url, a.handle, a.inbox_url, a.shared_inbox_url, a.display_name "SELECT f.remote_actor_url, a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.avatar_url
FROM ap_followers f 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 = $1 AND f.status = 'pending'", WHERE f.local_user_id = $1 AND f.status = 'pending'",
@@ -320,6 +325,7 @@ impl FederationRepository for PostgresFederationRepository {
inbox_url: row.try_get("inbox_url").unwrap_or_default(), inbox_url: row.try_get("inbox_url").unwrap_or_default(),
shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(), shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(),
display_name: row.try_get("display_name").ok().flatten(), display_name: row.try_get("display_name").ok().flatten(),
avatar_url: row.try_get("avatar_url").ok().flatten(),
}).collect()) }).collect())
} }
@@ -347,6 +353,134 @@ impl FederationRepository for PostgresFederationRepository {
} }
Ok(()) Ok(())
} }
async fn add_announce(
&self,
activity_id: &str,
object_url: &str,
actor_url: &str,
announced_at: chrono::DateTime<chrono::Utc>,
) -> Result<()> {
let ts = announced_at.format("%Y-%m-%d %H:%M:%S").to_string();
sqlx::query(
"INSERT INTO ap_announces (id, object_url, actor_url, announced_at) VALUES ($1, $2, $3, $4) ON CONFLICT (id) DO NOTHING",
)
.bind(activity_id)
.bind(object_url)
.bind(actor_url)
.bind(&ts)
.execute(&self.pool)
.await?;
Ok(())
}
async fn count_announces(&self, object_url: &str) -> Result<usize> {
let row = sqlx::query("SELECT COUNT(*) as cnt FROM ap_announces WHERE object_url = $1")
.bind(object_url)
.fetch_one(&self.pool)
.await?;
Ok(row.get::<i64, _>("cnt") as usize)
}
async fn add_blocked_domain(&self, domain: &str, reason: Option<&str>) -> Result<()> {
let now = Utc::now().naive_utc();
let ts = datetime_to_str(&now);
sqlx::query(
"INSERT INTO blocked_domains (domain, reason, blocked_at) VALUES ($1, $2, $3)
ON CONFLICT(domain) DO UPDATE SET reason = EXCLUDED.reason",
)
.bind(domain)
.bind(reason)
.bind(&ts)
.execute(&self.pool)
.await?;
Ok(())
}
async fn remove_blocked_domain(&self, domain: &str) -> Result<()> {
sqlx::query("DELETE FROM blocked_domains WHERE domain = $1")
.bind(domain)
.execute(&self.pool)
.await?;
Ok(())
}
async fn get_blocked_domains(&self) -> Result<Vec<BlockedDomain>> {
let rows = sqlx::query(
"SELECT domain, reason, blocked_at FROM blocked_domains ORDER BY blocked_at DESC",
)
.fetch_all(&self.pool)
.await?;
Ok(rows
.iter()
.map(|r| BlockedDomain {
domain: r.get("domain"),
reason: r.get("reason"),
blocked_at: r.get("blocked_at"),
})
.collect())
}
async fn is_domain_blocked(&self, domain: &str) -> Result<bool> {
let count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM blocked_domains WHERE domain = $1",
)
.bind(domain)
.fetch_one(&self.pool)
.await?;
Ok(count > 0)
}
async fn add_blocked_actor(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()> {
let uid = local_user_id.to_string();
let ts = datetime_to_str(&Utc::now().naive_utc());
sqlx::query(
"INSERT INTO blocked_actors (local_user_id, remote_actor_url, blocked_at)
VALUES ($1, $2, $3)
ON CONFLICT DO NOTHING",
)
.bind(&uid)
.bind(actor_url)
.bind(&ts)
.execute(&self.pool)
.await?;
Ok(())
}
async fn remove_blocked_actor(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()> {
let uid = local_user_id.to_string();
sqlx::query(
"DELETE FROM blocked_actors WHERE local_user_id = $1 AND remote_actor_url = $2",
)
.bind(&uid)
.bind(actor_url)
.execute(&self.pool)
.await?;
Ok(())
}
async fn get_blocked_actors(&self, local_user_id: uuid::Uuid) -> Result<Vec<String>> {
let uid = local_user_id.to_string();
let rows = sqlx::query(
"SELECT remote_actor_url FROM blocked_actors WHERE local_user_id = $1 ORDER BY blocked_at DESC",
)
.bind(&uid)
.fetch_all(&self.pool)
.await?;
Ok(rows.iter().map(|r| r.get::<String, _>("remote_actor_url")).collect())
}
async fn is_actor_blocked(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<bool> {
let uid = local_user_id.to_string();
let count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM blocked_actors WHERE local_user_id = $1 AND remote_actor_url = $2",
)
.bind(&uid)
.bind(actor_url)
.fetch_one(&self.pool)
.await?;
Ok(count > 0)
}
} }
#[async_trait] #[async_trait]

View File

@@ -0,0 +1,2 @@
ALTER TABLE users ADD COLUMN bio TEXT;
ALTER TABLE users ADD COLUMN avatar_path TEXT;

View File

@@ -0,0 +1 @@
ALTER TABLE ap_remote_actors ADD COLUMN avatar_url TEXT;

View File

@@ -0,0 +1,8 @@
CREATE TABLE ap_announces (
id TEXT PRIMARY KEY,
object_url TEXT NOT NULL,
actor_url TEXT NOT NULL,
announced_at TEXT NOT NULL
);
CREATE INDEX idx_ap_announces_object ON ap_announces (object_url);

View File

@@ -0,0 +1,5 @@
CREATE TABLE blocked_domains (
domain TEXT PRIMARY KEY,
reason TEXT,
blocked_at TEXT NOT NULL
);

View File

@@ -0,0 +1,6 @@
CREATE TABLE blocked_actors (
local_user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
remote_actor_url TEXT NOT NULL,
blocked_at TEXT NOT NULL,
PRIMARY KEY (local_user_id, remote_actor_url)
);

View File

@@ -766,6 +766,16 @@ impl DiaryRepository for PostgresRepository {
offset: page.offset, offset: page.offset,
}) })
} }
async fn count_local_posts(&self) -> Result<u64, DomainError> {
let count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM reviews WHERE remote_actor_url IS NULL"
)
.fetch_one(&self.pool)
.await
.map_err(Self::map_err)?;
Ok(count as u64)
}
} }
#[async_trait] #[async_trait]

View File

@@ -38,6 +38,8 @@ impl PostgresUserRepository {
username_str: String, username_str: String,
hash_str: String, hash_str: String,
role: UserRole, role: UserRole,
bio: Option<String>,
avatar_path: Option<String>,
) -> Result<User, DomainError> { ) -> Result<User, DomainError> {
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()))?;
@@ -53,6 +55,8 @@ impl PostgresUserRepository {
username, username,
hash, hash,
role, role,
bio,
avatar_path,
)) ))
} }
} }
@@ -68,9 +72,11 @@ impl UserRepository for PostgresUserRepository {
username: String, username: String,
password_hash: String, password_hash: String,
role: String, role: String,
bio: Option<String>,
avatar_path: Option<String>,
} }
let row = sqlx::query_as::<_, Row>( let row = sqlx::query_as::<_, Row>(
"SELECT id, email, username, password_hash, role FROM users WHERE email = $1", "SELECT id, email, username, password_hash, role, bio, avatar_path FROM users WHERE email = $1",
) )
.bind(email_str) .bind(email_str)
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
@@ -83,6 +89,8 @@ impl UserRepository for PostgresUserRepository {
r.username, r.username,
r.password_hash, r.password_hash,
Self::parse_role(&r.role), Self::parse_role(&r.role),
r.bio,
r.avatar_path,
) )
}) })
.transpose() .transpose()
@@ -97,9 +105,11 @@ impl UserRepository for PostgresUserRepository {
username: String, username: String,
password_hash: String, password_hash: String,
role: String, role: String,
bio: Option<String>,
avatar_path: Option<String>,
} }
let row = sqlx::query_as::<_, Row>( let row = sqlx::query_as::<_, Row>(
"SELECT id, email, username, password_hash, role FROM users WHERE username = $1", "SELECT id, email, username, password_hash, role, bio, avatar_path FROM users WHERE username = $1",
) )
.bind(username_str) .bind(username_str)
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
@@ -112,6 +122,8 @@ impl UserRepository for PostgresUserRepository {
r.username, r.username,
r.password_hash, r.password_hash,
Self::parse_role(&r.role), Self::parse_role(&r.role),
r.bio,
r.avatar_path,
) )
}) })
.transpose() .transpose()
@@ -164,9 +176,11 @@ impl UserRepository for PostgresUserRepository {
username: String, username: String,
password_hash: String, password_hash: String,
role: String, role: String,
bio: Option<String>,
avatar_path: Option<String>,
} }
let row = sqlx::query_as::<_, Row>( let row = sqlx::query_as::<_, Row>(
"SELECT id, email, username, password_hash, role FROM users WHERE id = $1", "SELECT id, email, username, password_hash, role, bio, avatar_path FROM users WHERE id = $1",
) )
.bind(&id_str) .bind(&id_str)
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
@@ -179,11 +193,30 @@ impl UserRepository for PostgresUserRepository {
r.username, r.username,
r.password_hash, r.password_hash,
Self::parse_role(&r.role), Self::parse_role(&r.role),
r.bio,
r.avatar_path,
) )
}) })
.transpose() .transpose()
} }
async fn update_profile(
&self,
user_id: &UserId,
bio: Option<String>,
avatar_path: Option<String>,
) -> Result<(), DomainError> {
let id_str = user_id.value().to_string();
sqlx::query("UPDATE users SET bio = $1, avatar_path = $2 WHERE id = $3")
.bind(&bio)
.bind(&avatar_path)
.bind(&id_str)
.execute(&self.pool)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
Ok(())
}
async fn list_with_stats(&self) -> Result<Vec<domain::models::UserSummary>, DomainError> { async fn list_with_stats(&self) -> Result<Vec<domain::models::UserSummary>, DomainError> {
sqlx::query_as::<_, UserSummaryRow>( sqlx::query_as::<_, UserSummaryRow>(
r#"SELECT u.id, u.email, r#"SELECT u.id, u.email,

View File

@@ -5,7 +5,7 @@ use sqlx::{Row, SqlitePool};
use activitypub::RemoteReviewRepository; use activitypub::RemoteReviewRepository;
use activitypub_base::{ use activitypub_base::{
FederationRepository, Follower, FollowerStatus, FollowingStatus, RemoteActor, BlockedDomain, FederationRepository, Follower, FollowerStatus, FollowingStatus, RemoteActor,
}; };
use domain::models::{Review, ReviewSource}; use domain::models::{Review, ReviewSource};
@@ -106,7 +106,7 @@ 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.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.avatar_url
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 = ?",
@@ -125,6 +125,7 @@ impl FederationRepository for SqliteFederationRepository {
let shared_inbox_url: Option<String> = let shared_inbox_url: Option<String> =
row.try_get("shared_inbox_url").ok().flatten(); row.try_get("shared_inbox_url").ok().flatten();
let display_name: Option<String> = row.try_get("display_name").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: RemoteActor {
@@ -133,6 +134,7 @@ impl FederationRepository for SqliteFederationRepository {
inbox_url, inbox_url,
shared_inbox_url, shared_inbox_url,
display_name, display_name,
avatar_url,
}, },
status: str_to_status(&status_str), status: str_to_status(&status_str),
} }
@@ -223,7 +225,7 @@ 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 "SELECT a.url, a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.avatar_url
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'",
@@ -240,6 +242,7 @@ impl FederationRepository for SqliteFederationRepository {
inbox_url: row.get("inbox_url"), inbox_url: row.get("inbox_url"),
shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(), shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(),
display_name: row.try_get("display_name").ok().flatten(), display_name: row.try_get("display_name").ok().flatten(),
avatar_url: row.try_get("avatar_url").ok().flatten(),
}) })
.collect()) .collect())
} }
@@ -260,13 +263,14 @@ impl FederationRepository for SqliteFederationRepository {
let fetched_at = datetime_to_str(&now); let fetched_at = datetime_to_str(&now);
sqlx::query( sqlx::query(
"INSERT INTO ap_remote_actors (url, handle, inbox_url, shared_inbox_url, display_name, fetched_at) "INSERT INTO ap_remote_actors (url, handle, inbox_url, shared_inbox_url, display_name, avatar_url, fetched_at)
VALUES (?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(url) DO UPDATE SET ON CONFLICT(url) DO UPDATE SET
handle = excluded.handle, handle = excluded.handle,
inbox_url = excluded.inbox_url, inbox_url = excluded.inbox_url,
shared_inbox_url = excluded.shared_inbox_url, shared_inbox_url = excluded.shared_inbox_url,
display_name = excluded.display_name, display_name = excluded.display_name,
avatar_url = excluded.avatar_url,
fetched_at = excluded.fetched_at", fetched_at = excluded.fetched_at",
) )
.bind(&actor.url) .bind(&actor.url)
@@ -274,6 +278,7 @@ impl FederationRepository for SqliteFederationRepository {
.bind(&actor.inbox_url) .bind(&actor.inbox_url)
.bind(&actor.shared_inbox_url) .bind(&actor.shared_inbox_url)
.bind(&actor.display_name) .bind(&actor.display_name)
.bind(&actor.avatar_url)
.bind(&fetched_at) .bind(&fetched_at)
.execute(&self.pool) .execute(&self.pool)
.await?; .await?;
@@ -283,7 +288,7 @@ impl FederationRepository for SqliteFederationRepository {
async fn get_remote_actor(&self, actor_url: &str) -> Result<Option<RemoteActor>> { async fn get_remote_actor(&self, actor_url: &str) -> Result<Option<RemoteActor>> {
let row = sqlx::query( let row = sqlx::query(
"SELECT url, handle, inbox_url, shared_inbox_url, display_name "SELECT url, handle, inbox_url, shared_inbox_url, display_name, avatar_url
FROM ap_remote_actors WHERE url = ?", FROM ap_remote_actors WHERE url = ?",
) )
.bind(actor_url) .bind(actor_url)
@@ -296,6 +301,7 @@ impl FederationRepository for SqliteFederationRepository {
inbox_url: row.get("inbox_url"), inbox_url: row.get("inbox_url"),
shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(), shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(),
display_name: row.try_get("display_name").ok().flatten(), display_name: row.try_get("display_name").ok().flatten(),
avatar_url: row.try_get("avatar_url").ok().flatten(),
})) }))
} }
@@ -344,7 +350,7 @@ impl FederationRepository for SqliteFederationRepository {
let rows = sqlx::query( let rows = sqlx::query(
"SELECT f.remote_actor_url, "SELECT f.remote_actor_url,
a.handle, a.inbox_url, a.shared_inbox_url, a.display_name a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.avatar_url
FROM ap_followers f 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 = 'pending'", WHERE f.local_user_id = ? AND f.status = 'pending'",
@@ -361,6 +367,7 @@ impl FederationRepository for SqliteFederationRepository {
inbox_url: row.try_get("inbox_url").unwrap_or_default(), inbox_url: row.try_get("inbox_url").unwrap_or_default(),
shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(), shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(),
display_name: row.try_get("display_name").ok().flatten(), display_name: row.try_get("display_name").ok().flatten(),
avatar_url: row.try_get("avatar_url").ok().flatten(),
}) })
.collect()) .collect())
} }
@@ -392,6 +399,134 @@ impl FederationRepository for SqliteFederationRepository {
Ok(()) Ok(())
} }
async fn add_announce(
&self,
activity_id: &str,
object_url: &str,
actor_url: &str,
announced_at: chrono::DateTime<chrono::Utc>,
) -> Result<()> {
let ts = announced_at.format("%Y-%m-%d %H:%M:%S").to_string();
sqlx::query(
"INSERT OR IGNORE INTO ap_announces (id, object_url, actor_url, announced_at)
VALUES (?1, ?2, ?3, ?4)",
)
.bind(activity_id)
.bind(object_url)
.bind(actor_url)
.bind(&ts)
.execute(&self.pool)
.await?;
Ok(())
}
async fn count_announces(&self, object_url: &str) -> Result<usize> {
let row = sqlx::query("SELECT COUNT(*) as cnt FROM ap_announces WHERE object_url = ?1")
.bind(object_url)
.fetch_one(&self.pool)
.await?;
Ok(row.get::<i64, _>("cnt") as usize)
}
async fn add_blocked_domain(&self, domain: &str, reason: Option<&str>) -> Result<()> {
let now = Utc::now().naive_utc();
let ts = datetime_to_str(&now);
sqlx::query(
"INSERT INTO blocked_domains (domain, reason, blocked_at) VALUES (?1, ?2, ?3)
ON CONFLICT(domain) DO UPDATE SET reason = excluded.reason",
)
.bind(domain)
.bind(reason)
.bind(&ts)
.execute(&self.pool)
.await?;
Ok(())
}
async fn remove_blocked_domain(&self, domain: &str) -> Result<()> {
sqlx::query("DELETE FROM blocked_domains WHERE domain = ?1")
.bind(domain)
.execute(&self.pool)
.await?;
Ok(())
}
async fn get_blocked_domains(&self) -> Result<Vec<BlockedDomain>> {
let rows = sqlx::query(
"SELECT domain, reason, blocked_at FROM blocked_domains ORDER BY blocked_at DESC",
)
.fetch_all(&self.pool)
.await?;
Ok(rows
.iter()
.map(|r| BlockedDomain {
domain: r.get("domain"),
reason: r.get("reason"),
blocked_at: r.get("blocked_at"),
})
.collect())
}
async fn is_domain_blocked(&self, domain: &str) -> Result<bool> {
let count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM blocked_domains WHERE domain = ?1",
)
.bind(domain)
.fetch_one(&self.pool)
.await?;
Ok(count > 0)
}
async fn add_blocked_actor(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()> {
let uid = local_user_id.to_string();
let ts = datetime_to_str(&Utc::now().naive_utc());
sqlx::query(
"INSERT OR IGNORE INTO blocked_actors (local_user_id, remote_actor_url, blocked_at)
VALUES (?1, ?2, ?3)",
)
.bind(&uid)
.bind(actor_url)
.bind(&ts)
.execute(&self.pool)
.await?;
Ok(())
}
async fn remove_blocked_actor(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()> {
let uid = local_user_id.to_string();
sqlx::query(
"DELETE FROM blocked_actors WHERE local_user_id = ?1 AND remote_actor_url = ?2",
)
.bind(&uid)
.bind(actor_url)
.execute(&self.pool)
.await?;
Ok(())
}
async fn get_blocked_actors(&self, local_user_id: uuid::Uuid) -> Result<Vec<String>> {
let uid = local_user_id.to_string();
let rows = sqlx::query(
"SELECT remote_actor_url FROM blocked_actors WHERE local_user_id = ?1 ORDER BY blocked_at DESC",
)
.bind(&uid)
.fetch_all(&self.pool)
.await?;
Ok(rows.iter().map(|r| r.get::<String, _>("remote_actor_url")).collect())
}
async fn is_actor_blocked(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<bool> {
let uid = local_user_id.to_string();
let count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM blocked_actors WHERE local_user_id = ?1 AND remote_actor_url = ?2",
)
.bind(&uid)
.bind(actor_url)
.fetch_one(&self.pool)
.await?;
Ok(count > 0)
}
} }
// --- Content-specific repository (movies-diary) --- // --- Content-specific repository (movies-diary) ---
@@ -550,12 +685,115 @@ pub fn wire(pool: sqlx::SqlitePool) -> (
) )
} }
#[cfg(test)]
mod actor_block_tests {
use super::*;
use sqlx::SqlitePool;
async fn test_pool() -> SqlitePool {
let pool = SqlitePool::connect("sqlite::memory:").await.unwrap();
sqlx::query("CREATE TABLE users (id TEXT PRIMARY KEY, email TEXT, password_hash TEXT, created_at TEXT)")
.execute(&pool).await.unwrap();
sqlx::query("CREATE TABLE blocked_actors (local_user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, remote_actor_url TEXT NOT NULL, blocked_at TEXT NOT NULL, PRIMARY KEY (local_user_id, remote_actor_url))")
.execute(&pool).await.unwrap();
let uid = uuid::Uuid::new_v4().to_string();
sqlx::query("INSERT INTO users (id, email, password_hash, created_at) VALUES (?, ?, ?, ?)")
.bind(&uid).bind("a@b.com").bind("hash").bind("2024-01-01")
.execute(&pool).await.unwrap();
pool
}
#[tokio::test]
async fn block_and_check_actor() {
let pool = test_pool().await;
let user_id = uuid::Uuid::parse_str(
&sqlx::query_scalar::<_, String>("SELECT id FROM users LIMIT 1")
.fetch_one(&pool).await.unwrap()
).unwrap();
let repo = SqliteFederationRepository::new(pool);
let actor_url = "https://mastodon.social/users/alice";
assert!(!repo.is_actor_blocked(user_id, actor_url).await.unwrap());
repo.add_blocked_actor(user_id, actor_url).await.unwrap();
assert!(repo.is_actor_blocked(user_id, actor_url).await.unwrap());
let list = repo.get_blocked_actors(user_id).await.unwrap();
assert_eq!(list, vec![actor_url.to_string()]);
repo.remove_blocked_actor(user_id, actor_url).await.unwrap();
assert!(!repo.is_actor_blocked(user_id, actor_url).await.unwrap());
}
}
#[cfg(test)]
mod domain_block_tests {
use super::*;
use sqlx::SqlitePool;
async fn test_pool() -> SqlitePool {
let pool = SqlitePool::connect("sqlite::memory:").await.unwrap();
sqlx::query("CREATE TABLE blocked_domains (domain TEXT PRIMARY KEY, reason TEXT, blocked_at TEXT NOT NULL)")
.execute(&pool).await.unwrap();
pool
}
#[tokio::test]
async fn blocked_domain_is_detected() {
let pool = test_pool().await;
let repo = SqliteFederationRepository::new(pool);
assert!(!repo.is_domain_blocked("mastodon.social").await.unwrap());
repo.add_blocked_domain("mastodon.social", Some("spam")).await.unwrap();
assert!(repo.is_domain_blocked("mastodon.social").await.unwrap());
}
#[tokio::test]
async fn remove_unblocks_domain() {
let pool = test_pool().await;
let repo = SqliteFederationRepository::new(pool);
repo.add_blocked_domain("spam.xyz", None).await.unwrap();
repo.remove_blocked_domain("spam.xyz").await.unwrap();
assert!(!repo.is_domain_blocked("spam.xyz").await.unwrap());
}
#[tokio::test]
async fn get_blocked_domains_returns_all() {
let pool = test_pool().await;
let repo = SqliteFederationRepository::new(pool);
repo.add_blocked_domain("a.com", Some("reason a")).await.unwrap();
repo.add_blocked_domain("b.com", None).await.unwrap();
let domains = repo.get_blocked_domains().await.unwrap();
assert_eq!(domains.len(), 2);
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use chrono::Utc;
use domain::ports::SocialQueryPort; use domain::ports::SocialQueryPort;
use sqlx::SqlitePool; use sqlx::SqlitePool;
async fn test_pool() -> SqlitePool {
let pool = SqlitePool::connect("sqlite::memory:").await.unwrap();
sqlx::query("CREATE TABLE ap_announces (id TEXT PRIMARY KEY, object_url TEXT NOT NULL, actor_url TEXT NOT NULL, announced_at TEXT NOT NULL)")
.execute(&pool).await.unwrap();
pool
}
#[tokio::test]
async fn add_announce_stores_and_counts() {
let pool = test_pool().await;
let repo = SqliteFederationRepository::new(pool);
repo.add_announce("https://remote/ann/1", "https://local/r/1", "https://remote/u/1", Utc::now()).await.unwrap();
assert_eq!(repo.count_announces("https://local/r/1").await.unwrap(), 1);
}
#[tokio::test]
async fn duplicate_announce_is_ignored() {
let pool = test_pool().await;
let repo = SqliteFederationRepository::new(pool);
repo.add_announce("https://remote/ann/1", "https://local/r/1", "https://remote/u/1", Utc::now()).await.unwrap();
repo.add_announce("https://remote/ann/1", "https://local/r/1", "https://remote/u/1", Utc::now()).await.unwrap();
assert_eq!(repo.count_announces("https://local/r/1").await.unwrap(), 1);
}
async fn setup_db(pool: &SqlitePool) { async fn setup_db(pool: &SqlitePool) {
sqlx::query( sqlx::query(
"CREATE TABLE IF NOT EXISTS ap_remote_actors ( "CREATE TABLE IF NOT EXISTS ap_remote_actors (
@@ -564,6 +802,7 @@ mod tests {
inbox_url TEXT NOT NULL, inbox_url TEXT NOT NULL,
shared_inbox_url TEXT, shared_inbox_url TEXT,
display_name TEXT, display_name TEXT,
avatar_url TEXT,
fetched_at TEXT NOT NULL fetched_at TEXT NOT NULL
)", )",
) )

View File

@@ -1,6 +1,6 @@
{ {
"db_name": "SQLite", "db_name": "SQLite",
"query": "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,\n r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at\n FROM reviews r\n INNER JOIN movies m ON m.id = r.movie_id\n WHERE r.movie_id = ?\n ORDER BY r.watched_at ASC\n LIMIT ? OFFSET ?", "query": "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,\n r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url\n FROM reviews r\n INNER JOIN movies m ON m.id = r.movie_id\n WHERE r.movie_id = ?\n ORDER BY r.watched_at DESC\n LIMIT ? OFFSET ?",
"describe": { "describe": {
"columns": [ "columns": [
{ {
@@ -67,6 +67,11 @@
"name": "created_at", "name": "created_at",
"ordinal": 12, "ordinal": 12,
"type_info": "Text" "type_info": "Text"
},
{
"name": "remote_actor_url",
"ordinal": 13,
"type_info": "Text"
} }
], ],
"parameters": { "parameters": {
@@ -85,8 +90,9 @@
false, false,
true, true,
false, false,
false false,
true
] ]
}, },
"hash": "01a08873b7fa815ad98a56a0902b60414cfcdc2c7a8570351320c4bc425347c6" "hash": "05d958c1fa38095ae2b5b81ede48fc85702d8c39c6301839de7b4d27f4a4d41b"
} }

View File

@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "INSERT INTO users (id, email, username, password_hash, created_at, role) VALUES (?, ?, ?, ?, ?, ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 6
},
"nullable": []
},
"hash": "0acf3515a4d1bef7a2458d878481ba3b11c9f252311579f7daba335f70d78f07"
}

View File

@@ -0,0 +1,98 @@
{
"db_name": "SQLite",
"query": "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,\n r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url\n FROM reviews r\n INNER JOIN movies m ON m.id = r.movie_id\n WHERE r.user_id = ?\n ORDER BY r.watched_at DESC",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "external_metadata_id",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "title",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "release_year",
"ordinal": 3,
"type_info": "Integer"
},
{
"name": "director",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "poster_path",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "review_id",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "movie_id",
"ordinal": 7,
"type_info": "Text"
},
{
"name": "user_id",
"ordinal": 8,
"type_info": "Text"
},
{
"name": "rating",
"ordinal": 9,
"type_info": "Integer"
},
{
"name": "comment",
"ordinal": 10,
"type_info": "Text"
},
{
"name": "watched_at",
"ordinal": 11,
"type_info": "Text"
},
{
"name": "created_at",
"ordinal": 12,
"type_info": "Text"
},
{
"name": "remote_actor_url",
"ordinal": 13,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
true,
false,
false,
true,
true,
false,
false,
false,
false,
true,
false,
false,
true
]
},
"hash": "106b5b65162314c47217c26b7e89194094e10122ea596e8d9323968e600635a9"
}

View File

@@ -0,0 +1,56 @@
{
"db_name": "SQLite",
"query": "SELECT id, email, username, password_hash, role, bio, avatar_path FROM users WHERE username = ?",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "email",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "username",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "password_hash",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "role",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "bio",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "avatar_path",
"ordinal": 6,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
true,
false,
false,
false,
false,
true,
true
]
},
"hash": "1417a8a295bc966637eb7e68e088148a7bef09fb1a3c3ea44d25da32c3908472"
}

View File

@@ -1,32 +0,0 @@
{
"db_name": "SQLite",
"query": "SELECT id, email, password_hash FROM users WHERE email = ?",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "email",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "password_hash",
"ordinal": 2,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false
]
},
"hash": "167481bb1692cc81531d9a5cd85425e43d09a6df97c335ac347f7cfd61acd171"
}

View File

@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "INSERT OR IGNORE INTO users (id, email, password_hash, created_at) VALUES (?, ?, ?, ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 4
},
"nullable": []
},
"hash": "18de90feb13b9f467f06d0ce25332d9ea7eabc99d9f1a44694e5d10762606f82"
}

View File

@@ -1,32 +0,0 @@
{
"db_name": "SQLite",
"query": "SELECT id, email, password_hash FROM users WHERE id = ?",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "email",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "password_hash",
"ordinal": 2,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false
]
},
"hash": "1bc5a51762717e45292626052f0a65ac0b8a001798a2476ea86143c5565df399"
}

View File

@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "DELETE FROM import_sessions WHERE expires_at < datetime('now')",
"describe": {
"columns": [],
"parameters": {
"Right": 0
},
"nullable": []
},
"hash": "1c70d5d455e5ab3dfb68d35ce5255c7e0e86b9f6549ae8ae09247806f1321086"
}

View File

@@ -0,0 +1,56 @@
{
"db_name": "SQLite",
"query": "SELECT id, email, username, password_hash, role, bio, avatar_path FROM users WHERE email = ?",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "email",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "username",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "password_hash",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "role",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "bio",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "avatar_path",
"ordinal": 6,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
true,
false,
false,
false,
false,
true,
true
]
},
"hash": "1edf77b936b825139735e4f92bc472031e3231235ca5fe40732d7bdfddc4cbba"
}

View File

@@ -1,6 +1,6 @@
{ {
"db_name": "SQLite", "db_name": "SQLite",
"query": "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,\n r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at\n FROM reviews r\n INNER JOIN movies m ON m.id = r.movie_id\n ORDER BY r.watched_at DESC\n LIMIT ? OFFSET ?", "query": "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,\n r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url\n FROM reviews r\n INNER JOIN movies m ON m.id = r.movie_id\n ORDER BY r.watched_at ASC\n LIMIT ? OFFSET ?",
"describe": { "describe": {
"columns": [ "columns": [
{ {
@@ -67,6 +67,11 @@
"name": "created_at", "name": "created_at",
"ordinal": 12, "ordinal": 12,
"type_info": "Text" "type_info": "Text"
},
{
"name": "remote_actor_url",
"ordinal": 13,
"type_info": "Text"
} }
], ],
"parameters": { "parameters": {
@@ -85,8 +90,9 @@
false, false,
true, true,
false, false,
false false,
true
] ]
}, },
"hash": "026e2afeb573707cb360fcdab8f6137aabfaf603b5ed57b98ac2888b4a0389ff" "hash": "25fd01355c929a83daf2c802b8ae3adaa4ce73fc037e2bf2a87d60187aeb7361"
} }

View File

@@ -0,0 +1,38 @@
{
"db_name": "SQLite",
"query": "SELECT u.id AS \"id!: String\",\n u.email AS \"email!: String\",\n COUNT(DISTINCT r.movie_id) AS \"total_movies!: i64\",\n AVG(CAST(r.rating AS REAL)) AS avg_rating\n FROM users u\n LEFT JOIN reviews r ON r.user_id = u.id AND r.remote_actor_url IS NULL\n GROUP BY u.id, u.email\n ORDER BY u.email ASC",
"describe": {
"columns": [
{
"name": "id!: String",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "email!: String",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "total_movies!: i64",
"ordinal": 2,
"type_info": "Integer"
},
{
"name": "avg_rating",
"ordinal": 3,
"type_info": "Float"
}
],
"parameters": {
"Right": 0
},
"nullable": [
true,
false,
false,
true
]
},
"hash": "2c7353f34c4748d4d4be6abbf343fa7ea30eeb985c4bfd12b0fc3997d1ba03bb"
}

View File

@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "DELETE FROM import_sessions WHERE user_id = ? AND expires_at < datetime('now')",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "34a0a7b5e6a7f2b97b59e9de6a34b41a7128a465c0957915c7ed7031c6b83fb0"
}

View File

@@ -0,0 +1,20 @@
{
"db_name": "SQLite",
"query": "SELECT strftime('%Y-%m', watched_at) AS month\n FROM reviews\n WHERE user_id = ?\n GROUP BY month\n ORDER BY COUNT(*) DESC\n LIMIT 1",
"describe": {
"columns": [
{
"name": "month",
"ordinal": 0,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
true
]
},
"hash": "4d85f0ff9732576bba77dc84d3885a0002c2b600c34ba4d99f1e1c5e99f35e75"
}

View File

@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "INSERT INTO reviews (id, movie_id, user_id, rating, comment, watched_at, created_at)\n VALUES (?, ?, ?, ?, ?, ?, ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 7
},
"nullable": []
},
"hash": "630e092fcd33bc312befef352a98225e6e18e6079644b949258a39bf4b0fe3e5"
}

View File

@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "INSERT OR REPLACE INTO import_profiles (id, user_id, name, field_mappings, created_at)\n VALUES (?, ?, ?, ?, ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 5
},
"nullable": []
},
"hash": "6638569b8759b965f8919f4998a8665b15c14fc0b4f6018e548b76474254073e"
}

View File

@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "UPDATE import_sessions SET field_mappings = ?, row_results = ? WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 3
},
"nullable": []
},
"hash": "759b45ac8426d93d4668e208003d52957f2f52d8a021cc6f117cb6f241689a07"
}

View File

@@ -0,0 +1,44 @@
{
"db_name": "SQLite",
"query": "SELECT id, user_id, name, field_mappings, created_at FROM import_profiles WHERE id = ? AND user_id = ?",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "user_id",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "name",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "field_mappings",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "created_at",
"ordinal": 4,
"type_info": "Text"
}
],
"parameters": {
"Right": 2
},
"nullable": [
false,
false,
false,
false,
false
]
},
"hash": "7fa9ce795286905f4b6c3d5a8975fabd813a2b39652dd7e18f03f5b10a4beaca"
}

View File

@@ -0,0 +1,62 @@
{
"db_name": "SQLite",
"query": "SELECT id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url\n FROM reviews WHERE movie_id = ? ORDER BY watched_at ASC",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "movie_id",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "user_id",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "rating",
"ordinal": 3,
"type_info": "Integer"
},
{
"name": "comment",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "watched_at",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "created_at",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "remote_actor_url",
"ordinal": 7,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
false,
true,
false,
false,
true
]
},
"hash": "7ff439f22f880f999a72aad1359eb8fec11fe868b940faee5a351795caaa2357"
}

View File

@@ -1,6 +1,6 @@
{ {
"db_name": "SQLite", "db_name": "SQLite",
"query": "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,\n r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at\n FROM reviews r\n INNER JOIN movies m ON m.id = r.movie_id\n WHERE r.movie_id = ?\n ORDER BY r.watched_at DESC\n LIMIT ? OFFSET ?", "query": "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,\n r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url\n FROM reviews r\n INNER JOIN movies m ON m.id = r.movie_id\n WHERE r.movie_id = ?\n ORDER BY r.watched_at ASC\n LIMIT ? OFFSET ?",
"describe": { "describe": {
"columns": [ "columns": [
{ {
@@ -67,6 +67,11 @@
"name": "created_at", "name": "created_at",
"ordinal": 12, "ordinal": 12,
"type_info": "Text" "type_info": "Text"
},
{
"name": "remote_actor_url",
"ordinal": 13,
"type_info": "Text"
} }
], ],
"parameters": { "parameters": {
@@ -85,8 +90,9 @@
false, false,
true, true,
false, false,
false false,
true
] ]
}, },
"hash": "47f7cf95ce3450635b643ab710cadba96f40319140834d510bc5207b2552e055" "hash": "8a70f21c39d203867c06dc0bf74a54745b3331b84ce9a2178f7812f1ed7262cc"
} }

View File

@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "DELETE FROM import_profiles WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "9024c2b533d0100f18d5a5550220a148d8dc29bbf0df715f895fa4248d83785f"
}

View File

@@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "SELECT COUNT(DISTINCT movie_id) AS \"total!: i64\",\n AVG(CAST(rating AS REAL)) AS avg_rating\n FROM reviews WHERE user_id = ?",
"describe": {
"columns": [
{
"name": "total!: i64",
"ordinal": 0,
"type_info": "Integer"
},
{
"name": "avg_rating",
"ordinal": 1,
"type_info": "Float"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
true
]
},
"hash": "a01336632a54099e31686a9cbe6fc53fef1299fc7c7b52be44f99c2302490a22"
}

View File

@@ -1,6 +1,6 @@
{ {
"db_name": "SQLite", "db_name": "SQLite",
"query": "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,\n r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at\n FROM reviews r\n INNER JOIN movies m ON m.id = r.movie_id\n ORDER BY r.watched_at ASC\n LIMIT ? OFFSET ?", "query": "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,\n r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url\n FROM reviews r\n INNER JOIN movies m ON m.id = r.movie_id\n ORDER BY r.watched_at DESC\n LIMIT ? OFFSET ?",
"describe": { "describe": {
"columns": [ "columns": [
{ {
@@ -67,6 +67,11 @@
"name": "created_at", "name": "created_at",
"ordinal": 12, "ordinal": 12,
"type_info": "Text" "type_info": "Text"
},
{
"name": "remote_actor_url",
"ordinal": 13,
"type_info": "Text"
} }
], ],
"parameters": { "parameters": {
@@ -85,8 +90,9 @@
false, false,
true, true,
false, false,
false false,
true
] ]
}, },
"hash": "affe1eb261283c09d4b1ce6e684681755f079a044ffec8ff2bd79cfd8efe16b8" "hash": "a7c424c26663e4e51b1c563fa977f28e1d55234a242a7ddba50db13cf73b488d"
} }

View File

@@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "SELECT m.director AS \"director!\",\n COUNT(*) AS \"count!: i64\"\n FROM reviews r\n INNER JOIN movies m ON m.id = r.movie_id\n WHERE r.user_id = ? AND m.director IS NOT NULL\n GROUP BY m.director\n ORDER BY COUNT(*) DESC\n LIMIT 5",
"describe": {
"columns": [
{
"name": "director!",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "count!: i64",
"ordinal": 1,
"type_info": "Integer"
}
],
"parameters": {
"Right": 1
},
"nullable": [
true,
false
]
},
"hash": "aca9e7aaa32c23b4de3f5048d60340e978d31a36be9121da3c59378f2fc1ed8e"
}

View File

@@ -1,6 +1,6 @@
{ {
"db_name": "SQLite", "db_name": "SQLite",
"query": "SELECT id, movie_id, user_id, rating, comment, watched_at, created_at\n FROM reviews WHERE movie_id = ? ORDER BY watched_at ASC", "query": "SELECT id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url\n FROM reviews WHERE id = ?",
"describe": { "describe": {
"columns": [ "columns": [
{ {
@@ -37,6 +37,11 @@
"name": "created_at", "name": "created_at",
"ordinal": 6, "ordinal": 6,
"type_info": "Text" "type_info": "Text"
},
{
"name": "remote_actor_url",
"ordinal": 7,
"type_info": "Text"
} }
], ],
"parameters": { "parameters": {
@@ -49,8 +54,9 @@
false, false,
true, true,
false, false,
false false,
true
] ]
}, },
"hash": "af883f8b78f185077e2d3dcfaa0a6e62fbdfbf00c97c9b33b699dc631476181d" "hash": "ae983138ad90fda3794b784fbf62c31fddcf182850782e9bfbc4ff3ee8b7d4bb"
} }

View File

@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "DELETE FROM import_sessions WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "bf4aaf630ebefde88d87a95c257bbeafaf39e2f53611dab435d485648eb7e598"
}

View File

@@ -0,0 +1,56 @@
{
"db_name": "SQLite",
"query": "SELECT id, user_id, parsed_data, field_mappings, row_results, created_at, expires_at\n FROM import_sessions WHERE id = ? AND user_id = ?",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "user_id",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "parsed_data",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "field_mappings",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "row_results",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "created_at",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "expires_at",
"ordinal": 6,
"type_info": "Text"
}
],
"parameters": {
"Right": 2
},
"nullable": [
false,
false,
false,
true,
true,
false,
false
]
},
"hash": "ca7c29f4c80cee4877625a259edef662b169377c5bafdd03583645a39368c910"
}

View File

@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "INSERT INTO reviews (id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url)\n VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 8
},
"nullable": []
},
"hash": "cca022ac6275f2b1aaf63a14420897074c8ff4cdd1d3e9a13ef4b9dd5346d12a"
}

View File

@@ -0,0 +1,20 @@
{
"db_name": "SQLite",
"query": "SELECT m.director\n FROM reviews r\n INNER JOIN movies m ON m.id = r.movie_id\n WHERE r.user_id = ? AND m.director IS NOT NULL\n GROUP BY m.director\n ORDER BY COUNT(*) DESC\n LIMIT 1",
"describe": {
"columns": [
{
"name": "director",
"ordinal": 0,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
true
]
},
"hash": "d5d2a81306488a8cee5654cea7e14d76d76ecc7d2190ffb73d12bec2874111d2"
}

View File

@@ -0,0 +1,56 @@
{
"db_name": "SQLite",
"query": "SELECT id, email, username, password_hash, role, bio, avatar_path FROM users WHERE id = ?",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "email",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "username",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "password_hash",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "role",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "bio",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "avatar_path",
"ordinal": 6,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
true,
false,
false,
false,
false,
true,
true
]
},
"hash": "d6c6b579a18fb106e62148f5f85b8071fceefea51909ace939ae1d09c4597c43"
}

View File

@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "INSERT INTO import_sessions (id, user_id, parsed_data, field_mappings, row_results, created_at, expires_at)\n VALUES (?, ?, ?, ?, ?, ?, ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 7
},
"nullable": []
},
"hash": "e3a3b56d63df433339bd8183f20ef33ded742ef03df885686cd5198f9f8e1c01"
}

View File

@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "DELETE FROM movies WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "e431381ad41c1c2f7b9c89509d5e3f4c19cb52dcfff66772145cd80c53c16883"
}

View File

@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "DELETE FROM reviews WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "f84e5483ca4210aec67b38cc1a9de4a42c12891025236abc48ea4f175292a6cc"
}

View File

@@ -0,0 +1,44 @@
{
"db_name": "SQLite",
"query": "SELECT id, user_id, name, field_mappings, created_at FROM import_profiles WHERE user_id = ? ORDER BY created_at DESC",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "user_id",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "name",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "field_mappings",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "created_at",
"ordinal": 4,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
false,
false
]
},
"hash": "f8559923b7a885fb0d4938479f11bd97708b401a6a2290abe6df49fcb7f28a8d"
}

View File

@@ -0,0 +1,32 @@
{
"db_name": "SQLite",
"query": "SELECT strftime('%Y-%m', watched_at) AS \"month!\",\n AVG(CAST(rating AS REAL)) AS \"avg_rating!: f64\",\n COUNT(*) AS \"count!: i64\"\n FROM reviews\n WHERE user_id = ? AND watched_at >= datetime('now', '-12 months')\n GROUP BY \"month!\"\n ORDER BY \"month!\" ASC",
"describe": {
"columns": [
{
"name": "month!",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "avg_rating!: f64",
"ordinal": 1,
"type_info": "Float"
},
{
"name": "count!: i64",
"ordinal": 2,
"type_info": "Integer"
}
],
"parameters": {
"Right": 1
},
"nullable": [
true,
false,
false
]
},
"hash": "fdd5b522f26b5e0ce62f76c774fbb606fd9ee9884f4457831f693a0df3609317"
}

View File

@@ -0,0 +1,2 @@
ALTER TABLE users ADD COLUMN bio TEXT;
ALTER TABLE users ADD COLUMN avatar_path TEXT;

View File

@@ -0,0 +1 @@
ALTER TABLE ap_remote_actors ADD COLUMN avatar_url TEXT;

View File

@@ -0,0 +1,8 @@
CREATE TABLE ap_announces (
id TEXT PRIMARY KEY,
object_url TEXT NOT NULL,
actor_url TEXT NOT NULL,
announced_at TEXT NOT NULL
);
CREATE INDEX idx_ap_announces_object ON ap_announces (object_url);

View File

@@ -0,0 +1,5 @@
CREATE TABLE blocked_domains (
domain TEXT PRIMARY KEY,
reason TEXT,
blocked_at TEXT NOT NULL
);

View File

@@ -0,0 +1,6 @@
CREATE TABLE blocked_actors (
local_user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
remote_actor_url TEXT NOT NULL,
blocked_at TEXT NOT NULL,
PRIMARY KEY (local_user_id, remote_actor_url)
);

View File

@@ -1,457 +0,0 @@
use anyhow::{anyhow, Result};
use async_trait::async_trait;
use chrono::Utc;
use sqlx::{Row, SqlitePool};
use activitypub_base::{FederationRepository, Follower, FollowerStatus, FollowingStatus, RemoteActor};
use activitypub::RemoteReviewRepository;
use domain::models::{Review, ReviewSource};
use crate::models::datetime_to_str;
pub struct SqliteFederationRepository {
pool: SqlitePool,
}
impl SqliteFederationRepository {
pub fn new(pool: SqlitePool) -> Self {
Self { pool }
}
}
fn status_to_str(status: &FollowerStatus) -> &'static str {
match status {
FollowerStatus::Pending => "pending",
FollowerStatus::Accepted => "accepted",
FollowerStatus::Rejected => "rejected",
}
}
fn str_to_status(s: &str) -> FollowerStatus {
match s {
"accepted" => FollowerStatus::Accepted,
"rejected" => FollowerStatus::Rejected,
_ => FollowerStatus::Pending,
}
}
#[async_trait]
impl FederationRepository for SqliteFederationRepository {
async fn add_follower(
&self,
local_user_id: uuid::Uuid,
remote_actor_url: &str,
status: FollowerStatus,
follow_activity_id: &str,
) -> Result<()> {
let uid = local_user_id.to_string();
let status_str = status_to_str(&status);
let now = Utc::now().naive_utc();
let created_at = datetime_to_str(&now);
sqlx::query(
"INSERT INTO ap_followers (local_user_id, remote_actor_url, status, created_at, follow_activity_id)
VALUES (?1, ?2, ?3, ?4, ?5)
ON CONFLICT(local_user_id, remote_actor_url) DO UPDATE SET
status = excluded.status,
follow_activity_id = excluded.follow_activity_id",
)
.bind(&uid)
.bind(remote_actor_url)
.bind(status_str)
.bind(&created_at)
.bind(follow_activity_id)
.execute(&self.pool)
.await?;
Ok(())
}
async fn get_follower_follow_activity_id(
&self,
local_user_id: uuid::Uuid,
remote_actor_url: &str,
) -> Result<Option<String>> {
let uid = local_user_id.to_string();
let row: Option<Option<String>> = sqlx::query_scalar(
"SELECT follow_activity_id FROM ap_followers WHERE local_user_id = ? AND remote_actor_url = ?",
)
.bind(&uid)
.bind(remote_actor_url)
.fetch_optional(&self.pool)
.await?;
Ok(row.flatten())
}
async fn remove_follower(&self, local_user_id: uuid::Uuid, remote_actor_url: &str) -> Result<()> {
let uid = local_user_id.to_string();
sqlx::query("DELETE FROM ap_followers WHERE local_user_id = ? AND remote_actor_url = ?")
.bind(&uid)
.bind(remote_actor_url)
.execute(&self.pool)
.await?;
Ok(())
}
async fn get_followers(&self, local_user_id: uuid::Uuid) -> Result<Vec<Follower>> {
let uid = local_user_id.to_string();
let rows = sqlx::query(
"SELECT f.remote_actor_url, f.status,
a.handle, a.inbox_url, a.shared_inbox_url, a.display_name
FROM ap_followers f
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
WHERE f.local_user_id = ?",
)
.bind(&uid)
.fetch_all(&self.pool)
.await?;
let followers = rows
.into_iter()
.map(|row| {
let url: String = row.get("remote_actor_url");
let status_str: String = row.get("status");
let handle: String = row.try_get("handle").unwrap_or_default();
let inbox_url: String = row.try_get("inbox_url").unwrap_or_default();
let shared_inbox_url: Option<String> = row.try_get("shared_inbox_url").ok().flatten();
let display_name: Option<String> = row.try_get("display_name").ok().flatten();
Follower {
actor: RemoteActor { url, handle, inbox_url, shared_inbox_url, display_name },
status: str_to_status(&status_str),
}
})
.collect();
Ok(followers)
}
async fn update_follower_status(
&self,
local_user_id: uuid::Uuid,
remote_actor_url: &str,
status: FollowerStatus,
) -> Result<()> {
let uid = local_user_id.to_string();
let status_str = status_to_str(&status);
let result = sqlx::query(
"UPDATE ap_followers SET status = ? WHERE local_user_id = ? AND remote_actor_url = ?",
)
.bind(status_str)
.bind(&uid)
.bind(remote_actor_url)
.execute(&self.pool)
.await?;
if result.rows_affected() == 0 {
tracing::warn!(local_user_id = %local_user_id, remote_actor_url, "update_follower_status: no row found");
}
Ok(())
}
async fn add_following(&self, local_user_id: uuid::Uuid, actor: RemoteActor, follow_activity_id: &str) -> Result<()> {
let uid = local_user_id.to_string();
let now = Utc::now().naive_utc();
let created_at = datetime_to_str(&now);
self.upsert_remote_actor(actor.clone()).await?;
sqlx::query(
"INSERT OR IGNORE INTO ap_following (local_user_id, remote_actor_url, follow_activity_id, created_at)
VALUES (?, ?, ?, ?)",
)
.bind(&uid)
.bind(&actor.url)
.bind(follow_activity_id)
.bind(&created_at)
.execute(&self.pool)
.await?;
Ok(())
}
async fn get_follow_activity_id(&self, local_user_id: uuid::Uuid, remote_actor_url: &str) -> Result<Option<String>> {
let uid = local_user_id.to_string();
let row: Option<Option<String>> = sqlx::query_scalar(
"SELECT follow_activity_id FROM ap_following WHERE local_user_id = ? AND remote_actor_url = ?",
)
.bind(&uid)
.bind(remote_actor_url)
.fetch_optional(&self.pool)
.await?;
Ok(row.flatten())
}
async fn remove_following(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()> {
let uid = local_user_id.to_string();
sqlx::query("DELETE FROM ap_following WHERE local_user_id = ? AND remote_actor_url = ?")
.bind(&uid)
.bind(actor_url)
.execute(&self.pool)
.await?;
Ok(())
}
async fn get_following(&self, local_user_id: uuid::Uuid) -> Result<Vec<RemoteActor>> {
let uid = local_user_id.to_string();
let rows = sqlx::query(
"SELECT a.url, a.handle, a.inbox_url, a.shared_inbox_url, a.display_name
FROM ap_following f
INNER JOIN ap_remote_actors a ON a.url = f.remote_actor_url
WHERE f.local_user_id = ? AND f.status = 'accepted'",
)
.bind(&uid)
.fetch_all(&self.pool)
.await?;
Ok(rows.into_iter().map(|row| RemoteActor {
url: row.get("url"),
handle: row.get("handle"),
inbox_url: row.get("inbox_url"),
shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(),
display_name: row.try_get("display_name").ok().flatten(),
}).collect())
}
async fn count_following(&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_following WHERE local_user_id = ? AND status = 'accepted'",
)
.bind(&uid)
.fetch_one(&self.pool)
.await?;
Ok(count as usize)
}
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, 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,
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(&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
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(),
}))
}
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
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(),
}).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 = ? WHERE local_user_id = ? AND remote_actor_url = ?",
)
.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(())
}
}
// --- Content-specific repository (movies-diary) ---
#[async_trait]
impl RemoteReviewRepository for SqliteFederationRepository {
async fn save_remote_review(
&self,
review: &Review,
ap_id: &str,
movie_title: &str,
release_year: u16,
poster_url: Option<&str>,
) -> Result<()> {
let actor_url = match review.source() {
ReviewSource::Remote { actor_url } => actor_url.clone(),
ReviewSource::Local => {
return Err(anyhow!("save_remote_review called with a local review"));
}
};
let movie_id = review.movie_id().value().to_string();
let _ = sqlx::query(
"INSERT INTO movies (id, external_metadata_id, title, release_year, director, poster_path)
VALUES (?, NULL, ?, ?, NULL, ?)
ON CONFLICT(id) DO UPDATE SET
poster_path = COALESCE(excluded.poster_path, movies.poster_path)",
)
.bind(&movie_id)
.bind(movie_title)
.bind(release_year.max(1888) as i64)
.bind(poster_url)
.execute(&self.pool)
.await?;
let id = review.id().value().to_string();
let user_id = review.user_id().value().to_string();
let rating = review.rating().value() as i64;
let comment = review.comment().map(|c| c.value().to_string());
let watched_at = datetime_to_str(review.watched_at());
let created_at = datetime_to_str(review.created_at());
sqlx::query(
"INSERT OR IGNORE INTO reviews (id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url, ap_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(&id)
.bind(&movie_id)
.bind(&user_id)
.bind(rating)
.bind(&comment)
.bind(&watched_at)
.bind(&created_at)
.bind(&actor_url)
.bind(ap_id)
.execute(&self.pool)
.await?;
Ok(())
}
async fn delete_remote_review(&self, ap_id: &str, actor_url: &str) -> Result<()> {
sqlx::query("DELETE FROM reviews WHERE ap_id = ? AND remote_actor_url = ?")
.bind(ap_id)
.bind(actor_url)
.execute(&self.pool)
.await?;
Ok(())
}
async fn update_remote_review(
&self,
ap_id: &str,
actor_url: &str,
rating: u8,
comment: Option<&str>,
watched_at: chrono::NaiveDateTime,
) -> Result<()> {
let watched_at_str = datetime_to_str(&watched_at);
sqlx::query(
"UPDATE reviews SET rating = ?, comment = ?, watched_at = ?
WHERE ap_id = ? AND remote_actor_url = ?",
)
.bind(rating as i64)
.bind(comment)
.bind(&watched_at_str)
.bind(ap_id)
.bind(actor_url)
.execute(&self.pool)
.await?;
Ok(())
}
async fn delete_by_actor(&self, actor_url: &str) -> Result<()> {
sqlx::query("DELETE FROM reviews WHERE remote_actor_url = ?")
.bind(actor_url)
.execute(&self.pool)
.await?;
Ok(())
}
}

View File

@@ -752,6 +752,16 @@ impl DiaryRepository for SqliteMovieRepository {
offset: page.offset, offset: page.offset,
}) })
} }
async fn count_local_posts(&self) -> Result<u64, DomainError> {
let count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM reviews WHERE remote_actor_url IS NULL"
)
.fetch_one(&self.pool)
.await
.map_err(Self::map_err)?;
Ok(count as u64)
}
} }
#[async_trait] #[async_trait]
@@ -1080,3 +1090,48 @@ mod feed_filter_tests {
assert_eq!(stats.rating_histogram[4], 0); // 5★ bucket assert_eq!(stats.rating_histogram[4], 0); // 5★ bucket
} }
} }
#[cfg(test)]
mod diary_count_tests {
use super::*;
use sqlx::SqlitePool;
async fn test_pool() -> SqlitePool {
let pool = SqlitePool::connect("sqlite::memory:").await.unwrap();
sqlx::migrate!("./migrations").run(&pool).await.unwrap();
pool
}
#[tokio::test]
async fn count_local_posts_excludes_remote_reviews() {
use domain::ports::DiaryRepository;
let pool = test_pool().await;
let repo = SqliteMovieRepository::new(pool.clone());
let user_id = uuid::Uuid::new_v4().to_string();
let movie_id = uuid::Uuid::new_v4().to_string();
sqlx::query("INSERT INTO users (id, email, password_hash, created_at, username) VALUES (?, ?, ?, ?, ?)")
.bind(&user_id).bind("a@b.com").bind("hash").bind("2024-01-01 00:00:00").bind("alice")
.execute(&pool).await.unwrap();
sqlx::query("INSERT INTO movies (id, title, release_year) VALUES (?, ?, ?)")
.bind(&movie_id).bind("Test Movie").bind(2024i32)
.execute(&pool).await.unwrap();
// Local review (remote_actor_url IS NULL)
let r1 = uuid::Uuid::new_v4().to_string();
sqlx::query("INSERT INTO reviews (id, movie_id, user_id, rating, watched_at, created_at) VALUES (?, ?, ?, ?, ?, ?)")
.bind(&r1).bind(&movie_id).bind(&user_id).bind(4i32)
.bind("2024-01-01 00:00:00").bind("2024-01-01 00:00:00")
.execute(&pool).await.unwrap();
// Remote review (remote_actor_url IS NOT NULL)
let r2 = uuid::Uuid::new_v4().to_string();
sqlx::query("INSERT INTO reviews (id, movie_id, user_id, rating, watched_at, created_at, remote_actor_url) VALUES (?, ?, ?, ?, ?, ?, ?)")
.bind(&r2).bind(&movie_id).bind(&user_id).bind(3i32)
.bind("2024-01-01 00:00:00").bind("2024-01-01 00:00:00").bind("https://remote/user")
.execute(&pool).await.unwrap();
let count = repo.count_local_posts().await.unwrap();
assert_eq!(count, 1);
}
}

View File

@@ -37,6 +37,8 @@ impl SqliteUserRepository {
username_str: String, username_str: String,
hash_str: String, hash_str: String,
role: UserRole, role: UserRole,
bio: Option<String>,
avatar_path: Option<String>,
) -> Result<User, DomainError> { ) -> Result<User, DomainError> {
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()))?;
@@ -52,6 +54,8 @@ impl SqliteUserRepository {
username, username,
hash, hash,
role, role,
bio,
avatar_path,
)) ))
} }
} }
@@ -61,7 +65,7 @@ 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!(
"SELECT id, email, username, password_hash, role FROM users WHERE email = ?", "SELECT id, email, username, password_hash, role, bio, avatar_path FROM users WHERE email = ?",
email_str email_str
) )
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
@@ -75,6 +79,8 @@ impl UserRepository for SqliteUserRepository {
r.username, r.username,
r.password_hash, r.password_hash,
Self::parse_role(&r.role), Self::parse_role(&r.role),
r.bio,
r.avatar_path,
) )
}) })
.transpose() .transpose()
@@ -83,7 +89,7 @@ impl UserRepository for SqliteUserRepository {
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!(
"SELECT id, email, username, password_hash, role FROM users WHERE username = ?", "SELECT id, email, username, password_hash, role, bio, avatar_path FROM users WHERE username = ?",
username_str username_str
) )
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
@@ -97,6 +103,8 @@ impl UserRepository for SqliteUserRepository {
r.username, r.username,
r.password_hash, r.password_hash,
Self::parse_role(&r.role), Self::parse_role(&r.role),
r.bio,
r.avatar_path,
) )
}) })
.transpose() .transpose()
@@ -140,7 +148,7 @@ 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!(
"SELECT id, email, username, password_hash, role FROM users WHERE id = ?", "SELECT id, email, username, password_hash, role, bio, avatar_path FROM users WHERE id = ?",
id_str id_str
) )
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
@@ -154,11 +162,30 @@ impl UserRepository for SqliteUserRepository {
r.username, r.username,
r.password_hash, r.password_hash,
Self::parse_role(&r.role), Self::parse_role(&r.role),
r.bio,
r.avatar_path,
) )
}) })
.transpose() .transpose()
} }
async fn update_profile(
&self,
user_id: &UserId,
bio: Option<String>,
avatar_path: Option<String>,
) -> Result<(), DomainError> {
let id_str = user_id.value().to_string();
sqlx::query("UPDATE users SET bio = ?, avatar_path = ? WHERE id = ?")
.bind(&bio)
.bind(&avatar_path)
.bind(&id_str)
.execute(&self.pool)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
Ok(())
}
async fn list_with_stats(&self) -> Result<Vec<domain::models::UserSummary>, DomainError> { async fn list_with_stats(&self) -> Result<Vec<domain::models::UserSummary>, DomainError> {
sqlx::query_as!( sqlx::query_as!(
UserSummaryRow, UserSummaryRow,
@@ -183,12 +210,14 @@ impl UserRepository for SqliteUserRepository {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use domain::models::UserRole;
use domain::value_objects::{Email, PasswordHash, Username};
use sqlx::SqlitePool; 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')" "CREATE TABLE users (id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, created_at TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'standard', bio TEXT, avatar_path TEXT)"
) )
.execute(&pool) .execute(&pool)
.await .await
@@ -227,4 +256,48 @@ mod tests {
assert!(result.is_some()); assert!(result.is_some());
assert_eq!(result.unwrap().email().value(), "test@example.com"); assert_eq!(result.unwrap().email().value(), "test@example.com");
} }
#[tokio::test]
async fn update_profile_persists_bio_and_avatar() {
let (_, repo) = setup().await;
let user = domain::models::User::new(
Email::new("test@example.com".to_string()).unwrap(),
Username::new("testuser".to_string()).unwrap(),
PasswordHash::new("hash".to_string()).unwrap(),
UserRole::Standard,
);
repo.save(&user).await.unwrap();
repo.update_profile(
user.id(),
Some("My biography".to_string()),
Some("avatars/user1".to_string()),
)
.await
.unwrap();
let found = repo.find_by_id(user.id()).await.unwrap().unwrap();
assert_eq!(found.bio(), Some("My biography"));
assert_eq!(found.avatar_path(), Some("avatars/user1"));
}
#[tokio::test]
async fn update_profile_clears_fields_with_none() {
let (_, repo) = setup().await;
let user = domain::models::User::new(
Email::new("test2@example.com".to_string()).unwrap(),
Username::new("testuser2".to_string()).unwrap(),
PasswordHash::new("hash".to_string()).unwrap(),
UserRole::Standard,
);
repo.save(&user).await.unwrap();
repo.update_profile(user.id(), Some("bio".to_string()), Some("path".to_string()))
.await
.unwrap();
repo.update_profile(user.id(), None, None).await.unwrap();
let found = repo.find_by_id(user.id()).await.unwrap().unwrap();
assert_eq!(found.bio(), None);
assert_eq!(found.avatar_path(), None);
}
} }

View File

@@ -1,8 +1,9 @@
use application::ports::{ use application::ports::{
ActivityFeedPageData, FollowersPageData, FollowingPageData, HtmlPageContext, HtmlRenderer, ActivityFeedPageData, BlockedActorEntry, BlockedActorsPageData, BlockedDomainEntry,
BlockedDomainsPageData, FollowersPageData, FollowingPageData, HtmlPageContext, HtmlRenderer,
ImportMappingPageData, ImportPreviewPageData, ImportPreviewRow, ImportProfileView, ImportMappingPageData, ImportPreviewPageData, ImportPreviewRow, ImportProfileView,
ImportRowStatus, ImportUploadPageData, LoginPageData, MovieDetailPageData, NewReviewPageData, ImportRowStatus, ImportUploadPageData, LoginPageData, MovieDetailPageData, NewReviewPageData,
ProfilePageData, RegisterPageData, UsersPageData, ProfilePageData, ProfileSettingsPageData, RegisterPageData, UsersPageData,
}; };
use askama::Template; use askama::Template;
use chrono::Datelike; use chrono::Datelike;
@@ -224,6 +225,20 @@ struct FollowersTemplate {
error: Option<String>, error: Option<String>,
} }
#[derive(Template)]
#[template(path = "blocked_domains.html")]
struct BlockedDomainsTemplate<'a> {
ctx: &'a HtmlPageContext,
domains: &'a [BlockedDomainEntry],
}
#[derive(Template)]
#[template(path = "blocked_actors.html")]
struct BlockedActorsTemplate<'a> {
ctx: &'a HtmlPageContext,
actors: &'a [BlockedActorEntry],
}
struct HeatmapCell { struct HeatmapCell {
month_label: String, month_label: String,
count: i64, count: i64,
@@ -305,6 +320,15 @@ fn bar_height_px(avg_rating: f64) -> i64 {
(avg_rating / 5.0 * 60.0) as i64 (avg_rating / 5.0 * 60.0) as i64
} }
#[derive(Template)]
#[template(path = "profile_settings.html")]
struct ProfileSettingsTemplate<'a> {
ctx: &'a HtmlPageContext,
bio: Option<&'a str>,
avatar_url: Option<&'a str>,
saved: bool,
}
#[derive(Template)] #[derive(Template)]
#[template(path = "import_upload.html")] #[template(path = "import_upload.html")]
struct ImportUploadTemplate<'a> { struct ImportUploadTemplate<'a> {
@@ -649,4 +673,36 @@ impl HtmlRenderer for AskamaHtmlRenderer {
.render() .render()
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())
} }
fn render_profile_settings_page(
&self,
data: ProfileSettingsPageData,
) -> Result<String, String> {
ProfileSettingsTemplate {
ctx: &data.ctx,
bio: data.bio.as_deref(),
avatar_url: data.avatar_url.as_deref(),
saved: data.saved,
}
.render()
.map_err(|e| e.to_string())
}
fn render_blocked_domains_page(&self, data: BlockedDomainsPageData) -> Result<String, String> {
BlockedDomainsTemplate {
ctx: &data.ctx,
domains: &data.domains,
}
.render()
.map_err(|e| e.to_string())
}
fn render_blocked_actors_page(&self, data: BlockedActorsPageData) -> Result<String, String> {
BlockedActorsTemplate {
ctx: &data.ctx,
actors: &data.actors,
}
.render()
.map_err(|e| e.to_string())
}
} }

View File

@@ -0,0 +1,24 @@
{% extends "base.html" %}
{% block content %}
<h2>Blocked Users</h2>
{% if actors.is_empty() %}
<p>No users blocked.</p>
{% else %}
<ul class="following-list">
{% for a in actors %}
<li class="following-item">
{% if let Some(avatar) = a.avatar_url %}
<img src="{{ avatar }}" alt="avatar" style="width:32px;height:32px;border-radius:50%" />
{% endif %}
<strong>{{ a.handle }}</strong>{% if let Some(name) = a.display_name %} ({{ name }}){% endif %}
<form method="POST" action="/social/unblock" style="display:inline">
<input type="hidden" name="_csrf" value="{{ ctx.csrf_token }}" />
<input type="hidden" name="actor_url" value="{{ a.url }}" />
<button type="submit">Unblock</button>
</form>
</li>
{% endfor %}
</ul>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,35 @@
{% extends "base.html" %}
{% block content %}
<h2>Blocked Domains</h2>
<form method="POST" action="/admin/blocked-domains">
<input type="hidden" name="_csrf" value="{{ ctx.csrf_token }}" />
<div>
<label for="domain">Domain</label>
<input id="domain" type="text" name="domain" placeholder="spam.example.com" required />
</div>
<div>
<label for="reason">Reason (optional)</label>
<input id="reason" type="text" name="reason" />
</div>
<button type="submit">Block Domain</button>
</form>
{% if domains.is_empty() %}
<p>No domains blocked.</p>
{% else %}
<ul>
{% for d in domains %}
<li>
<strong>{{ d.domain }}</strong>{% if let Some(r) = d.reason %} — {{ r }}{% endif %}
({{ d.blocked_at }})
<form method="POST" action="/admin/blocked-domains/remove" style="display:inline">
<input type="hidden" name="_csrf" value="{{ ctx.csrf_token }}" />
<input type="hidden" name="domain" value="{{ d.domain }}" />
<button type="submit">Unblock</button>
</form>
</li>
{% endfor %}
</ul>
{% endif %}
{% endblock %}

View File

@@ -20,6 +20,11 @@
<input type="hidden" name="_csrf" value="{{ ctx.csrf_token }}"> <input type="hidden" name="_csrf" value="{{ ctx.csrf_token }}">
<button type="submit">Remove</button> <button type="submit">Remove</button>
</form> </form>
<form method="POST" action="/social/block" style="display:inline">
<input type="hidden" name="actor_url" value="{{ actor.url }}">
<input type="hidden" name="_csrf" value="{{ ctx.csrf_token }}">
<button type="submit">Block</button>
</form>
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>

View File

@@ -20,6 +20,11 @@
<input type="hidden" name="_csrf" value="{{ ctx.csrf_token }}"> <input type="hidden" name="_csrf" value="{{ ctx.csrf_token }}">
<button type="submit">Unfollow</button> <button type="submit">Unfollow</button>
</form> </form>
<form method="POST" action="/social/block" style="display:inline">
<input type="hidden" name="actor_url" value="{{ actor.url }}">
<input type="hidden" name="_csrf" value="{{ ctx.csrf_token }}">
<button type="submit">Block</button>
</form>
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>

View File

@@ -0,0 +1,25 @@
{% extends "base.html" %}
{% block content %}
<h1>Profile Settings</h1>
{% if saved %}
<p class="success">Saved.</p>
{% endif %}
<form method="post" action="/settings/profile" enctype="multipart/form-data">
<input type="hidden" name="_csrf" value="{{ ctx.csrf_token }}">
<label>
Bio<br>
<textarea name="bio">{% if let Some(b) = bio %}{{ b }}{% endif %}</textarea>
</label>
{% if let Some(url) = avatar_url %}
<div>
<p>Current avatar:</p>
<img src="{{ url }}" alt="Current avatar" style="max-width:128px;max-height:128px;">
</div>
{% endif %}
<label>
Avatar image<br>
<input type="file" name="avatar" accept="image/jpeg,image/png,image/webp">
</label>
<button type="submit">Save</button>
</form>
{% endblock %}

View File

@@ -2,9 +2,10 @@ use std::sync::Arc;
use domain::ports::{ use domain::ports::{
AuthService, DiaryExporter, DiaryRepository, DocumentParser, EventPublisher, AuthService, DiaryExporter, DiaryRepository, DocumentParser, EventPublisher,
ImageStorage,
ImportProfileRepository, ImportSessionRepository, ImportProfileRepository, ImportSessionRepository,
MetadataClient, MovieRepository, PasswordHasher, PosterFetcherClient, MetadataClient, MovieRepository, PasswordHasher, PosterFetcherClient,
PosterStorage, ReviewRepository, StatsRepository, UserRepository, ReviewRepository, StatsRepository, UserRepository,
}; };
use crate::config::AppConfig; use crate::config::AppConfig;
@@ -19,7 +20,7 @@ pub struct AppContext {
pub stats_repository: Arc<dyn StatsRepository>, pub stats_repository: Arc<dyn StatsRepository>,
pub metadata_client: Arc<dyn MetadataClient>, pub metadata_client: Arc<dyn MetadataClient>,
pub poster_fetcher: Arc<dyn PosterFetcherClient>, pub poster_fetcher: Arc<dyn PosterFetcherClient>,
pub poster_storage: Arc<dyn PosterStorage>, pub image_storage: Arc<dyn ImageStorage>,
pub event_publisher: Arc<dyn EventPublisher>, pub event_publisher: Arc<dyn EventPublisher>,
pub auth_service: Arc<dyn AuthService>, pub auth_service: Arc<dyn AuthService>,
pub password_hasher: Arc<dyn PasswordHasher>, pub password_hasher: Arc<dyn PasswordHasher>,

View File

@@ -145,6 +145,36 @@ pub struct ImportPreviewPageData {
pub rows: Vec<ImportPreviewRow>, pub rows: Vec<ImportPreviewRow>,
} }
pub struct ProfileSettingsPageData {
pub ctx: HtmlPageContext,
pub bio: Option<String>,
pub avatar_url: Option<String>,
pub saved: bool,
}
pub struct BlockedDomainEntry {
pub domain: String,
pub reason: Option<String>,
pub blocked_at: String,
}
pub struct BlockedDomainsPageData {
pub ctx: HtmlPageContext,
pub domains: Vec<BlockedDomainEntry>,
}
pub struct BlockedActorEntry {
pub url: String,
pub handle: String,
pub display_name: Option<String>,
pub avatar_url: Option<String>,
}
pub struct BlockedActorsPageData {
pub ctx: HtmlPageContext,
pub actors: Vec<BlockedActorEntry>,
}
pub trait HtmlRenderer: Send + Sync { pub trait HtmlRenderer: Send + Sync {
fn render_diary_page( fn render_diary_page(
&self, &self,
@@ -163,6 +193,12 @@ pub trait HtmlRenderer: Send + Sync {
fn render_import_upload_page(&self, data: ImportUploadPageData) -> Result<String, String>; fn render_import_upload_page(&self, data: ImportUploadPageData) -> Result<String, String>;
fn render_import_mapping_page(&self, data: ImportMappingPageData) -> Result<String, String>; fn render_import_mapping_page(&self, data: ImportMappingPageData) -> Result<String, String>;
fn render_import_preview_page(&self, data: ImportPreviewPageData) -> Result<String, String>; fn render_import_preview_page(&self, data: ImportPreviewPageData) -> Result<String, String>;
fn render_profile_settings_page(
&self,
data: ProfileSettingsPageData,
) -> Result<String, String>;
fn render_blocked_domains_page(&self, data: BlockedDomainsPageData) -> Result<String, String>;
fn render_blocked_actors_page(&self, data: BlockedActorsPageData) -> Result<String, String>;
} }
pub trait RssFeedRenderer: Send + Sync { pub trait RssFeedRenderer: Send + Sync {

View File

@@ -18,3 +18,4 @@ pub mod log_review;
pub mod login; pub mod login;
pub mod register; pub mod register;
pub mod sync_poster; pub mod sync_poster;
pub mod update_profile;

View File

@@ -1,6 +1,6 @@
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
value_objects::{ExternalMetadataId, MovieId}, value_objects::{ExternalMetadataId, MovieId, PosterPath},
}; };
use crate::{commands::SyncPosterCommand, context::AppContext}; use crate::{commands::SyncPosterCommand, context::AppContext};
@@ -36,11 +36,12 @@ pub async fn execute(ctx: &AppContext, cmd: SyncPosterCommand) -> Result<(), Dom
let image_bytes = ctx.poster_fetcher.fetch_poster_bytes(&poster_url).await?; let image_bytes = ctx.poster_fetcher.fetch_poster_bytes(&poster_url).await?;
let stored_path = ctx let stored_path = ctx
.poster_storage .image_storage
.store_poster(&movie_id, &image_bytes) .store(&movie_id.value().to_string(), &image_bytes)
.await?; .await?;
let poster_path = PosterPath::new(stored_path)?;
movie.update_poster(stored_path); movie.update_poster(poster_path);
ctx.movie_repository.upsert_movie(&movie).await?; ctx.movie_repository.upsert_movie(&movie).await?;
Ok(()) Ok(())

View File

@@ -0,0 +1,51 @@
use domain::{
errors::DomainError,
events::DomainEvent,
value_objects::UserId,
};
use crate::context::AppContext;
pub struct UpdateProfileCommand {
pub user_id: uuid::Uuid,
pub bio: Option<String>,
pub avatar_bytes: Option<Vec<u8>>,
pub avatar_content_type: Option<String>,
}
pub async fn execute(ctx: &AppContext, cmd: UpdateProfileCommand) -> Result<(), DomainError> {
let user_id = UserId::from_uuid(cmd.user_id);
let user = ctx
.user_repository
.find_by_id(&user_id)
.await?
.ok_or_else(|| DomainError::NotFound("User not found".into()))?;
let new_avatar_path = if let Some(bytes) = cmd.avatar_bytes {
let content_type = cmd.avatar_content_type.as_deref().unwrap_or("");
if !["image/jpeg", "image/png", "image/webp"].contains(&content_type) {
return Err(DomainError::ValidationError(
"Avatar must be jpeg, png, or webp".into(),
));
}
if let Some(old_path) = user.avatar_path() {
let _ = ctx.image_storage.delete(old_path).await;
}
let key = format!("avatars/{}", user_id.value());
let stored = ctx.image_storage.store(&key, &bytes).await?;
Some(stored)
} else {
user.avatar_path().map(|s| s.to_string())
};
ctx.user_repository
.update_profile(&user_id, cmd.bio, new_avatar_path)
.await?;
ctx.event_publisher
.publish(&DomainEvent::UserUpdated { user_id })
.await?;
Ok(())
}

View File

@@ -94,6 +94,7 @@ mod tests {
DomainEvent::ReviewLogged { .. } => "review_logged", DomainEvent::ReviewLogged { .. } => "review_logged",
DomainEvent::ReviewUpdated { .. } => "review_updated", DomainEvent::ReviewUpdated { .. } => "review_updated",
DomainEvent::MovieDeleted { .. } => "movie_deleted", DomainEvent::MovieDeleted { .. } => "movie_deleted",
DomainEvent::UserUpdated { .. } => "user_updated",
}; };
self.calls.lock().unwrap().push(label); self.calls.lock().unwrap().push(label);
Ok(()) Ok(())

View File

@@ -30,6 +30,9 @@ pub enum DomainEvent {
movie_id: MovieId, movie_id: MovieId,
poster_path: Option<PosterPath>, poster_path: Option<PosterPath>,
}, },
UserUpdated {
user_id: UserId,
},
} }
#[async_trait] #[async_trait]

View File

@@ -290,6 +290,8 @@ pub struct User {
username: Username, username: Username,
password_hash: PasswordHash, password_hash: PasswordHash,
role: UserRole, role: UserRole,
bio: Option<String>,
avatar_path: Option<String>,
} }
impl User { impl User {
@@ -305,6 +307,8 @@ impl User {
username, username,
password_hash, password_hash,
role, role,
bio: None,
avatar_path: None,
} }
} }
@@ -314,6 +318,8 @@ impl User {
username: Username, username: Username,
password_hash: PasswordHash, password_hash: PasswordHash,
role: UserRole, role: UserRole,
bio: Option<String>,
avatar_path: Option<String>,
) -> Self { ) -> Self {
Self { Self {
id, id,
@@ -321,6 +327,8 @@ impl User {
username, username,
password_hash, password_hash,
role, role,
bio,
avatar_path,
} }
} }
@@ -328,6 +336,11 @@ impl User {
self.password_hash = new_hash; self.password_hash = new_hash;
} }
pub fn update_profile(&mut self, bio: Option<String>, avatar_path: Option<String>) {
self.bio = bio;
self.avatar_path = avatar_path;
}
pub fn email(&self) -> &Email { pub fn email(&self) -> &Email {
&self.email &self.email
} }
@@ -343,6 +356,13 @@ impl User {
pub fn role(&self) -> &UserRole { pub fn role(&self) -> &UserRole {
&self.role &self.role
} }
pub fn bio(&self) -> Option<&str> {
self.bio.as_deref()
}
pub fn avatar_path(&self) -> Option<&str> {
self.avatar_path.as_deref()
}
} }
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
@@ -435,3 +455,38 @@ pub enum ExportFormat {
Csv, Csv,
Json, Json,
} }
#[cfg(test)]
mod tests {
use super::*;
use crate::value_objects::{Email, PasswordHash, UserId, Username};
fn make_user() -> User {
User::from_persistence(
UserId::generate(),
Email::new("a@b.com".to_string()).unwrap(),
Username::new("alice".to_string()).unwrap(),
PasswordHash::new("hash".to_string()).unwrap(),
UserRole::Standard,
None,
None,
)
}
#[test]
fn update_profile_sets_fields() {
let mut user = make_user();
user.update_profile(Some("My bio".to_string()), Some("avatars/abc".to_string()));
assert_eq!(user.bio(), Some("My bio"));
assert_eq!(user.avatar_path(), Some("avatars/abc"));
}
#[test]
fn update_profile_clears_with_none() {
let mut user = make_user();
user.update_profile(Some("bio".to_string()), Some("path".to_string()));
user.update_profile(None, None);
assert_eq!(user.bio(), None);
assert_eq!(user.avatar_path(), None);
}
}

View File

@@ -12,7 +12,7 @@ use crate::{
}, },
value_objects::{ value_objects::{
Email, ExternalMetadataId, ImportProfileId, ImportSessionId, MovieId, MovieTitle, Email, ExternalMetadataId, ImportProfileId, ImportSessionId, MovieId, MovieTitle,
PasswordHash, PosterPath, PosterUrl, ReleaseYear, ReviewId, UserId, Username, PasswordHash, PosterUrl, ReleaseYear, ReviewId, UserId, Username,
}, },
}; };
@@ -116,6 +116,7 @@ pub trait DiaryRepository: Send + Sync {
movie_id: &MovieId, movie_id: &MovieId,
page: &PageParams, page: &PageParams,
) -> Result<Paginated<FeedEntry>, DomainError>; ) -> Result<Paginated<FeedEntry>, DomainError>;
async fn count_local_posts(&self) -> Result<u64, DomainError>;
} }
#[async_trait] #[async_trait]
@@ -150,16 +151,11 @@ pub trait PosterFetcherClient: Send + Sync {
} }
#[async_trait] #[async_trait]
pub trait PosterStorage: Send + Sync { pub trait ImageStorage: Send + Sync {
async fn store_poster( /// Stores `image_bytes` at `key` and returns the stored key.
&self, async fn store(&self, key: &str, image_bytes: &[u8]) -> Result<String, DomainError>;
movie_id: &MovieId, async fn get(&self, key: &str) -> Result<Vec<u8>, DomainError>;
image_bytes: &[u8], async fn delete(&self, key: &str) -> Result<(), DomainError>;
) -> Result<PosterPath, DomainError>;
async fn get_poster(&self, poster_path: &PosterPath) -> Result<Vec<u8>, DomainError>;
async fn delete_poster(&self, path: &PosterPath) -> Result<(), DomainError>;
} }
pub struct GeneratedToken { pub struct GeneratedToken {
@@ -180,6 +176,12 @@ pub trait UserRepository: Send + Sync {
async fn save(&self, user: &User) -> Result<(), DomainError>; async fn save(&self, user: &User) -> Result<(), DomainError>;
async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError>; async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError>;
async fn list_with_stats(&self) -> Result<Vec<UserSummary>, DomainError>; async fn list_with_stats(&self) -> Result<Vec<UserSummary>, DomainError>;
async fn update_profile(
&self,
user_id: &UserId,
bio: Option<String>,
avatar_path: Option<String>,
) -> Result<(), DomainError>;
} }
#[async_trait] #[async_trait]

View File

@@ -47,7 +47,7 @@ application = { workspace = true }
auth = { workspace = true } auth = { workspace = true }
metadata = { workspace = true } metadata = { workspace = true }
poster-fetcher = { workspace = true } poster-fetcher = { workspace = true }
poster-storage = { workspace = true } image-storage = { workspace = true }
template-askama = { workspace = true } template-askama = { workspace = true }
nats = { workspace = true, optional = true } nats = { workspace = true, optional = true }
rss = { workspace = true } rss = { workspace = true }

View File

@@ -278,6 +278,29 @@ pub struct FollowerActionForm {
pub csrf_token: String, pub csrf_token: String,
} }
#[derive(Deserialize)]
pub struct BlockDomainForm {
pub domain: String,
#[serde(default)]
pub reason: Option<String>,
#[serde(rename = "_csrf", default)]
pub csrf_token: String,
}
#[derive(Deserialize)]
pub struct RemoveDomainForm {
pub domain: String,
#[serde(rename = "_csrf", default)]
pub csrf_token: String,
}
#[derive(Deserialize)]
pub struct ActorUrlForm {
pub actor_url: String,
#[serde(rename = "_csrf", default)]
pub csrf_token: String,
}
#[derive(serde::Deserialize, Default)] #[derive(serde::Deserialize, Default)]
pub struct ProfileQueryParams { pub struct ProfileQueryParams {
pub view: Option<String>, pub view: Option<String>,
@@ -433,6 +456,13 @@ pub struct PaginationQueryParams {
pub offset: Option<u32>, pub offset: Option<u32>,
} }
#[derive(serde::Serialize, utoipa::ToSchema)]
pub struct ProfileResponse {
pub username: String,
pub bio: Option<String>,
pub avatar_url: Option<String>,
}
#[derive(serde::Serialize, utoipa::ToSchema)] #[derive(serde::Serialize, utoipa::ToSchema)]
pub struct MovieStatsDto { pub struct MovieStatsDto {
pub total_count: u64, pub total_count: u64,
@@ -465,6 +495,27 @@ pub struct MovieDetailResponse {
pub reviews: SocialFeedResponse, pub reviews: SocialFeedResponse,
} }
#[derive(serde::Serialize)]
pub struct BlockedDomainResponse {
pub domain: String,
pub reason: Option<String>,
pub blocked_at: String,
}
#[derive(serde::Deserialize)]
pub struct AddBlockedDomainRequest {
pub domain: String,
pub reason: Option<String>,
}
#[derive(serde::Serialize)]
pub struct BlockedActorResponse {
pub url: String,
pub handle: String,
pub display_name: Option<String>,
pub avatar_url: Option<String>,
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;

View File

@@ -137,12 +137,12 @@ mod tests {
collections::{PageParams, Paginated}, collections::{PageParams, Paginated},
}, },
ports::{ ports::{
AuthService, DiaryRepository, EventPublisher, GeneratedToken, MetadataClient, AuthService, DiaryRepository, EventPublisher, GeneratedToken, ImageStorage,
MovieRepository, PasswordHasher, PosterFetcherClient, PosterStorage, ReviewRepository, MetadataClient, MovieRepository, PasswordHasher, PosterFetcherClient, ReviewRepository,
StatsRepository, UserRepository, StatsRepository, UserRepository,
}, },
value_objects::{ value_objects::{
Email, ExternalMetadataId, MovieId, MovieTitle, PasswordHash, PosterPath, PosterUrl, Email, ExternalMetadataId, MovieId, MovieTitle, PasswordHash, PosterUrl,
ReleaseYear, ReviewId, UserId, ReleaseYear, ReviewId, UserId,
}, },
}; };
@@ -232,6 +232,9 @@ mod tests {
) -> Result<Paginated<FeedEntry>, DomainError> { ) -> Result<Paginated<FeedEntry>, DomainError> {
panic!() panic!()
} }
async fn count_local_posts(&self) -> Result<u64, DomainError> {
panic!()
}
} }
#[cfg(feature = "federation")] #[cfg(feature = "federation")]
#[async_trait::async_trait] #[async_trait::async_trait]
@@ -279,16 +282,10 @@ mod tests {
} }
} }
#[async_trait::async_trait] #[async_trait::async_trait]
impl PosterStorage for Panic { impl ImageStorage for Panic {
async fn store_poster(&self, _: &MovieId, _: &[u8]) -> Result<PosterPath, DomainError> { async fn store(&self, _: &str, _: &[u8]) -> Result<String, DomainError> { panic!() }
panic!() async fn get(&self, _: &str) -> Result<Vec<u8>, DomainError> { panic!() }
} async fn delete(&self, _: &str) -> Result<(), DomainError> { panic!() }
async fn get_poster(&self, _: &PosterPath) -> Result<Vec<u8>, DomainError> {
panic!()
}
async fn delete_poster(&self, _: &PosterPath) -> Result<(), DomainError> {
panic!()
}
} }
#[async_trait::async_trait] #[async_trait::async_trait]
impl AuthService for Panic { impl AuthService for Panic {
@@ -334,6 +331,9 @@ mod tests {
async fn list_with_stats(&self) -> Result<Vec<domain::models::UserSummary>, DomainError> { async fn list_with_stats(&self) -> Result<Vec<domain::models::UserSummary>, DomainError> {
panic!() panic!()
} }
async fn update_profile(&self, _: &UserId, _: Option<String>, _: Option<String>) -> Result<(), DomainError> {
panic!()
}
} }
#[async_trait::async_trait] #[async_trait::async_trait]
impl EventPublisher for Panic { impl EventPublisher for Panic {
@@ -442,6 +442,9 @@ mod tests {
fn render_import_upload_page(&self, _: application::ports::ImportUploadPageData) -> Result<String, String> { panic!() } fn render_import_upload_page(&self, _: application::ports::ImportUploadPageData) -> Result<String, String> { panic!() }
fn render_import_mapping_page(&self, _: application::ports::ImportMappingPageData) -> Result<String, String> { panic!() } fn render_import_mapping_page(&self, _: application::ports::ImportMappingPageData) -> Result<String, String> { panic!() }
fn render_import_preview_page(&self, _: application::ports::ImportPreviewPageData) -> Result<String, String> { panic!() } fn render_import_preview_page(&self, _: application::ports::ImportPreviewPageData) -> Result<String, String> { panic!() }
fn render_profile_settings_page(&self, _: application::ports::ProfileSettingsPageData) -> Result<String, String> { panic!() }
fn render_blocked_domains_page(&self, _: application::ports::BlockedDomainsPageData) -> Result<String, String> { panic!() }
fn render_blocked_actors_page(&self, _: application::ports::BlockedActorsPageData) -> Result<String, String> { panic!() }
} }
impl crate::ports::RssFeedRenderer for Panic { impl crate::ports::RssFeedRenderer for Panic {
fn render_feed(&self, _: &[DiaryEntry], _: &str) -> Result<String, String> { fn render_feed(&self, _: &[DiaryEntry], _: &str) -> Result<String, String> {
@@ -474,7 +477,7 @@ mod tests {
stats_repository: Arc::clone(&repo) as _, stats_repository: Arc::clone(&repo) as _,
metadata_client: Arc::clone(&repo) as _, metadata_client: Arc::clone(&repo) as _,
poster_fetcher: Arc::clone(&repo) as _, poster_fetcher: Arc::clone(&repo) as _,
poster_storage: Arc::clone(&repo) as _, image_storage: Arc::clone(&repo) as _,
event_publisher: Arc::clone(&repo) as _, event_publisher: Arc::clone(&repo) as _,
password_hasher: Arc::clone(&repo) as _, password_hasher: Arc::clone(&repo) as _,
user_repository: Arc::clone(&repo) as _, user_repository: Arc::clone(&repo) as _,

View File

@@ -1,6 +1,6 @@
use axum::{ use axum::{
Json, Json,
extract::{Path, Query, State}, extract::{Multipart, Path, Query, State},
http::StatusCode, http::StatusCode,
response::IntoResponse, response::IntoResponse,
}; };
@@ -20,7 +20,7 @@ use application::{
delete_review, export_diary as export_diary_uc, get_activity_feed as get_feed_uc, delete_review, export_diary as export_diary_uc, get_activity_feed as get_feed_uc,
get_diary, get_movie_social_page, get_review_history, get_diary, get_movie_social_page, get_review_history,
get_user_profile as get_user_profile_uc, get_users, log_review, login as login_uc, get_user_profile as get_user_profile_uc, get_users, log_review, login as login_uc,
register as register_uc, sync_poster, register as register_uc, sync_poster, update_profile,
}, },
}; };
use domain::{ use domain::{
@@ -37,8 +37,8 @@ use crate::{
ActivityFeedQueryParams, ActivityFeedResponse, DiaryEntryDto, DiaryQueryParams, ActivityFeedQueryParams, ActivityFeedResponse, DiaryEntryDto, DiaryQueryParams,
DiaryResponse, DirectorStatDto, ExportQueryParams, FeedEntryDto, LogReviewData, DiaryResponse, DirectorStatDto, ExportQueryParams, FeedEntryDto, LogReviewData,
LogReviewRequest, LoginRequest, LoginResponse, MonthActivityDto, MonthlyRatingDto, LogReviewRequest, LoginRequest, LoginResponse, MonthActivityDto, MonthlyRatingDto,
MovieDetailResponse, MovieDto, MovieStatsDto, PaginationQueryParams, RegisterRequest, MovieDetailResponse, MovieDto, MovieStatsDto, PaginationQueryParams, ProfileResponse,
ReviewDto, ReviewHistoryResponse, SocialFeedResponse, SocialReviewDto, RegisterRequest, ReviewDto, ReviewHistoryResponse, SocialFeedResponse, SocialReviewDto,
UserProfileQueryParams, UserProfileResponse, UserStatsDto, UserSummaryDto, UserTrendsDto, UserProfileQueryParams, UserProfileResponse, UserStatsDto, UserSummaryDto, UserTrendsDto,
UsersResponse, UsersResponse,
}, },
@@ -290,6 +290,101 @@ pub async fn get_movie_detail(
})) }))
} }
#[utoipa::path(
get, path = "/api/v1/profile",
responses(
(status = 200, body = ProfileResponse),
(status = 401, description = "Unauthorized"),
(status = 404, description = "User not found"),
),
security(("bearer_auth" = []))
)]
pub async fn get_profile(
State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser,
) -> impl IntoResponse {
let user = match state.app_ctx.user_repository.find_by_id(&user_id).await {
Ok(Some(u)) => u,
Ok(None) => return StatusCode::NOT_FOUND.into_response(),
Err(e) => {
tracing::error!("get_profile user lookup: {:?}", e);
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
}
};
let base_url = &state.app_ctx.config.base_url;
let avatar_url = user
.avatar_path()
.map(|path| format!("{}/images/{}", base_url, path));
Json(ProfileResponse {
username: user.username().value().to_string(),
bio: user.bio().map(|s| s.to_string()),
avatar_url,
})
.into_response()
}
#[utoipa::path(
put, path = "/api/v1/profile",
responses(
(status = 204, description = "Profile updated"),
(status = 400, description = "Invalid input"),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn update_profile_handler(
State(state): State<AppState>,
AuthenticatedUser(user_id): AuthenticatedUser,
mut multipart: Multipart,
) -> impl IntoResponse {
let mut bio: Option<String> = None;
let mut avatar_bytes: Option<Vec<u8>> = None;
let mut avatar_content_type: Option<String> = None;
while let Ok(Some(field)) = multipart.next_field().await {
let name = field.name().unwrap_or("").to_string();
match name.as_str() {
"bio" => {
if let Ok(text) = field.text().await {
bio = Some(text);
}
}
"avatar" => {
let content_type = field.content_type().map(|s| s.to_string());
if let Ok(bytes) = field.bytes().await {
if !bytes.is_empty() {
avatar_bytes = Some(bytes.to_vec());
avatar_content_type = content_type;
}
}
}
_ => {}
}
}
let cmd = update_profile::UpdateProfileCommand {
user_id: user_id.value(),
bio,
avatar_bytes,
avatar_content_type,
};
match update_profile::execute(&state.app_ctx, cmd).await {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(domain::errors::DomainError::ValidationError(msg)) => {
tracing::warn!("update_profile validation: {}", msg);
StatusCode::BAD_REQUEST.into_response()
}
Err(e) => {
tracing::error!("update_profile error: {:?}", e);
StatusCode::INTERNAL_SERVER_ERROR.into_response()
}
}
}
fn movie_to_dto(movie: &Movie) -> MovieDto { fn movie_to_dto(movie: &Movie) -> MovieDto {
MovieDto { MovieDto {
id: movie.id().value(), id: movie.id().value(),
@@ -316,6 +411,97 @@ fn entry_to_dto(entry: &DiaryEntry) -> DiaryEntryDto {
} }
} }
#[cfg(feature = "federation")]
pub async fn get_blocked_domains_admin(
State(state): State<AppState>,
_admin: crate::extractors::AdminUser,
) -> impl IntoResponse {
match state.ap_service.get_blocked_domains().await {
Ok(domains) => {
let response: Vec<crate::dtos::BlockedDomainResponse> = domains
.into_iter()
.map(|d| crate::dtos::BlockedDomainResponse {
domain: d.domain,
reason: d.reason,
blocked_at: d.blocked_at,
})
.collect();
axum::Json(response).into_response()
}
Err(e) => ap_err(e).into_response(),
}
}
#[cfg(feature = "federation")]
pub async fn add_blocked_domain_admin(
State(state): State<AppState>,
_admin: crate::extractors::AdminUser,
axum::Json(body): axum::Json<crate::dtos::AddBlockedDomainRequest>,
) -> impl IntoResponse {
match state.ap_service.add_blocked_domain(&body.domain, body.reason.as_deref()).await {
Ok(()) => StatusCode::CREATED.into_response(),
Err(e) => ap_err(e).into_response(),
}
}
#[cfg(feature = "federation")]
pub async fn remove_blocked_domain_admin(
State(state): State<AppState>,
_admin: crate::extractors::AdminUser,
axum::extract::Path(domain): axum::extract::Path<String>,
) -> impl IntoResponse {
match state.ap_service.remove_blocked_domain(&domain).await {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(e) => ap_err(e).into_response(),
}
}
#[cfg(feature = "federation")]
pub async fn block_actor_api(
State(state): State<AppState>,
user: AuthenticatedUser,
axum::Json(body): axum::Json<ActorUrlRequest>,
) -> impl IntoResponse {
match state.ap_service.block_actor(user.0.value(), &body.actor_url).await {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(e) => ap_err(e).into_response(),
}
}
#[cfg(feature = "federation")]
pub async fn unblock_actor_api(
State(state): State<AppState>,
user: AuthenticatedUser,
axum::Json(body): axum::Json<ActorUrlRequest>,
) -> impl IntoResponse {
match state.ap_service.unblock_actor(user.0.value(), &body.actor_url).await {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(e) => ap_err(e).into_response(),
}
}
#[cfg(feature = "federation")]
pub async fn get_blocked_actors_api(
State(state): State<AppState>,
user: AuthenticatedUser,
) -> impl IntoResponse {
match state.ap_service.get_blocked_actors(user.0.value()).await {
Ok(actors) => {
let response: Vec<crate::dtos::BlockedActorResponse> = actors
.into_iter()
.map(|a| crate::dtos::BlockedActorResponse {
url: a.url,
handle: a.handle,
display_name: a.display_name,
avatar_url: a.avatar_url,
})
.collect();
axum::Json(response).into_response()
}
Err(e) => ap_err(e).into_response(),
}
}
#[cfg(feature = "federation")] #[cfg(feature = "federation")]
fn ap_err(e: anyhow::Error) -> impl IntoResponse { fn ap_err(e: anyhow::Error) -> impl IntoResponse {
tracing::error!("ActivityPub error: {:?}", e); tracing::error!("ActivityPub error: {:?}", e);

View File

@@ -2,7 +2,7 @@ use std::str::FromStr;
use axum::{ use axum::{
Form, Form,
extract::{Extension, Path, Query, State}, extract::{Extension, Multipart, Path, Query, State},
http::{HeaderValue, StatusCode, header::SET_COOKIE}, http::{HeaderValue, StatusCode, header::SET_COOKIE},
response::{Html, IntoResponse, Redirect}, response::{Html, IntoResponse, Redirect},
}; };
@@ -10,30 +10,33 @@ use chrono::Utc;
use uuid::Uuid; use uuid::Uuid;
#[cfg(feature = "federation")] #[cfg(feature = "federation")]
use application::ports::{FollowersPageData, FollowingPageData}; use application::ports::{
BlockedActorEntry, BlockedActorsPageData, BlockedDomainEntry, BlockedDomainsPageData,
FollowersPageData, FollowingPageData,
};
use application::{ use application::{
commands::{DeleteReviewCommand, ExportCommand, LoginCommand, RegisterCommand}, commands::{DeleteReviewCommand, ExportCommand, LoginCommand, RegisterCommand},
ports::{ ports::{
HtmlPageContext, LoginPageData, MovieDetailPageData, NewReviewPageData, RegisterPageData, HtmlPageContext, LoginPageData, MovieDetailPageData, NewReviewPageData,
RemoteActorView, ProfileSettingsPageData, RegisterPageData, RemoteActorView,
}, },
queries::GetMovieSocialPageQuery, queries::GetMovieSocialPageQuery,
use_cases::{ use_cases::{
delete_review, export_diary as export_diary_uc, get_movie_social_page, log_review, delete_review, export_diary as export_diary_uc, get_movie_social_page, log_review,
login as login_uc, register as register_uc, login as login_uc, register as register_uc, update_profile,
}, },
}; };
use domain::models::ExportFormat; use domain::models::ExportFormat;
use domain::{errors::DomainError, value_objects::UserId}; use domain::{errors::DomainError, value_objects::UserId};
#[cfg(feature = "federation")] #[cfg(feature = "federation")]
use crate::dtos::{FollowForm, FollowerActionForm, UnfollowForm}; use crate::dtos::{ActorUrlForm, BlockDomainForm, FollowForm, FollowerActionForm, RemoveDomainForm, UnfollowForm};
use crate::{ use crate::{
csrf::CsrfToken, csrf::CsrfToken,
dtos::{ dtos::{
ErrorQuery, FeedQueryParams, LogReviewData, LogReviewForm, LoginForm, RegisterForm, ErrorQuery, FeedQueryParams, LogReviewData, LogReviewForm, LoginForm, RegisterForm,
}, },
extractors::{OptionalCookieUser, RequiredCookieUser}, extractors::{AdminUser, OptionalCookieUser, RequiredCookieUser},
state::AppState, state::AppState,
}; };
@@ -966,3 +969,251 @@ pub async fn get_movie_detail(
} }
} }
} }
#[derive(serde::Deserialize, Default)]
pub struct SavedQuery {
pub saved: Option<String>,
}
pub async fn get_profile_settings(
RequiredCookieUser(user_id): RequiredCookieUser,
State(state): State<AppState>,
Query(params): Query<SavedQuery>,
Extension(csrf): Extension<CsrfToken>,
) -> impl IntoResponse {
let mut ctx = build_page_context(&state, Some(user_id.clone()), csrf.0).await;
ctx.page_title = "Profile Settings — Movies Diary".to_string();
ctx.canonical_url = format!("{}/settings/profile", state.app_ctx.config.base_url);
let user = match state
.app_ctx
.user_repository
.find_by_id(&user_id)
.await
{
Ok(Some(u)) => u,
Ok(None) => return StatusCode::NOT_FOUND.into_response(),
Err(e) => {
tracing::error!("get_profile_settings user lookup: {:?}", e);
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
}
};
let base_url = &state.app_ctx.config.base_url;
let avatar_url = user
.avatar_path()
.map(|path| format!("{}/images/{}", base_url, path));
let saved = params.saved.as_deref() == Some("1");
let data = ProfileSettingsPageData {
ctx,
bio: user.bio().map(|s| s.to_string()),
avatar_url,
saved,
};
match state.html_renderer.render_profile_settings_page(data) {
Ok(html) => Html(html).into_response(),
Err(e) => {
tracing::error!("profile_settings template error: {}", e);
StatusCode::INTERNAL_SERVER_ERROR.into_response()
}
}
}
pub async fn get_tag(Path(tag): Path<String>) -> impl IntoResponse {
if tag.eq_ignore_ascii_case("moviesdiary") {
Redirect::temporary("/")
} else {
Redirect::temporary(&format!("/?search={}", tag))
}
}
#[cfg(feature = "federation")]
pub async fn get_blocked_domains_page(
AdminUser(user_id): AdminUser,
State(state): State<AppState>,
Extension(csrf): Extension<CsrfToken>,
) -> impl IntoResponse {
let mut ctx = build_page_context(&state, Some(user_id), csrf.0).await;
ctx.page_title = "Blocked Domains — Movies Diary".to_string();
ctx.canonical_url = format!("{}/admin/blocked-domains", state.app_ctx.config.base_url);
match state.ap_service.get_blocked_domains().await {
Ok(domains) => {
let data = BlockedDomainsPageData {
ctx,
domains: domains
.into_iter()
.map(|d| BlockedDomainEntry {
domain: d.domain,
reason: d.reason,
blocked_at: d.blocked_at,
})
.collect(),
};
match state.html_renderer.render_blocked_domains_page(data) {
Ok(html) => Html(html).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
}
}
Err(e) => {
tracing::error!("get_blocked_domains error: {:?}", e);
(StatusCode::INTERNAL_SERVER_ERROR, "Failed to load blocked domains").into_response()
}
}
}
#[cfg(feature = "federation")]
pub async fn post_blocked_domain(
AdminUser(_): AdminUser,
State(state): State<AppState>,
Extension(csrf): Extension<CsrfToken>,
Form(form): Form<BlockDomainForm>,
) -> impl IntoResponse {
if crate::csrf::mismatch(&csrf, &form.csrf_token) {
return StatusCode::FORBIDDEN.into_response();
}
let reason = form.reason.as_deref().filter(|s| !s.trim().is_empty());
match state.ap_service.add_blocked_domain(&form.domain, reason).await {
Ok(()) => Redirect::to("/admin/blocked-domains").into_response(),
Err(e) => {
tracing::error!("add_blocked_domain error: {:?}", e);
Redirect::to("/admin/blocked-domains").into_response()
}
}
}
#[cfg(feature = "federation")]
pub async fn post_remove_blocked_domain(
AdminUser(_): AdminUser,
State(state): State<AppState>,
Extension(csrf): Extension<CsrfToken>,
Form(form): Form<RemoveDomainForm>,
) -> impl IntoResponse {
if crate::csrf::mismatch(&csrf, &form.csrf_token) {
return StatusCode::FORBIDDEN.into_response();
}
match state.ap_service.remove_blocked_domain(&form.domain).await {
Ok(()) => Redirect::to("/admin/blocked-domains").into_response(),
Err(e) => {
tracing::error!("remove_blocked_domain error: {:?}", e);
Redirect::to("/admin/blocked-domains").into_response()
}
}
}
#[cfg(feature = "federation")]
pub async fn get_blocked_actors_page(
RequiredCookieUser(user_id): RequiredCookieUser,
State(state): State<AppState>,
Extension(csrf): Extension<CsrfToken>,
) -> impl IntoResponse {
let mut ctx = build_page_context(&state, Some(user_id.clone()), csrf.0).await;
ctx.page_title = "Blocked Users — Movies Diary".to_string();
ctx.canonical_url = format!("{}/social/blocked", state.app_ctx.config.base_url);
match state.ap_service.get_blocked_actors(user_id.value()).await {
Ok(actors) => {
let data = BlockedActorsPageData {
ctx,
actors: actors
.into_iter()
.map(|a| BlockedActorEntry {
url: a.url,
handle: a.handle,
display_name: a.display_name,
avatar_url: a.avatar_url,
})
.collect(),
};
match state.html_renderer.render_blocked_actors_page(data) {
Ok(html) => Html(html).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
}
}
Err(e) => {
tracing::error!("get_blocked_actors error: {:?}", e);
(StatusCode::INTERNAL_SERVER_ERROR, "Failed to load blocked users").into_response()
}
}
}
#[cfg(feature = "federation")]
pub async fn post_block_actor_html(
RequiredCookieUser(user_id): RequiredCookieUser,
State(state): State<AppState>,
Extension(csrf): Extension<CsrfToken>,
Form(form): Form<ActorUrlForm>,
) -> impl IntoResponse {
if crate::csrf::mismatch(&csrf, &form.csrf_token) {
return StatusCode::FORBIDDEN.into_response();
}
match state.ap_service.block_actor(user_id.value(), &form.actor_url).await {
Ok(()) => Redirect::to("/social/blocked").into_response(),
Err(e) => {
tracing::error!("block_actor html error: {:?}", e);
Redirect::to("/social/blocked").into_response()
}
}
}
#[cfg(feature = "federation")]
pub async fn post_unblock_actor(
RequiredCookieUser(user_id): RequiredCookieUser,
State(state): State<AppState>,
Extension(csrf): Extension<CsrfToken>,
Form(form): Form<ActorUrlForm>,
) -> impl IntoResponse {
if crate::csrf::mismatch(&csrf, &form.csrf_token) {
return StatusCode::FORBIDDEN.into_response();
}
match state.ap_service.unblock_actor(user_id.value(), &form.actor_url).await {
Ok(()) => Redirect::to("/social/blocked").into_response(),
Err(e) => {
tracing::error!("unblock_actor error: {:?}", e);
Redirect::to("/social/blocked").into_response()
}
}
}
pub async fn post_profile_settings(
RequiredCookieUser(user_id): RequiredCookieUser,
State(state): State<AppState>,
mut multipart: Multipart,
) -> impl IntoResponse {
let mut bio: Option<String> = None;
let mut avatar_bytes: Option<Vec<u8>> = None;
let mut avatar_content_type: Option<String> = None;
while let Ok(Some(field)) = multipart.next_field().await {
let name = field.name().unwrap_or("").to_string();
match name.as_str() {
"bio" => {
if let Ok(text) = field.text().await {
bio = Some(text);
}
}
"avatar" => {
let content_type = field.content_type().map(|s| s.to_string());
if let Ok(bytes) = field.bytes().await {
if !bytes.is_empty() {
avatar_bytes = Some(bytes.to_vec());
avatar_content_type = content_type;
}
}
}
_ => {}
}
}
let cmd = update_profile::UpdateProfileCommand {
user_id: user_id.value(),
bio,
avatar_bytes,
avatar_content_type,
};
let _ = update_profile::execute(&state.app_ctx, cmd).await;
Redirect::to("/settings/profile?saved=1").into_response()
}

View File

@@ -0,0 +1,25 @@
use axum::{
extract::{Path, State},
http::{StatusCode, header},
response::IntoResponse,
};
use crate::state::AppState;
pub async fn get_image(
State(state): State<AppState>,
Path(key): Path<String>,
) -> impl IntoResponse {
if key.starts_with("http://") || key.starts_with("https://") {
return axum::response::Redirect::temporary(&key).into_response();
}
match state.app_ctx.image_storage.get(&key).await {
Ok(bytes) => {
let mime = infer::get(&bytes)
.map(|t| t.mime_type())
.unwrap_or("application/octet-stream");
([(header::CONTENT_TYPE, mime)], bytes).into_response()
}
Err(_) => StatusCode::NOT_FOUND.into_response(),
}
}

View File

@@ -1,5 +1,5 @@
pub mod html; pub mod html;
pub mod posters; pub mod images;
pub mod rss; pub mod rss;
pub mod api; pub mod api;
pub mod import; pub mod import;

Some files were not shown because too many files have changed in this diff Show More