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)
This commit is contained in:
32
Cargo.lock
generated
32
Cargo.lock
generated
@@ -2399,6 +2399,20 @@ dependencies = [
|
||||
"icu_properties",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "image-storage"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"domain",
|
||||
"infer",
|
||||
"object_store",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "impl-more"
|
||||
version = "0.1.9"
|
||||
@@ -3424,20 +3438,6 @@ dependencies = [
|
||||
"reqwest 0.13.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "poster-storage"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"domain",
|
||||
"infer",
|
||||
"object_store",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "poster-sync"
|
||||
version = "0.1.0"
|
||||
@@ -3539,13 +3539,13 @@ dependencies = [
|
||||
"dotenvy",
|
||||
"export",
|
||||
"http-body-util",
|
||||
"image-storage",
|
||||
"importer",
|
||||
"infer",
|
||||
"metadata",
|
||||
"nats",
|
||||
"percent-encoding",
|
||||
"poster-fetcher",
|
||||
"poster-storage",
|
||||
"postgres",
|
||||
"postgres-event-queue",
|
||||
"postgres-federation",
|
||||
@@ -6340,11 +6340,11 @@ dependencies = [
|
||||
"dotenvy",
|
||||
"export",
|
||||
"futures",
|
||||
"image-storage",
|
||||
"importer",
|
||||
"metadata",
|
||||
"nats",
|
||||
"poster-fetcher",
|
||||
"poster-storage",
|
||||
"poster-sync",
|
||||
"postgres",
|
||||
"postgres-event-queue",
|
||||
|
||||
@@ -4,7 +4,7 @@ members = [
|
||||
"crates/adapters/event-publisher",
|
||||
"crates/adapters/metadata",
|
||||
"crates/adapters/poster-fetcher",
|
||||
"crates/adapters/poster-storage",
|
||||
"crates/adapters/image-storage",
|
||||
"crates/adapters/poster-sync",
|
||||
"crates/adapters/rss",
|
||||
"crates/adapters/sqlite",
|
||||
@@ -59,7 +59,7 @@ presentation = { path = "crates/presentation" }
|
||||
auth = { path = "crates/adapters/auth" }
|
||||
metadata = { path = "crates/adapters/metadata" }
|
||||
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" }
|
||||
event-publisher = { path = "crates/adapters/event-publisher" }
|
||||
rss = { path = "crates/adapters/rss" }
|
||||
|
||||
@@ -76,14 +76,14 @@ OMDB_API_KEY=your-key
|
||||
# Public base URL (used for ActivityPub actor URLs and canonical links)
|
||||
BASE_URL=https://yourdomain.example.com
|
||||
|
||||
# Poster storage — pick one backend:
|
||||
# Image storage — pick one backend:
|
||||
|
||||
# Option A: local filesystem (zero deps)
|
||||
POSTER_STORAGE_BACKEND=local
|
||||
POSTER_STORAGE_PATH=./posters
|
||||
IMAGE_STORAGE_BACKEND=local
|
||||
IMAGE_STORAGE_PATH=./images
|
||||
|
||||
# Option B: S3-compatible (MinIO, AWS S3, etc.)
|
||||
# POSTER_STORAGE_BACKEND=s3
|
||||
# IMAGE_STORAGE_BACKEND=s3
|
||||
# MINIO_ENDPOINT=http://localhost:9000
|
||||
# MINIO_BUCKET=posters
|
||||
# MINIO_REGION=minio
|
||||
|
||||
@@ -9,6 +9,10 @@ use activitypub_federation::{
|
||||
use serde::{Deserialize, Serialize};
|
||||
use url::Url;
|
||||
|
||||
#[derive(Clone, Default, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename = "Announce")]
|
||||
pub struct AnnounceType;
|
||||
|
||||
use crate::actors::DbActor;
|
||||
use crate::data::FederationData;
|
||||
use crate::error::Error;
|
||||
@@ -330,6 +334,54 @@ 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 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(())
|
||||
}
|
||||
}
|
||||
|
||||
// --- Inbox dispatch enum ---
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
@@ -350,4 +402,6 @@ pub enum InboxActivities {
|
||||
Delete(DeleteActivity),
|
||||
#[serde(rename = "Update")]
|
||||
Update(UpdateActivity),
|
||||
#[serde(rename = "Announce")]
|
||||
Announce(AnnounceActivity),
|
||||
}
|
||||
|
||||
@@ -26,6 +26,15 @@ pub struct DbActor {
|
||||
pub following_url: Url,
|
||||
pub ap_id: Url,
|
||||
pub last_refreshed_at: DateTime<Utc>,
|
||||
pub bio: Option<String>,
|
||||
pub avatar_path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct ApImageObject {
|
||||
#[serde(rename = "type")]
|
||||
pub kind: String,
|
||||
pub url: Url,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
@@ -41,6 +50,15 @@ pub struct Person {
|
||||
following: Url,
|
||||
public_key: PublicKey,
|
||||
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(
|
||||
@@ -86,6 +104,8 @@ pub async fn get_local_actor(
|
||||
following_url,
|
||||
ap_id,
|
||||
last_refreshed_at: Utc::now(),
|
||||
bio: user.bio,
|
||||
avatar_path: user.avatar_path,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -143,16 +163,27 @@ impl Object for DbActor {
|
||||
following_url,
|
||||
ap_id,
|
||||
last_refreshed_at: Utc::now(),
|
||||
bio: None,
|
||||
avatar_path: 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 {
|
||||
id: format!("{}#main-key", &self.ap_id),
|
||||
owner: self.ap_id.clone(),
|
||||
public_key_pem: self.public_key_pem.clone(),
|
||||
};
|
||||
|
||||
let icon = self.avatar_path.as_ref().map(|p| ApImageObject {
|
||||
kind: "Image".to_string(),
|
||||
url: Url::parse(&format!("{}/images/{}", data.base_url, p))
|
||||
.expect("valid avatar url"),
|
||||
});
|
||||
let profile_url =
|
||||
Url::parse(&format!("{}/u/{}", data.base_url, self.username))
|
||||
.expect("valid profile url");
|
||||
|
||||
Ok(Person {
|
||||
kind: Default::default(),
|
||||
id: self.ap_id.clone().into(),
|
||||
@@ -163,6 +194,11 @@ impl Object for DbActor {
|
||||
following: self.following_url.clone(),
|
||||
public_key,
|
||||
name: Some(self.username.clone()),
|
||||
summary: self.bio.clone(),
|
||||
icon,
|
||||
url: Some(profile_url),
|
||||
discoverable: Some(true),
|
||||
manually_approves_followers: false,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -182,6 +218,7 @@ impl Object for DbActor {
|
||||
inbox_url: json.inbox.to_string(),
|
||||
shared_inbox_url: None,
|
||||
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?;
|
||||
|
||||
@@ -204,6 +241,8 @@ impl Object for DbActor {
|
||||
following_url,
|
||||
ap_id,
|
||||
last_refreshed_at: Utc::now(),
|
||||
bio: None,
|
||||
avatar_path: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -221,3 +260,40 @@ impl Actor for DbActor {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use url::Url;
|
||||
|
||||
#[async_trait]
|
||||
@@ -10,6 +11,15 @@ pub trait ApObjectHandler: Send + Sync {
|
||||
user_id: uuid::Uuid,
|
||||
) -> 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.
|
||||
async fn on_create(
|
||||
&self,
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
use activitypub_federation::{axum::json::FederationJson, config::Data};
|
||||
use axum::extract::Path;
|
||||
use axum::extract::{Path, Query};
|
||||
use axum::response::IntoResponse;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use url::Url;
|
||||
|
||||
use crate::data::FederationData;
|
||||
use crate::error::Error;
|
||||
use activitypub_federation::{config::Data, fetch::object_id::ObjectId, kinds::activity::CreateType};
|
||||
|
||||
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)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -14,13 +25,28 @@ pub struct OrderedCollection {
|
||||
kind: String,
|
||||
id: String,
|
||||
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>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
next: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn outbox_handler(
|
||||
Path(user_id_str): Path<String>,
|
||||
Query(query): Query<OutboxQuery>,
|
||||
data: Data<FederationData>,
|
||||
) -> Result<FederationJson<OrderedCollection>, Error> {
|
||||
) -> Result<axum::response::Response, Error> {
|
||||
let uuid = uuid::Uuid::parse_str(&user_id_str)
|
||||
.map_err(|_| Error::bad_request(anyhow::anyhow!("invalid user id")))?;
|
||||
|
||||
@@ -30,19 +56,80 @@ pub async fn outbox_handler(
|
||||
.map_err(Error::from)?
|
||||
.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);
|
||||
|
||||
Ok(FederationJson(OrderedCollection {
|
||||
context: "https://www.w3.org/ns/activitystreams".to_string(),
|
||||
kind: "OrderedCollection".to_string(),
|
||||
id: outbox_url,
|
||||
total_items: objects.len() as u64,
|
||||
ordered_items: vec![],
|
||||
}))
|
||||
if query.page.unwrap_or(false) {
|
||||
let before: Option<DateTime<Utc>> = query.before.as_deref().and_then(|s| s.parse().ok());
|
||||
|
||||
let items = data
|
||||
.object_handler
|
||||
.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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ pub struct RemoteActor {
|
||||
pub inbox_url: String,
|
||||
pub shared_inbox_url: Option<String>,
|
||||
pub display_name: Option<String>,
|
||||
pub avatar_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -88,4 +89,12 @@ pub trait FederationRepository: Send + Sync {
|
||||
remote_actor_url: &str,
|
||||
status: FollowingStatus,
|
||||
) -> 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>;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ use axum::{Router, routing::get, routing::post};
|
||||
use url::Url;
|
||||
|
||||
use crate::{
|
||||
activities::{AcceptActivity, CreateActivity, FollowActivity, RejectActivity, UndoActivity},
|
||||
activities::{AcceptActivity, CreateActivity, FollowActivity, RejectActivity, UndoActivity, UpdateActivity},
|
||||
actors::{DbActor, get_local_actor},
|
||||
content::ApObjectHandler,
|
||||
data::FederationData,
|
||||
@@ -24,6 +24,24 @@ use crate::{
|
||||
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(
|
||||
sends: Vec<SendActivityTask>,
|
||||
data: &activitypub_federation::config::Data<FederationData>,
|
||||
@@ -150,6 +168,7 @@ impl ActivityPubService {
|
||||
inbox_url: remote_actor.inbox_url.to_string(),
|
||||
shared_inbox_url: None,
|
||||
display_name: Some(remote_actor.username.clone()),
|
||||
avatar_url: None,
|
||||
};
|
||||
data.federation_repo
|
||||
.add_following(local_user_id, remote, &follow_id_str)
|
||||
@@ -289,7 +308,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(())
|
||||
}
|
||||
@@ -437,10 +460,7 @@ impl ActivityPubService {
|
||||
};
|
||||
let create_with_ctx = WithContext::new_default(create);
|
||||
|
||||
let inboxes: Vec<Url> = accepted
|
||||
.iter()
|
||||
.filter_map(|f| Url::parse(&f.actor.inbox_url).ok())
|
||||
.collect();
|
||||
let inboxes = collect_inboxes(&accepted);
|
||||
|
||||
let sends =
|
||||
SendActivityTask::prepare(&create_with_ctx, &local_actor, inboxes, &data).await?;
|
||||
@@ -455,6 +475,57 @@ impl ActivityPubService {
|
||||
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(())
|
||||
}
|
||||
|
||||
async fn follow_local(
|
||||
&self,
|
||||
local_user_id: uuid::Uuid,
|
||||
@@ -493,6 +564,7 @@ impl ActivityPubService {
|
||||
inbox_url: target_inbox_url,
|
||||
shared_inbox_url: None,
|
||||
display_name: Some(target.username),
|
||||
avatar_url: None,
|
||||
};
|
||||
data.federation_repo
|
||||
.add_following(local_user_id, target_as_remote, &follow_id)
|
||||
@@ -618,3 +690,47 @@ impl ActivityPubService {
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ use async_trait::async_trait;
|
||||
pub struct ApUser {
|
||||
pub id: uuid::Uuid,
|
||||
pub username: String,
|
||||
pub bio: Option<String>,
|
||||
pub avatar_path: Option<String>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
|
||||
@@ -46,6 +46,11 @@ impl EventHandler for ActivityPubEventHandler {
|
||||
.on_review_logged(user_id, review_id)
|
||||
.await
|
||||
.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(()),
|
||||
}
|
||||
}
|
||||
@@ -78,7 +83,7 @@ impl ActivityPubEventHandler {
|
||||
let poster_url = movie
|
||||
.as_ref()
|
||||
.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(
|
||||
&review,
|
||||
|
||||
@@ -75,6 +75,73 @@ impl ApObjectHandler for ReviewObjectHandler {
|
||||
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!("{}/posters/{}", self.base_url, p.value()));
|
||||
|
||||
let obj = review_to_ap_object(
|
||||
review,
|
||||
ap_id.clone(),
|
||||
actor_url,
|
||||
movie_title,
|
||||
release_year,
|
||||
poster_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(
|
||||
&self,
|
||||
_ap_id: &Url,
|
||||
|
||||
@@ -13,6 +13,8 @@ impl ApUserRepository for DomainUserRepoAdapter {
|
||||
Ok(self.0.find_by_id(&user_id).await?.map(|u| ApUser {
|
||||
id: u.id().value(),
|
||||
username: u.username().value().to_string(),
|
||||
bio: u.bio().map(|s| s.to_string()),
|
||||
avatar_path: u.avatar_path().map(|s| s.to_string()),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -23,6 +25,8 @@ impl ApUserRepository for DomainUserRepoAdapter {
|
||||
Ok(self.0.find_by_username(&uname).await?.map(|u| ApUser {
|
||||
id: u.id().value(),
|
||||
username: u.username().value().to_string(),
|
||||
bio: u.bio().map(|s| s.to_string()),
|
||||
avatar_path: u.avatar_path().map(|s| s.to_string()),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,9 @@ pub enum EventPayload {
|
||||
movie_id: String,
|
||||
poster_path: Option<String>,
|
||||
},
|
||||
UserUpdated {
|
||||
user_id: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl EventPayload {
|
||||
@@ -41,6 +44,7 @@ impl EventPayload {
|
||||
EventPayload::ReviewUpdated { .. } => "ReviewUpdated",
|
||||
EventPayload::MovieDiscovered { .. } => "MovieDiscovered",
|
||||
EventPayload::MovieDeleted { .. } => "MovieDeleted",
|
||||
EventPayload::UserUpdated { .. } => "UserUpdated",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -87,6 +91,9 @@ impl From<&DomainEvent> for EventPayload {
|
||||
movie_id: movie_id.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()))?;
|
||||
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")?),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
[package]
|
||||
name = "poster-storage"
|
||||
name = "image-storage"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
@@ -6,8 +6,8 @@ pub struct StorageConfig(Arc<dyn ObjectStore>);
|
||||
|
||||
impl StorageConfig {
|
||||
pub fn from_env() -> anyhow::Result<Self> {
|
||||
let backend = std::env::var("POSTER_STORAGE_BACKEND")
|
||||
.context("POSTER_STORAGE_BACKEND required (valid values: s3, local)")?;
|
||||
let backend = std::env::var("IMAGE_STORAGE_BACKEND")
|
||||
.context("IMAGE_STORAGE_BACKEND required (valid values: s3, local)")?;
|
||||
|
||||
let store: Arc<dyn ObjectStore> = match backend.as_str() {
|
||||
"s3" => build_s3_store(
|
||||
@@ -19,11 +19,11 @@ impl StorageConfig {
|
||||
&std::env::var("MINIO_REGION").unwrap_or_else(|_| "minio".to_string()),
|
||||
)?,
|
||||
"local" => build_local_store(
|
||||
&std::env::var("POSTER_STORAGE_PATH")
|
||||
.context("POSTER_STORAGE_PATH required when POSTER_STORAGE_BACKEND=local")?,
|
||||
&std::env::var("IMAGE_STORAGE_PATH")
|
||||
.context("IMAGE_STORAGE_PATH required when IMAGE_STORAGE_BACKEND=local")?,
|
||||
)?,
|
||||
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>> {
|
||||
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)
|
||||
.context("Failed to initialise local file system store")?;
|
||||
Ok(Arc::new(store))
|
||||
@@ -67,7 +67,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
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());
|
||||
assert!(result.is_ok(), "expected Ok, got: {:?}", result.err());
|
||||
assert!(dir.exists(), "directory should have been created");
|
||||
@@ -75,7 +75,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
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();
|
||||
let result = build_local_store(dir.to_str().unwrap());
|
||||
assert!(result.is_ok());
|
||||
157
crates/adapters/image-storage/src/lib.rs
Normal file
157
crates/adapters/image-storage/src/lib.rs
Normal 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(_))));
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ pub fn event_to_subject(prefix: &str, event: &DomainEvent) -> String {
|
||||
DomainEvent::ReviewUpdated { .. } => "review.updated",
|
||||
DomainEvent::MovieDiscovered { .. } => "movie.discovered",
|
||||
DomainEvent::MovieDeleted { .. } => "movie.deleted",
|
||||
DomainEvent::UserUpdated { .. } => "user.updated",
|
||||
};
|
||||
format!("{prefix}.{suffix}")
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -4,15 +4,15 @@ use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
events::DomainEvent,
|
||||
ports::{EventHandler, MetadataClient, MovieRepository, PosterFetcherClient, PosterStorage},
|
||||
value_objects::{ExternalMetadataId, MovieId},
|
||||
ports::{EventHandler, ImageStorage, MetadataClient, MovieRepository, PosterFetcherClient},
|
||||
value_objects::{ExternalMetadataId, MovieId, PosterPath},
|
||||
};
|
||||
|
||||
pub struct PosterSyncHandler {
|
||||
movie_repository: Arc<dyn MovieRepository>,
|
||||
metadata_client: Arc<dyn MetadataClient>,
|
||||
poster_fetcher: Arc<dyn PosterFetcherClient>,
|
||||
poster_storage: Arc<dyn PosterStorage>,
|
||||
image_storage: Arc<dyn ImageStorage>,
|
||||
max_retries: u32,
|
||||
}
|
||||
|
||||
@@ -21,10 +21,10 @@ impl PosterSyncHandler {
|
||||
movie_repository: Arc<dyn MovieRepository>,
|
||||
metadata_client: Arc<dyn MetadataClient>,
|
||||
poster_fetcher: Arc<dyn PosterFetcherClient>,
|
||||
poster_storage: Arc<dyn PosterStorage>,
|
||||
image_storage: Arc<dyn ImageStorage>,
|
||||
max_retries: u32,
|
||||
) -> 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> {
|
||||
@@ -46,9 +46,10 @@ impl PosterSyncHandler {
|
||||
};
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,7 +103,7 @@ impl FederationRepository for PostgresFederationRepository {
|
||||
let uid = local_user_id.to_string();
|
||||
let rows = sqlx::query(
|
||||
"SELECT f.remote_actor_url, f.status,
|
||||
a.handle, a.inbox_url, a.shared_inbox_url, a.display_name
|
||||
a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.avatar_url
|
||||
FROM ap_followers f
|
||||
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
|
||||
WHERE f.local_user_id = $1",
|
||||
@@ -118,8 +118,9 @@ impl FederationRepository for PostgresFederationRepository {
|
||||
let inbox_url: String = row.try_get("inbox_url").unwrap_or_default();
|
||||
let shared_inbox_url: Option<String> = row.try_get("shared_inbox_url").ok().flatten();
|
||||
let display_name: Option<String> = row.try_get("display_name").ok().flatten();
|
||||
let avatar_url: Option<String> = row.try_get("avatar_url").ok().flatten();
|
||||
Follower {
|
||||
actor: RemoteActor { url, handle, inbox_url, shared_inbox_url, display_name },
|
||||
actor: RemoteActor { url, handle, inbox_url, shared_inbox_url, display_name, avatar_url },
|
||||
status: str_to_status(&status_str),
|
||||
}
|
||||
}).collect())
|
||||
@@ -200,7 +201,7 @@ impl FederationRepository for PostgresFederationRepository {
|
||||
async fn get_following(&self, local_user_id: uuid::Uuid) -> Result<Vec<RemoteActor>> {
|
||||
let uid = local_user_id.to_string();
|
||||
let rows = sqlx::query(
|
||||
"SELECT a.url, a.handle, a.inbox_url, a.shared_inbox_url, a.display_name
|
||||
"SELECT a.url, a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.avatar_url
|
||||
FROM ap_following f
|
||||
INNER JOIN ap_remote_actors a ON a.url = f.remote_actor_url
|
||||
WHERE f.local_user_id = $1 AND f.status = 'accepted'",
|
||||
@@ -214,6 +215,7 @@ impl FederationRepository for PostgresFederationRepository {
|
||||
inbox_url: row.get("inbox_url"),
|
||||
shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(),
|
||||
display_name: row.try_get("display_name").ok().flatten(),
|
||||
avatar_url: row.try_get("avatar_url").ok().flatten(),
|
||||
}).collect())
|
||||
}
|
||||
|
||||
@@ -232,13 +234,14 @@ impl FederationRepository for PostgresFederationRepository {
|
||||
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 ($1, $2, $3, $4, $5, $6::timestamptz)
|
||||
"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, $7::timestamptz)
|
||||
ON CONFLICT(url) DO UPDATE SET
|
||||
handle = EXCLUDED.handle,
|
||||
inbox_url = EXCLUDED.inbox_url,
|
||||
shared_inbox_url = EXCLUDED.shared_inbox_url,
|
||||
display_name = EXCLUDED.display_name,
|
||||
avatar_url = EXCLUDED.avatar_url,
|
||||
fetched_at = EXCLUDED.fetched_at",
|
||||
)
|
||||
.bind(&actor.url)
|
||||
@@ -246,6 +249,7 @@ impl FederationRepository for PostgresFederationRepository {
|
||||
.bind(&actor.inbox_url)
|
||||
.bind(&actor.shared_inbox_url)
|
||||
.bind(&actor.display_name)
|
||||
.bind(&actor.avatar_url)
|
||||
.bind(&fetched_at)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
@@ -254,7 +258,7 @@ impl FederationRepository for PostgresFederationRepository {
|
||||
|
||||
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
|
||||
"SELECT url, handle, inbox_url, shared_inbox_url, display_name, avatar_url
|
||||
FROM ap_remote_actors WHERE url = $1",
|
||||
)
|
||||
.bind(actor_url)
|
||||
@@ -266,6 +270,7 @@ impl FederationRepository for PostgresFederationRepository {
|
||||
inbox_url: row.get("inbox_url"),
|
||||
shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(),
|
||||
display_name: row.try_get("display_name").ok().flatten(),
|
||||
avatar_url: row.try_get("avatar_url").ok().flatten(),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -306,7 +311,7 @@ impl FederationRepository for PostgresFederationRepository {
|
||||
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
|
||||
"SELECT f.remote_actor_url, a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.avatar_url
|
||||
FROM ap_followers f
|
||||
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
|
||||
WHERE f.local_user_id = $1 AND f.status = 'pending'",
|
||||
@@ -320,6 +325,7 @@ impl FederationRepository for PostgresFederationRepository {
|
||||
inbox_url: row.try_get("inbox_url").unwrap_or_default(),
|
||||
shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(),
|
||||
display_name: row.try_get("display_name").ok().flatten(),
|
||||
avatar_url: row.try_get("avatar_url").ok().flatten(),
|
||||
}).collect())
|
||||
}
|
||||
|
||||
@@ -347,6 +353,34 @@ impl FederationRepository for PostgresFederationRepository {
|
||||
}
|
||||
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_trait]
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE users ADD COLUMN bio TEXT;
|
||||
ALTER TABLE users ADD COLUMN avatar_path TEXT;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE ap_remote_actors ADD COLUMN avatar_url TEXT;
|
||||
@@ -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);
|
||||
@@ -38,6 +38,8 @@ impl PostgresUserRepository {
|
||||
username_str: String,
|
||||
hash_str: String,
|
||||
role: UserRole,
|
||||
bio: Option<String>,
|
||||
avatar_path: Option<String>,
|
||||
) -> Result<User, DomainError> {
|
||||
let id = uuid::Uuid::parse_str(&id_str)
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
@@ -53,6 +55,8 @@ impl PostgresUserRepository {
|
||||
username,
|
||||
hash,
|
||||
role,
|
||||
bio,
|
||||
avatar_path,
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -68,9 +72,11 @@ impl UserRepository for PostgresUserRepository {
|
||||
username: String,
|
||||
password_hash: String,
|
||||
role: String,
|
||||
bio: Option<String>,
|
||||
avatar_path: Option<String>,
|
||||
}
|
||||
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)
|
||||
.fetch_optional(&self.pool)
|
||||
@@ -83,6 +89,8 @@ impl UserRepository for PostgresUserRepository {
|
||||
r.username,
|
||||
r.password_hash,
|
||||
Self::parse_role(&r.role),
|
||||
r.bio,
|
||||
r.avatar_path,
|
||||
)
|
||||
})
|
||||
.transpose()
|
||||
@@ -97,9 +105,11 @@ impl UserRepository for PostgresUserRepository {
|
||||
username: String,
|
||||
password_hash: String,
|
||||
role: String,
|
||||
bio: Option<String>,
|
||||
avatar_path: Option<String>,
|
||||
}
|
||||
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)
|
||||
.fetch_optional(&self.pool)
|
||||
@@ -112,6 +122,8 @@ impl UserRepository for PostgresUserRepository {
|
||||
r.username,
|
||||
r.password_hash,
|
||||
Self::parse_role(&r.role),
|
||||
r.bio,
|
||||
r.avatar_path,
|
||||
)
|
||||
})
|
||||
.transpose()
|
||||
@@ -164,9 +176,11 @@ impl UserRepository for PostgresUserRepository {
|
||||
username: String,
|
||||
password_hash: String,
|
||||
role: String,
|
||||
bio: Option<String>,
|
||||
avatar_path: Option<String>,
|
||||
}
|
||||
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)
|
||||
.fetch_optional(&self.pool)
|
||||
@@ -179,11 +193,30 @@ impl UserRepository for PostgresUserRepository {
|
||||
r.username,
|
||||
r.password_hash,
|
||||
Self::parse_role(&r.role),
|
||||
r.bio,
|
||||
r.avatar_path,
|
||||
)
|
||||
})
|
||||
.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> {
|
||||
sqlx::query_as::<_, UserSummaryRow>(
|
||||
r#"SELECT u.id, u.email,
|
||||
|
||||
@@ -106,7 +106,7 @@ impl FederationRepository for SqliteFederationRepository {
|
||||
|
||||
let rows = sqlx::query(
|
||||
"SELECT f.remote_actor_url, f.status,
|
||||
a.handle, a.inbox_url, a.shared_inbox_url, a.display_name
|
||||
a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.avatar_url
|
||||
FROM ap_followers f
|
||||
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
|
||||
WHERE f.local_user_id = ?",
|
||||
@@ -125,6 +125,7 @@ impl FederationRepository for SqliteFederationRepository {
|
||||
let shared_inbox_url: Option<String> =
|
||||
row.try_get("shared_inbox_url").ok().flatten();
|
||||
let display_name: Option<String> = row.try_get("display_name").ok().flatten();
|
||||
let avatar_url: Option<String> = row.try_get("avatar_url").ok().flatten();
|
||||
|
||||
Follower {
|
||||
actor: RemoteActor {
|
||||
@@ -133,6 +134,7 @@ impl FederationRepository for SqliteFederationRepository {
|
||||
inbox_url,
|
||||
shared_inbox_url,
|
||||
display_name,
|
||||
avatar_url,
|
||||
},
|
||||
status: str_to_status(&status_str),
|
||||
}
|
||||
@@ -223,7 +225,7 @@ impl FederationRepository for SqliteFederationRepository {
|
||||
let uid = local_user_id.to_string();
|
||||
|
||||
let rows = sqlx::query(
|
||||
"SELECT a.url, a.handle, a.inbox_url, a.shared_inbox_url, a.display_name
|
||||
"SELECT a.url, a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.avatar_url
|
||||
FROM ap_following f
|
||||
INNER JOIN ap_remote_actors a ON a.url = f.remote_actor_url
|
||||
WHERE f.local_user_id = ? AND f.status = 'accepted'",
|
||||
@@ -240,6 +242,7 @@ impl FederationRepository for SqliteFederationRepository {
|
||||
inbox_url: row.get("inbox_url"),
|
||||
shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(),
|
||||
display_name: row.try_get("display_name").ok().flatten(),
|
||||
avatar_url: row.try_get("avatar_url").ok().flatten(),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
@@ -260,13 +263,14 @@ impl FederationRepository for SqliteFederationRepository {
|
||||
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 (?, ?, ?, ?, ?, ?)
|
||||
"INSERT INTO ap_remote_actors (url, handle, inbox_url, shared_inbox_url, display_name, avatar_url, fetched_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(url) DO UPDATE SET
|
||||
handle = excluded.handle,
|
||||
inbox_url = excluded.inbox_url,
|
||||
shared_inbox_url = excluded.shared_inbox_url,
|
||||
display_name = excluded.display_name,
|
||||
avatar_url = excluded.avatar_url,
|
||||
fetched_at = excluded.fetched_at",
|
||||
)
|
||||
.bind(&actor.url)
|
||||
@@ -274,6 +278,7 @@ impl FederationRepository for SqliteFederationRepository {
|
||||
.bind(&actor.inbox_url)
|
||||
.bind(&actor.shared_inbox_url)
|
||||
.bind(&actor.display_name)
|
||||
.bind(&actor.avatar_url)
|
||||
.bind(&fetched_at)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
@@ -283,7 +288,7 @@ impl FederationRepository for SqliteFederationRepository {
|
||||
|
||||
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
|
||||
"SELECT url, handle, inbox_url, shared_inbox_url, display_name, avatar_url
|
||||
FROM ap_remote_actors WHERE url = ?",
|
||||
)
|
||||
.bind(actor_url)
|
||||
@@ -296,6 +301,7 @@ impl FederationRepository for SqliteFederationRepository {
|
||||
inbox_url: row.get("inbox_url"),
|
||||
shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(),
|
||||
display_name: row.try_get("display_name").ok().flatten(),
|
||||
avatar_url: row.try_get("avatar_url").ok().flatten(),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -344,7 +350,7 @@ impl FederationRepository for SqliteFederationRepository {
|
||||
|
||||
let rows = sqlx::query(
|
||||
"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
|
||||
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
|
||||
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(),
|
||||
shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(),
|
||||
display_name: row.try_get("display_name").ok().flatten(),
|
||||
avatar_url: row.try_get("avatar_url").ok().flatten(),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
@@ -392,6 +399,35 @@ impl FederationRepository for SqliteFederationRepository {
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Content-specific repository (movies-diary) ---
|
||||
@@ -553,9 +589,34 @@ pub fn wire(pool: sqlx::SqlitePool) -> (
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::Utc;
|
||||
use domain::ports::SocialQueryPort;
|
||||
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) {
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS ap_remote_actors (
|
||||
@@ -564,6 +625,7 @@ mod tests {
|
||||
inbox_url TEXT NOT NULL,
|
||||
shared_inbox_url TEXT,
|
||||
display_name TEXT,
|
||||
avatar_url TEXT,
|
||||
fetched_at TEXT NOT NULL
|
||||
)",
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"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": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -67,6 +67,11 @@
|
||||
"name": "created_at",
|
||||
"ordinal": 12,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "remote_actor_url",
|
||||
"ordinal": 13,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -85,8 +90,9 @@
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "01a08873b7fa815ad98a56a0902b60414cfcdc2c7a8570351320c4bc425347c6"
|
||||
"hash": "05d958c1fa38095ae2b5b81ede48fc85702d8c39c6301839de7b4d27f4a4d41b"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"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": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -67,6 +67,11 @@
|
||||
"name": "created_at",
|
||||
"ordinal": 12,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "remote_actor_url",
|
||||
"ordinal": 13,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -85,8 +90,9 @@
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "026e2afeb573707cb360fcdab8f6137aabfaf603b5ed57b98ac2888b4a0389ff"
|
||||
"hash": "25fd01355c929a83daf2c802b8ae3adaa4ce73fc037e2bf2a87d60187aeb7361"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"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": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -67,6 +67,11 @@
|
||||
"name": "created_at",
|
||||
"ordinal": 12,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "remote_actor_url",
|
||||
"ordinal": 13,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -85,8 +90,9 @@
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "47f7cf95ce3450635b643ab710cadba96f40319140834d510bc5207b2552e055"
|
||||
"hash": "8a70f21c39d203867c06dc0bf74a54745b3331b84ce9a2178f7812f1ed7262cc"
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "DELETE FROM import_profiles WHERE id = ?",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "9024c2b533d0100f18d5a5550220a148d8dc29bbf0df715f895fa4248d83785f"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"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": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -67,6 +67,11 @@
|
||||
"name": "created_at",
|
||||
"ordinal": 12,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "remote_actor_url",
|
||||
"ordinal": 13,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -85,8 +90,9 @@
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "affe1eb261283c09d4b1ce6e684681755f079a044ffec8ff2bd79cfd8efe16b8"
|
||||
"hash": "a7c424c26663e4e51b1c563fa977f28e1d55234a242a7ddba50db13cf73b488d"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"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": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -37,6 +37,11 @@
|
||||
"name": "created_at",
|
||||
"ordinal": 6,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "remote_actor_url",
|
||||
"ordinal": 7,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -49,8 +54,9 @@
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "af883f8b78f185077e2d3dcfaa0a6e62fbdfbf00c97c9b33b699dc631476181d"
|
||||
"hash": "ae983138ad90fda3794b784fbf62c31fddcf182850782e9bfbc4ff3ee8b7d4bb"
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "DELETE FROM import_sessions WHERE id = ?",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "bf4aaf630ebefde88d87a95c257bbeafaf39e2f53611dab435d485648eb7e598"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "DELETE FROM movies WHERE id = ?",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "e431381ad41c1c2f7b9c89509d5e3f4c19cb52dcfff66772145cd80c53c16883"
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "DELETE FROM reviews WHERE id = ?",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "f84e5483ca4210aec67b38cc1a9de4a42c12891025236abc48ea4f175292a6cc"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
2
crates/adapters/sqlite/migrations/0009_user_profile.sql
Normal file
2
crates/adapters/sqlite/migrations/0009_user_profile.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE users ADD COLUMN bio TEXT;
|
||||
ALTER TABLE users ADD COLUMN avatar_path TEXT;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE ap_remote_actors ADD COLUMN avatar_url TEXT;
|
||||
8
crates/adapters/sqlite/migrations/0011_ap_announces.sql
Normal file
8
crates/adapters/sqlite/migrations/0011_ap_announces.sql
Normal 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);
|
||||
@@ -98,7 +98,7 @@ impl FederationRepository for SqliteFederationRepository {
|
||||
|
||||
let rows = sqlx::query(
|
||||
"SELECT f.remote_actor_url, f.status,
|
||||
a.handle, a.inbox_url, a.shared_inbox_url, a.display_name
|
||||
a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.avatar_url
|
||||
FROM ap_followers f
|
||||
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
|
||||
WHERE f.local_user_id = ?",
|
||||
@@ -116,9 +116,10 @@ impl FederationRepository for SqliteFederationRepository {
|
||||
let inbox_url: String = row.try_get("inbox_url").unwrap_or_default();
|
||||
let shared_inbox_url: Option<String> = row.try_get("shared_inbox_url").ok().flatten();
|
||||
let display_name: Option<String> = row.try_get("display_name").ok().flatten();
|
||||
let avatar_url: Option<String> = row.try_get("avatar_url").ok().flatten();
|
||||
|
||||
Follower {
|
||||
actor: RemoteActor { url, handle, inbox_url, shared_inbox_url, display_name },
|
||||
actor: RemoteActor { url, handle, inbox_url, shared_inbox_url, display_name, avatar_url },
|
||||
status: str_to_status(&status_str),
|
||||
}
|
||||
})
|
||||
@@ -199,7 +200,7 @@ impl FederationRepository for SqliteFederationRepository {
|
||||
let uid = local_user_id.to_string();
|
||||
|
||||
let rows = sqlx::query(
|
||||
"SELECT a.url, a.handle, a.inbox_url, a.shared_inbox_url, a.display_name
|
||||
"SELECT a.url, a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.avatar_url
|
||||
FROM ap_following f
|
||||
INNER JOIN ap_remote_actors a ON a.url = f.remote_actor_url
|
||||
WHERE f.local_user_id = ? AND f.status = 'accepted'",
|
||||
@@ -214,6 +215,7 @@ impl FederationRepository for SqliteFederationRepository {
|
||||
inbox_url: row.get("inbox_url"),
|
||||
shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(),
|
||||
display_name: row.try_get("display_name").ok().flatten(),
|
||||
avatar_url: row.try_get("avatar_url").ok().flatten(),
|
||||
}).collect())
|
||||
}
|
||||
|
||||
@@ -233,13 +235,14 @@ impl FederationRepository for SqliteFederationRepository {
|
||||
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 (?, ?, ?, ?, ?, ?)
|
||||
"INSERT INTO ap_remote_actors (url, handle, inbox_url, shared_inbox_url, display_name, avatar_url, fetched_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(url) DO UPDATE SET
|
||||
handle = excluded.handle,
|
||||
inbox_url = excluded.inbox_url,
|
||||
shared_inbox_url = excluded.shared_inbox_url,
|
||||
display_name = excluded.display_name,
|
||||
avatar_url = excluded.avatar_url,
|
||||
fetched_at = excluded.fetched_at",
|
||||
)
|
||||
.bind(&actor.url)
|
||||
@@ -247,6 +250,7 @@ impl FederationRepository for SqliteFederationRepository {
|
||||
.bind(&actor.inbox_url)
|
||||
.bind(&actor.shared_inbox_url)
|
||||
.bind(&actor.display_name)
|
||||
.bind(&actor.avatar_url)
|
||||
.bind(&fetched_at)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
@@ -256,7 +260,7 @@ impl FederationRepository for SqliteFederationRepository {
|
||||
|
||||
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
|
||||
"SELECT url, handle, inbox_url, shared_inbox_url, display_name, avatar_url
|
||||
FROM ap_remote_actors WHERE url = ?",
|
||||
)
|
||||
.bind(actor_url)
|
||||
@@ -269,6 +273,7 @@ impl FederationRepository for SqliteFederationRepository {
|
||||
inbox_url: row.get("inbox_url"),
|
||||
shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(),
|
||||
display_name: row.try_get("display_name").ok().flatten(),
|
||||
avatar_url: row.try_get("avatar_url").ok().flatten(),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -308,7 +313,7 @@ impl FederationRepository for SqliteFederationRepository {
|
||||
|
||||
let rows = sqlx::query(
|
||||
"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
|
||||
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
|
||||
WHERE f.local_user_id = ? AND f.status = 'pending'",
|
||||
@@ -323,6 +328,7 @@ impl FederationRepository for SqliteFederationRepository {
|
||||
inbox_url: row.try_get("inbox_url").unwrap_or_default(),
|
||||
shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(),
|
||||
display_name: row.try_get("display_name").ok().flatten(),
|
||||
avatar_url: row.try_get("avatar_url").ok().flatten(),
|
||||
}).collect())
|
||||
}
|
||||
|
||||
@@ -353,6 +359,35 @@ impl FederationRepository for SqliteFederationRepository {
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Content-specific repository (movies-diary) ---
|
||||
|
||||
@@ -37,6 +37,8 @@ impl SqliteUserRepository {
|
||||
username_str: String,
|
||||
hash_str: String,
|
||||
role: UserRole,
|
||||
bio: Option<String>,
|
||||
avatar_path: Option<String>,
|
||||
) -> Result<User, DomainError> {
|
||||
let id = uuid::Uuid::parse_str(&id_str)
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
@@ -52,6 +54,8 @@ impl SqliteUserRepository {
|
||||
username,
|
||||
hash,
|
||||
role,
|
||||
bio,
|
||||
avatar_path,
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -61,7 +65,7 @@ impl UserRepository for SqliteUserRepository {
|
||||
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError> {
|
||||
let email_str = email.value();
|
||||
let row = sqlx::query!(
|
||||
"SELECT id, email, username, password_hash, role FROM users WHERE email = ?",
|
||||
"SELECT id, email, username, password_hash, role, bio, avatar_path FROM users WHERE email = ?",
|
||||
email_str
|
||||
)
|
||||
.fetch_optional(&self.pool)
|
||||
@@ -75,6 +79,8 @@ impl UserRepository for SqliteUserRepository {
|
||||
r.username,
|
||||
r.password_hash,
|
||||
Self::parse_role(&r.role),
|
||||
r.bio,
|
||||
r.avatar_path,
|
||||
)
|
||||
})
|
||||
.transpose()
|
||||
@@ -83,7 +89,7 @@ impl UserRepository for SqliteUserRepository {
|
||||
async fn find_by_username(&self, username: &Username) -> Result<Option<User>, DomainError> {
|
||||
let username_str = username.value();
|
||||
let row = sqlx::query!(
|
||||
"SELECT id, email, username, password_hash, role FROM users WHERE username = ?",
|
||||
"SELECT id, email, username, password_hash, role, bio, avatar_path FROM users WHERE username = ?",
|
||||
username_str
|
||||
)
|
||||
.fetch_optional(&self.pool)
|
||||
@@ -97,6 +103,8 @@ impl UserRepository for SqliteUserRepository {
|
||||
r.username,
|
||||
r.password_hash,
|
||||
Self::parse_role(&r.role),
|
||||
r.bio,
|
||||
r.avatar_path,
|
||||
)
|
||||
})
|
||||
.transpose()
|
||||
@@ -140,7 +148,7 @@ impl UserRepository for SqliteUserRepository {
|
||||
async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError> {
|
||||
let id_str = id.value().to_string();
|
||||
let row = sqlx::query!(
|
||||
"SELECT id, email, username, password_hash, role FROM users WHERE id = ?",
|
||||
"SELECT id, email, username, password_hash, role, bio, avatar_path FROM users WHERE id = ?",
|
||||
id_str
|
||||
)
|
||||
.fetch_optional(&self.pool)
|
||||
@@ -154,11 +162,30 @@ impl UserRepository for SqliteUserRepository {
|
||||
r.username,
|
||||
r.password_hash,
|
||||
Self::parse_role(&r.role),
|
||||
r.bio,
|
||||
r.avatar_path,
|
||||
)
|
||||
})
|
||||
.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> {
|
||||
sqlx::query_as!(
|
||||
UserSummaryRow,
|
||||
@@ -183,12 +210,14 @@ impl UserRepository for SqliteUserRepository {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use domain::models::UserRole;
|
||||
use domain::value_objects::{Email, PasswordHash, Username};
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
async fn setup() -> (SqlitePool, SqliteUserRepository) {
|
||||
let pool = SqlitePool::connect(":memory:").await.unwrap();
|
||||
sqlx::query(
|
||||
"CREATE TABLE users (id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, created_at TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'standard')"
|
||||
"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)
|
||||
.await
|
||||
@@ -227,4 +256,48 @@ mod tests {
|
||||
assert!(result.is_some());
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ use application::ports::{
|
||||
ActivityFeedPageData, FollowersPageData, FollowingPageData, HtmlPageContext, HtmlRenderer,
|
||||
ImportMappingPageData, ImportPreviewPageData, ImportPreviewRow, ImportProfileView,
|
||||
ImportRowStatus, ImportUploadPageData, LoginPageData, MovieDetailPageData, NewReviewPageData,
|
||||
ProfilePageData, RegisterPageData, UsersPageData,
|
||||
ProfilePageData, ProfileSettingsPageData, RegisterPageData, UsersPageData,
|
||||
};
|
||||
use askama::Template;
|
||||
use chrono::Datelike;
|
||||
@@ -305,6 +305,15 @@ fn bar_height_px(avg_rating: f64) -> 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)]
|
||||
#[template(path = "import_upload.html")]
|
||||
struct ImportUploadTemplate<'a> {
|
||||
@@ -649,4 +658,18 @@ impl HtmlRenderer for AskamaHtmlRenderer {
|
||||
.render()
|
||||
.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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 %}
|
||||
@@ -2,9 +2,10 @@ use std::sync::Arc;
|
||||
|
||||
use domain::ports::{
|
||||
AuthService, DiaryExporter, DiaryRepository, DocumentParser, EventPublisher,
|
||||
ImageStorage,
|
||||
ImportProfileRepository, ImportSessionRepository,
|
||||
MetadataClient, MovieRepository, PasswordHasher, PosterFetcherClient,
|
||||
PosterStorage, ReviewRepository, StatsRepository, UserRepository,
|
||||
ReviewRepository, StatsRepository, UserRepository,
|
||||
};
|
||||
|
||||
use crate::config::AppConfig;
|
||||
@@ -19,7 +20,7 @@ pub struct AppContext {
|
||||
pub stats_repository: Arc<dyn StatsRepository>,
|
||||
pub metadata_client: Arc<dyn MetadataClient>,
|
||||
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 auth_service: Arc<dyn AuthService>,
|
||||
pub password_hasher: Arc<dyn PasswordHasher>,
|
||||
|
||||
@@ -145,6 +145,13 @@ pub struct ImportPreviewPageData {
|
||||
pub rows: Vec<ImportPreviewRow>,
|
||||
}
|
||||
|
||||
pub struct ProfileSettingsPageData {
|
||||
pub ctx: HtmlPageContext,
|
||||
pub bio: Option<String>,
|
||||
pub avatar_url: Option<String>,
|
||||
pub saved: bool,
|
||||
}
|
||||
|
||||
pub trait HtmlRenderer: Send + Sync {
|
||||
fn render_diary_page(
|
||||
&self,
|
||||
@@ -163,6 +170,10 @@ pub trait HtmlRenderer: Send + Sync {
|
||||
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_preview_page(&self, data: ImportPreviewPageData) -> Result<String, String>;
|
||||
fn render_profile_settings_page(
|
||||
&self,
|
||||
data: ProfileSettingsPageData,
|
||||
) -> Result<String, String>;
|
||||
}
|
||||
|
||||
pub trait RssFeedRenderer: Send + Sync {
|
||||
|
||||
@@ -18,3 +18,4 @@ pub mod log_review;
|
||||
pub mod login;
|
||||
pub mod register;
|
||||
pub mod sync_poster;
|
||||
pub mod update_profile;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
value_objects::{ExternalMetadataId, MovieId},
|
||||
value_objects::{ExternalMetadataId, MovieId, PosterPath},
|
||||
};
|
||||
|
||||
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 stored_path = ctx
|
||||
.poster_storage
|
||||
.store_poster(&movie_id, &image_bytes)
|
||||
.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);
|
||||
ctx.movie_repository.upsert_movie(&movie).await?;
|
||||
|
||||
Ok(())
|
||||
|
||||
51
crates/application/src/use_cases/update_profile.rs
Normal file
51
crates/application/src/use_cases/update_profile.rs
Normal 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(())
|
||||
}
|
||||
@@ -94,6 +94,7 @@ mod tests {
|
||||
DomainEvent::ReviewLogged { .. } => "review_logged",
|
||||
DomainEvent::ReviewUpdated { .. } => "review_updated",
|
||||
DomainEvent::MovieDeleted { .. } => "movie_deleted",
|
||||
DomainEvent::UserUpdated { .. } => "user_updated",
|
||||
};
|
||||
self.calls.lock().unwrap().push(label);
|
||||
Ok(())
|
||||
|
||||
@@ -30,6 +30,9 @@ pub enum DomainEvent {
|
||||
movie_id: MovieId,
|
||||
poster_path: Option<PosterPath>,
|
||||
},
|
||||
UserUpdated {
|
||||
user_id: UserId,
|
||||
},
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
|
||||
@@ -290,6 +290,8 @@ pub struct User {
|
||||
username: Username,
|
||||
password_hash: PasswordHash,
|
||||
role: UserRole,
|
||||
bio: Option<String>,
|
||||
avatar_path: Option<String>,
|
||||
}
|
||||
|
||||
impl User {
|
||||
@@ -305,6 +307,8 @@ impl User {
|
||||
username,
|
||||
password_hash,
|
||||
role,
|
||||
bio: None,
|
||||
avatar_path: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -314,6 +318,8 @@ impl User {
|
||||
username: Username,
|
||||
password_hash: PasswordHash,
|
||||
role: UserRole,
|
||||
bio: Option<String>,
|
||||
avatar_path: Option<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
id,
|
||||
@@ -321,6 +327,8 @@ impl User {
|
||||
username,
|
||||
password_hash,
|
||||
role,
|
||||
bio,
|
||||
avatar_path,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -328,6 +336,11 @@ impl User {
|
||||
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 {
|
||||
&self.email
|
||||
}
|
||||
@@ -343,6 +356,13 @@ impl User {
|
||||
pub fn role(&self) -> &UserRole {
|
||||
&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)]
|
||||
@@ -435,3 +455,38 @@ pub enum ExportFormat {
|
||||
Csv,
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ use crate::{
|
||||
},
|
||||
value_objects::{
|
||||
Email, ExternalMetadataId, ImportProfileId, ImportSessionId, MovieId, MovieTitle,
|
||||
PasswordHash, PosterPath, PosterUrl, ReleaseYear, ReviewId, UserId, Username,
|
||||
PasswordHash, PosterUrl, ReleaseYear, ReviewId, UserId, Username,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -150,16 +150,11 @@ pub trait PosterFetcherClient: Send + Sync {
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait PosterStorage: Send + Sync {
|
||||
async fn store_poster(
|
||||
&self,
|
||||
movie_id: &MovieId,
|
||||
image_bytes: &[u8],
|
||||
) -> 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 trait ImageStorage: Send + Sync {
|
||||
/// Stores `image_bytes` at `key` and returns the stored key.
|
||||
async fn store(&self, key: &str, image_bytes: &[u8]) -> Result<String, DomainError>;
|
||||
async fn get(&self, key: &str) -> Result<Vec<u8>, DomainError>;
|
||||
async fn delete(&self, key: &str) -> Result<(), DomainError>;
|
||||
}
|
||||
|
||||
pub struct GeneratedToken {
|
||||
@@ -180,6 +175,12 @@ pub trait UserRepository: Send + Sync {
|
||||
async fn save(&self, user: &User) -> Result<(), 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 update_profile(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
bio: Option<String>,
|
||||
avatar_path: Option<String>,
|
||||
) -> Result<(), DomainError>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
|
||||
@@ -47,7 +47,7 @@ application = { workspace = true }
|
||||
auth = { workspace = true }
|
||||
metadata = { workspace = true }
|
||||
poster-fetcher = { workspace = true }
|
||||
poster-storage = { workspace = true }
|
||||
image-storage = { workspace = true }
|
||||
template-askama = { workspace = true }
|
||||
nats = { workspace = true, optional = true }
|
||||
rss = { workspace = true }
|
||||
|
||||
@@ -433,6 +433,13 @@ pub struct PaginationQueryParams {
|
||||
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)]
|
||||
pub struct MovieStatsDto {
|
||||
pub total_count: u64,
|
||||
|
||||
@@ -137,12 +137,12 @@ mod tests {
|
||||
collections::{PageParams, Paginated},
|
||||
},
|
||||
ports::{
|
||||
AuthService, DiaryRepository, EventPublisher, GeneratedToken, MetadataClient,
|
||||
MovieRepository, PasswordHasher, PosterFetcherClient, PosterStorage, ReviewRepository,
|
||||
AuthService, DiaryRepository, EventPublisher, GeneratedToken, ImageStorage,
|
||||
MetadataClient, MovieRepository, PasswordHasher, PosterFetcherClient, ReviewRepository,
|
||||
StatsRepository, UserRepository,
|
||||
},
|
||||
value_objects::{
|
||||
Email, ExternalMetadataId, MovieId, MovieTitle, PasswordHash, PosterPath, PosterUrl,
|
||||
Email, ExternalMetadataId, MovieId, MovieTitle, PasswordHash, PosterUrl,
|
||||
ReleaseYear, ReviewId, UserId,
|
||||
},
|
||||
};
|
||||
@@ -279,16 +279,10 @@ mod tests {
|
||||
}
|
||||
}
|
||||
#[async_trait::async_trait]
|
||||
impl PosterStorage for Panic {
|
||||
async fn store_poster(&self, _: &MovieId, _: &[u8]) -> Result<PosterPath, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn get_poster(&self, _: &PosterPath) -> Result<Vec<u8>, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn delete_poster(&self, _: &PosterPath) -> Result<(), DomainError> {
|
||||
panic!()
|
||||
}
|
||||
impl ImageStorage for Panic {
|
||||
async fn store(&self, _: &str, _: &[u8]) -> Result<String, DomainError> { panic!() }
|
||||
async fn get(&self, _: &str) -> Result<Vec<u8>, DomainError> { panic!() }
|
||||
async fn delete(&self, _: &str) -> Result<(), DomainError> { panic!() }
|
||||
}
|
||||
#[async_trait::async_trait]
|
||||
impl AuthService for Panic {
|
||||
@@ -334,6 +328,9 @@ mod tests {
|
||||
async fn list_with_stats(&self) -> Result<Vec<domain::models::UserSummary>, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn update_profile(&self, _: &UserId, _: Option<String>, _: Option<String>) -> Result<(), DomainError> {
|
||||
panic!()
|
||||
}
|
||||
}
|
||||
#[async_trait::async_trait]
|
||||
impl EventPublisher for Panic {
|
||||
@@ -442,6 +439,7 @@ mod tests {
|
||||
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_preview_page(&self, _: application::ports::ImportPreviewPageData) -> Result<String, String> { panic!() }
|
||||
fn render_profile_settings_page(&self, _: application::ports::ProfileSettingsPageData) -> Result<String, String> { panic!() }
|
||||
}
|
||||
impl crate::ports::RssFeedRenderer for Panic {
|
||||
fn render_feed(&self, _: &[DiaryEntry], _: &str) -> Result<String, String> {
|
||||
@@ -474,7 +472,7 @@ mod tests {
|
||||
stats_repository: Arc::clone(&repo) as _,
|
||||
metadata_client: 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 _,
|
||||
password_hasher: Arc::clone(&repo) as _,
|
||||
user_repository: Arc::clone(&repo) as _,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, Query, State},
|
||||
extract::{Multipart, Path, Query, State},
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
};
|
||||
@@ -20,7 +20,7 @@ use application::{
|
||||
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_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::{
|
||||
@@ -37,8 +37,8 @@ use crate::{
|
||||
ActivityFeedQueryParams, ActivityFeedResponse, DiaryEntryDto, DiaryQueryParams,
|
||||
DiaryResponse, DirectorStatDto, ExportQueryParams, FeedEntryDto, LogReviewData,
|
||||
LogReviewRequest, LoginRequest, LoginResponse, MonthActivityDto, MonthlyRatingDto,
|
||||
MovieDetailResponse, MovieDto, MovieStatsDto, PaginationQueryParams, RegisterRequest,
|
||||
ReviewDto, ReviewHistoryResponse, SocialFeedResponse, SocialReviewDto,
|
||||
MovieDetailResponse, MovieDto, MovieStatsDto, PaginationQueryParams, ProfileResponse,
|
||||
RegisterRequest, ReviewDto, ReviewHistoryResponse, SocialFeedResponse, SocialReviewDto,
|
||||
UserProfileQueryParams, UserProfileResponse, UserStatsDto, UserSummaryDto, UserTrendsDto,
|
||||
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 {
|
||||
MovieDto {
|
||||
id: movie.id().value(),
|
||||
|
||||
@@ -2,7 +2,7 @@ use std::str::FromStr;
|
||||
|
||||
use axum::{
|
||||
Form,
|
||||
extract::{Extension, Path, Query, State},
|
||||
extract::{Extension, Multipart, Path, Query, State},
|
||||
http::{HeaderValue, StatusCode, header::SET_COOKIE},
|
||||
response::{Html, IntoResponse, Redirect},
|
||||
};
|
||||
@@ -14,13 +14,13 @@ use application::ports::{FollowersPageData, FollowingPageData};
|
||||
use application::{
|
||||
commands::{DeleteReviewCommand, ExportCommand, LoginCommand, RegisterCommand},
|
||||
ports::{
|
||||
HtmlPageContext, LoginPageData, MovieDetailPageData, NewReviewPageData, RegisterPageData,
|
||||
RemoteActorView,
|
||||
HtmlPageContext, LoginPageData, MovieDetailPageData, NewReviewPageData,
|
||||
ProfileSettingsPageData, RegisterPageData, RemoteActorView,
|
||||
},
|
||||
queries::GetMovieSocialPageQuery,
|
||||
use_cases::{
|
||||
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;
|
||||
@@ -966,3 +966,97 @@ 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 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()
|
||||
}
|
||||
|
||||
25
crates/presentation/src/handlers/images.rs
Normal file
25
crates/presentation/src/handlers/images.rs
Normal 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(),
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
pub mod html;
|
||||
pub mod posters;
|
||||
pub mod images;
|
||||
pub mod rss;
|
||||
pub mod api;
|
||||
pub mod import;
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::{StatusCode, header},
|
||||
response::IntoResponse,
|
||||
};
|
||||
|
||||
use domain::value_objects::PosterPath;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
pub async fn get_poster(
|
||||
State(state): State<AppState>,
|
||||
Path(path): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
// If path is a remote URL, redirect directly instead of serving from local storage.
|
||||
if path.starts_with("http://") || path.starts_with("https://") {
|
||||
return axum::response::Redirect::temporary(&path).into_response();
|
||||
}
|
||||
|
||||
let poster_path = match PosterPath::new(path) {
|
||||
Ok(p) => p,
|
||||
Err(_) => return StatusCode::BAD_REQUEST.into_response(),
|
||||
};
|
||||
match state.app_ctx.poster_storage.get_poster(&poster_path).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(),
|
||||
}
|
||||
}
|
||||
@@ -49,7 +49,7 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
|
||||
let (auth_service, password_hasher) = auth::create()?;
|
||||
let metadata_client = metadata::create()?;
|
||||
let poster_fetcher = poster_fetcher::create()?;
|
||||
let poster_storage = poster_storage::create()?;
|
||||
let image_storage = image_storage::create()?;
|
||||
|
||||
let (movie_repository, review_repository, diary_repository, stats_repository, user_repository, import_session_repository, import_profile_repository, db_pool) =
|
||||
match backend.as_str() {
|
||||
@@ -155,7 +155,7 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
|
||||
stats_repository,
|
||||
metadata_client,
|
||||
poster_fetcher,
|
||||
poster_storage,
|
||||
image_storage,
|
||||
event_publisher: event_publisher_arc,
|
||||
auth_service,
|
||||
password_hasher,
|
||||
|
||||
@@ -74,8 +74,14 @@ fn html_routes(rate_limit: u64) -> Router<AppState> {
|
||||
routing::post(handlers::html::post_delete_review),
|
||||
)
|
||||
.route(
|
||||
"/posters/{*path}",
|
||||
routing::get(handlers::posters::get_poster),
|
||||
"/images/{*key}",
|
||||
routing::get(handlers::images::get_image),
|
||||
)
|
||||
.route(
|
||||
"/posters/{path}",
|
||||
routing::get(|axum::extract::Path(p): axum::extract::Path<String>| async move {
|
||||
axum::response::Redirect::permanent(&format!("/images/{}", p))
|
||||
}),
|
||||
)
|
||||
.route("/diary/export", routing::get(handlers::html::get_export))
|
||||
.route("/import", routing::get(handlers::import::get_import_page))
|
||||
@@ -89,6 +95,11 @@ fn html_routes(rate_limit: u64) -> Router<AppState> {
|
||||
.route(
|
||||
"/users/{id}/feed.rss",
|
||||
routing::get(handlers::rss::get_user_feed),
|
||||
)
|
||||
.route(
|
||||
"/settings/profile",
|
||||
routing::get(handlers::html::get_profile_settings)
|
||||
.post(handlers::html::post_profile_settings),
|
||||
);
|
||||
|
||||
#[cfg(feature = "federation")]
|
||||
@@ -171,7 +182,8 @@ fn api_routes(rate_limit: u64) -> Router<AppState> {
|
||||
.route("/import/sessions/{id}/mapping", routing::put(handlers::import::api_put_mapping))
|
||||
.route("/import/sessions/{id}/confirm", routing::post(handlers::import::api_post_confirm))
|
||||
.route("/import/profiles", routing::get(handlers::import::api_get_profiles).post(handlers::import::api_post_profile))
|
||||
.route("/import/profiles/{id}", routing::delete(handlers::import::api_delete_profile));
|
||||
.route("/import/profiles/{id}", routing::delete(handlers::import::api_delete_profile))
|
||||
.route("/profile", routing::get(handlers::api::get_profile).put(handlers::api::update_profile_handler));
|
||||
|
||||
#[cfg(feature = "federation")]
|
||||
let base = base.merge(federation_api_routes());
|
||||
|
||||
@@ -12,11 +12,11 @@ use domain::{
|
||||
events::DomainEvent,
|
||||
models::{Movie, User},
|
||||
ports::{
|
||||
AuthService, EventPublisher, GeneratedToken, MetadataClient, MetadataSearchCriteria,
|
||||
PasswordHasher, PosterFetcherClient, PosterStorage, UserRepository,
|
||||
AuthService, EventPublisher, GeneratedToken, ImageStorage, MetadataClient, MetadataSearchCriteria,
|
||||
PasswordHasher, PosterFetcherClient, UserRepository,
|
||||
},
|
||||
value_objects::{
|
||||
Email, ExternalMetadataId, MovieId, PasswordHash, PosterPath, PosterUrl, UserId,
|
||||
Email, ExternalMetadataId, PasswordHash, PosterUrl, UserId,
|
||||
},
|
||||
};
|
||||
use http_body_util::BodyExt;
|
||||
@@ -57,18 +57,12 @@ impl PosterFetcherClient for PanicFetcher {
|
||||
}
|
||||
}
|
||||
|
||||
struct PanicStorage;
|
||||
struct PanicImageStorage;
|
||||
#[async_trait]
|
||||
impl PosterStorage for PanicStorage {
|
||||
async fn store_poster(&self, _: &MovieId, _: &[u8]) -> Result<PosterPath, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn get_poster(&self, _: &PosterPath) -> Result<Vec<u8>, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn delete_poster(&self, _: &PosterPath) -> Result<(), DomainError> {
|
||||
panic!()
|
||||
}
|
||||
impl ImageStorage for PanicImageStorage {
|
||||
async fn store(&self, _: &str, _: &[u8]) -> Result<String, DomainError> { panic!() }
|
||||
async fn get(&self, _: &str) -> Result<Vec<u8>, DomainError> { panic!() }
|
||||
async fn delete(&self, _: &str) -> Result<(), DomainError> { panic!() }
|
||||
}
|
||||
|
||||
struct PanicHasher;
|
||||
@@ -114,6 +108,9 @@ impl UserRepository for NobodyUserRepo {
|
||||
async fn list_with_stats(&self) -> Result<Vec<domain::models::UserSummary>, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn update_profile(&self, _: &UserId, _: Option<String>, _: Option<String>) -> Result<(), DomainError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
struct PanicExporter;
|
||||
@@ -194,7 +191,7 @@ async fn test_app() -> Router {
|
||||
stats_repository: Arc::clone(&repo) as _,
|
||||
metadata_client: Arc::new(PanicMeta),
|
||||
poster_fetcher: Arc::new(PanicFetcher),
|
||||
poster_storage: Arc::new(PanicStorage),
|
||||
image_storage: Arc::new(PanicImageStorage),
|
||||
event_publisher: Arc::new(NoopEventPublisher),
|
||||
auth_service: Arc::new(PanicAuth),
|
||||
password_hasher: Arc::new(PanicHasher),
|
||||
|
||||
@@ -30,7 +30,7 @@ async-trait = { workspace = true }
|
||||
auth = { workspace = true }
|
||||
metadata = { workspace = true }
|
||||
poster-fetcher = { workspace = true }
|
||||
poster-storage = { workspace = true }
|
||||
image-storage = { workspace = true }
|
||||
poster-sync = { workspace = true }
|
||||
export = { workspace = true }
|
||||
importer = { workspace = true }
|
||||
|
||||
@@ -23,7 +23,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
let (auth_service, password_hasher) = auth::create()?;
|
||||
let metadata_client = metadata::create()?;
|
||||
let poster_fetcher = poster_fetcher::create()?;
|
||||
let poster_storage = poster_storage::create()?;
|
||||
let image_storage = image_storage::create()?;
|
||||
|
||||
let (movie_repository, review_repository, diary_repository, stats_repository, user_repository, import_session_repository, import_profile_repository, db_pool) =
|
||||
match backend.as_str() {
|
||||
@@ -82,7 +82,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
stats_repository,
|
||||
metadata_client,
|
||||
poster_fetcher,
|
||||
poster_storage,
|
||||
image_storage,
|
||||
event_publisher: event_publisher_arc,
|
||||
auth_service,
|
||||
password_hasher,
|
||||
@@ -112,12 +112,12 @@ async fn main() -> anyhow::Result<()> {
|
||||
Arc::clone(&ctx.movie_repository),
|
||||
Arc::clone(&ctx.metadata_client),
|
||||
Arc::clone(&ctx.poster_fetcher),
|
||||
Arc::clone(&ctx.poster_storage),
|
||||
Arc::clone(&ctx.image_storage),
|
||||
3,
|
||||
)) as Arc<dyn EventHandler>;
|
||||
|
||||
let cleanup = Arc::new(poster_storage::PosterCleanupHandler::new(
|
||||
Arc::clone(&ctx.poster_storage),
|
||||
let cleanup = Arc::new(image_storage::ImageCleanupHandler::new(
|
||||
Arc::clone(&ctx.image_storage),
|
||||
)) as Arc<dyn EventHandler>;
|
||||
|
||||
#[cfg(not(feature = "federation"))]
|
||||
|
||||
Reference in New Issue
Block a user