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",
|
"icu_properties",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "image-storage"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"async-trait",
|
||||||
|
"domain",
|
||||||
|
"infer",
|
||||||
|
"object_store",
|
||||||
|
"tokio",
|
||||||
|
"tracing",
|
||||||
|
"uuid",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "impl-more"
|
name = "impl-more"
|
||||||
version = "0.1.9"
|
version = "0.1.9"
|
||||||
@@ -3424,20 +3438,6 @@ dependencies = [
|
|||||||
"reqwest 0.13.3",
|
"reqwest 0.13.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "poster-storage"
|
|
||||||
version = "0.1.0"
|
|
||||||
dependencies = [
|
|
||||||
"anyhow",
|
|
||||||
"async-trait",
|
|
||||||
"domain",
|
|
||||||
"infer",
|
|
||||||
"object_store",
|
|
||||||
"tokio",
|
|
||||||
"tracing",
|
|
||||||
"uuid",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "poster-sync"
|
name = "poster-sync"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -3539,13 +3539,13 @@ dependencies = [
|
|||||||
"dotenvy",
|
"dotenvy",
|
||||||
"export",
|
"export",
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
|
"image-storage",
|
||||||
"importer",
|
"importer",
|
||||||
"infer",
|
"infer",
|
||||||
"metadata",
|
"metadata",
|
||||||
"nats",
|
"nats",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"poster-fetcher",
|
"poster-fetcher",
|
||||||
"poster-storage",
|
|
||||||
"postgres",
|
"postgres",
|
||||||
"postgres-event-queue",
|
"postgres-event-queue",
|
||||||
"postgres-federation",
|
"postgres-federation",
|
||||||
@@ -6340,11 +6340,11 @@ dependencies = [
|
|||||||
"dotenvy",
|
"dotenvy",
|
||||||
"export",
|
"export",
|
||||||
"futures",
|
"futures",
|
||||||
|
"image-storage",
|
||||||
"importer",
|
"importer",
|
||||||
"metadata",
|
"metadata",
|
||||||
"nats",
|
"nats",
|
||||||
"poster-fetcher",
|
"poster-fetcher",
|
||||||
"poster-storage",
|
|
||||||
"poster-sync",
|
"poster-sync",
|
||||||
"postgres",
|
"postgres",
|
||||||
"postgres-event-queue",
|
"postgres-event-queue",
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ members = [
|
|||||||
"crates/adapters/event-publisher",
|
"crates/adapters/event-publisher",
|
||||||
"crates/adapters/metadata",
|
"crates/adapters/metadata",
|
||||||
"crates/adapters/poster-fetcher",
|
"crates/adapters/poster-fetcher",
|
||||||
"crates/adapters/poster-storage",
|
"crates/adapters/image-storage",
|
||||||
"crates/adapters/poster-sync",
|
"crates/adapters/poster-sync",
|
||||||
"crates/adapters/rss",
|
"crates/adapters/rss",
|
||||||
"crates/adapters/sqlite",
|
"crates/adapters/sqlite",
|
||||||
@@ -59,7 +59,7 @@ presentation = { path = "crates/presentation" }
|
|||||||
auth = { path = "crates/adapters/auth" }
|
auth = { path = "crates/adapters/auth" }
|
||||||
metadata = { path = "crates/adapters/metadata" }
|
metadata = { path = "crates/adapters/metadata" }
|
||||||
poster-fetcher = { path = "crates/adapters/poster-fetcher" }
|
poster-fetcher = { path = "crates/adapters/poster-fetcher" }
|
||||||
poster-storage = { path = "crates/adapters/poster-storage" }
|
image-storage = { path = "crates/adapters/image-storage" }
|
||||||
poster-sync = { path = "crates/adapters/poster-sync" }
|
poster-sync = { path = "crates/adapters/poster-sync" }
|
||||||
event-publisher = { path = "crates/adapters/event-publisher" }
|
event-publisher = { path = "crates/adapters/event-publisher" }
|
||||||
rss = { path = "crates/adapters/rss" }
|
rss = { path = "crates/adapters/rss" }
|
||||||
|
|||||||
@@ -76,14 +76,14 @@ OMDB_API_KEY=your-key
|
|||||||
# Public base URL (used for ActivityPub actor URLs and canonical links)
|
# Public base URL (used for ActivityPub actor URLs and canonical links)
|
||||||
BASE_URL=https://yourdomain.example.com
|
BASE_URL=https://yourdomain.example.com
|
||||||
|
|
||||||
# Poster storage — pick one backend:
|
# Image storage — pick one backend:
|
||||||
|
|
||||||
# Option A: local filesystem (zero deps)
|
# Option A: local filesystem (zero deps)
|
||||||
POSTER_STORAGE_BACKEND=local
|
IMAGE_STORAGE_BACKEND=local
|
||||||
POSTER_STORAGE_PATH=./posters
|
IMAGE_STORAGE_PATH=./images
|
||||||
|
|
||||||
# Option B: S3-compatible (MinIO, AWS S3, etc.)
|
# Option B: S3-compatible (MinIO, AWS S3, etc.)
|
||||||
# POSTER_STORAGE_BACKEND=s3
|
# IMAGE_STORAGE_BACKEND=s3
|
||||||
# MINIO_ENDPOINT=http://localhost:9000
|
# MINIO_ENDPOINT=http://localhost:9000
|
||||||
# MINIO_BUCKET=posters
|
# MINIO_BUCKET=posters
|
||||||
# MINIO_REGION=minio
|
# MINIO_REGION=minio
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ use activitypub_federation::{
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
|
#[derive(Clone, Default, Debug, Serialize, Deserialize)]
|
||||||
|
#[serde(rename = "Announce")]
|
||||||
|
pub struct AnnounceType;
|
||||||
|
|
||||||
use crate::actors::DbActor;
|
use crate::actors::DbActor;
|
||||||
use crate::data::FederationData;
|
use crate::data::FederationData;
|
||||||
use crate::error::Error;
|
use crate::error::Error;
|
||||||
@@ -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 ---
|
// --- Inbox dispatch enum ---
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Serialize)]
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
@@ -350,4 +402,6 @@ pub enum InboxActivities {
|
|||||||
Delete(DeleteActivity),
|
Delete(DeleteActivity),
|
||||||
#[serde(rename = "Update")]
|
#[serde(rename = "Update")]
|
||||||
Update(UpdateActivity),
|
Update(UpdateActivity),
|
||||||
|
#[serde(rename = "Announce")]
|
||||||
|
Announce(AnnounceActivity),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,15 @@ pub struct DbActor {
|
|||||||
pub following_url: Url,
|
pub following_url: Url,
|
||||||
pub ap_id: Url,
|
pub ap_id: Url,
|
||||||
pub last_refreshed_at: DateTime<Utc>,
|
pub last_refreshed_at: DateTime<Utc>,
|
||||||
|
pub bio: Option<String>,
|
||||||
|
pub avatar_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)]
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
@@ -41,6 +50,15 @@ pub struct Person {
|
|||||||
following: Url,
|
following: Url,
|
||||||
public_key: PublicKey,
|
public_key: PublicKey,
|
||||||
name: Option<String>,
|
name: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
summary: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
icon: Option<ApImageObject>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
url: Option<Url>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
discoverable: Option<bool>,
|
||||||
|
manually_approves_followers: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_local_actor(
|
pub async fn get_local_actor(
|
||||||
@@ -86,6 +104,8 @@ pub async fn get_local_actor(
|
|||||||
following_url,
|
following_url,
|
||||||
ap_id,
|
ap_id,
|
||||||
last_refreshed_at: Utc::now(),
|
last_refreshed_at: Utc::now(),
|
||||||
|
bio: user.bio,
|
||||||
|
avatar_path: user.avatar_path,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,16 +163,27 @@ impl Object for DbActor {
|
|||||||
following_url,
|
following_url,
|
||||||
ap_id,
|
ap_id,
|
||||||
last_refreshed_at: Utc::now(),
|
last_refreshed_at: Utc::now(),
|
||||||
|
bio: None,
|
||||||
|
avatar_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 {
|
let public_key = PublicKey {
|
||||||
id: format!("{}#main-key", &self.ap_id),
|
id: format!("{}#main-key", &self.ap_id),
|
||||||
owner: self.ap_id.clone(),
|
owner: self.ap_id.clone(),
|
||||||
public_key_pem: self.public_key_pem.clone(),
|
public_key_pem: self.public_key_pem.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let icon = self.avatar_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 {
|
Ok(Person {
|
||||||
kind: Default::default(),
|
kind: Default::default(),
|
||||||
id: self.ap_id.clone().into(),
|
id: self.ap_id.clone().into(),
|
||||||
@@ -163,6 +194,11 @@ impl Object for DbActor {
|
|||||||
following: self.following_url.clone(),
|
following: self.following_url.clone(),
|
||||||
public_key,
|
public_key,
|
||||||
name: Some(self.username.clone()),
|
name: Some(self.username.clone()),
|
||||||
|
summary: self.bio.clone(),
|
||||||
|
icon,
|
||||||
|
url: Some(profile_url),
|
||||||
|
discoverable: Some(true),
|
||||||
|
manually_approves_followers: false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,6 +218,7 @@ impl Object for DbActor {
|
|||||||
inbox_url: json.inbox.to_string(),
|
inbox_url: json.inbox.to_string(),
|
||||||
shared_inbox_url: None,
|
shared_inbox_url: None,
|
||||||
display_name: json.name.clone(),
|
display_name: json.name.clone(),
|
||||||
|
avatar_url: json.icon.as_ref().map(|i| i.url.to_string()),
|
||||||
};
|
};
|
||||||
data.federation_repo.upsert_remote_actor(actor).await?;
|
data.federation_repo.upsert_remote_actor(actor).await?;
|
||||||
|
|
||||||
@@ -204,6 +241,8 @@ impl Object for DbActor {
|
|||||||
following_url,
|
following_url,
|
||||||
ap_id,
|
ap_id,
|
||||||
last_refreshed_at: Utc::now(),
|
last_refreshed_at: Utc::now(),
|
||||||
|
bio: None,
|
||||||
|
avatar_path: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -221,3 +260,40 @@ impl Actor for DbActor {
|
|||||||
self.inbox_url.clone()
|
self.inbox_url.clone()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn person_serializes_with_enriched_fields() {
|
||||||
|
let person = Person {
|
||||||
|
kind: Default::default(),
|
||||||
|
id: "https://example.com/users/1".parse::<url::Url>().unwrap().into(),
|
||||||
|
preferred_username: "alice".to_string(),
|
||||||
|
inbox: "https://example.com/users/1/inbox".parse().unwrap(),
|
||||||
|
outbox: "https://example.com/users/1/outbox".parse().unwrap(),
|
||||||
|
followers: "https://example.com/users/1/followers".parse().unwrap(),
|
||||||
|
following: "https://example.com/users/1/following".parse().unwrap(),
|
||||||
|
public_key: PublicKey {
|
||||||
|
id: "https://example.com/users/1#main-key".to_string(),
|
||||||
|
owner: "https://example.com/users/1".parse().unwrap(),
|
||||||
|
public_key_pem: "pem".to_string(),
|
||||||
|
},
|
||||||
|
name: Some("Alice".to_string()),
|
||||||
|
summary: Some("Bio text".to_string()),
|
||||||
|
icon: Some(ApImageObject {
|
||||||
|
kind: "Image".to_string(),
|
||||||
|
url: "https://example.com/images/avatars/1".parse().unwrap(),
|
||||||
|
}),
|
||||||
|
url: Some("https://example.com/u/alice".parse().unwrap()),
|
||||||
|
discoverable: Some(true),
|
||||||
|
manually_approves_followers: false,
|
||||||
|
};
|
||||||
|
let json = serde_json::to_value(&person).unwrap();
|
||||||
|
assert_eq!(json["discoverable"], true);
|
||||||
|
assert_eq!(json["summary"], "Bio text");
|
||||||
|
assert_eq!(json["icon"]["type"], "Image");
|
||||||
|
assert!(json.get("manuallyApprovesFollowers").is_some());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -10,6 +11,15 @@ pub trait ApObjectHandler: Send + Sync {
|
|||||||
user_id: uuid::Uuid,
|
user_id: uuid::Uuid,
|
||||||
) -> anyhow::Result<Vec<(Url, serde_json::Value)>>;
|
) -> anyhow::Result<Vec<(Url, serde_json::Value)>>;
|
||||||
|
|
||||||
|
/// Returns up to `limit` objects ordered newest-first, published before `before`.
|
||||||
|
/// Returns (ap_id, object_json, published_at).
|
||||||
|
async fn get_local_objects_page(
|
||||||
|
&self,
|
||||||
|
user_id: uuid::Uuid,
|
||||||
|
before: Option<DateTime<Utc>>,
|
||||||
|
limit: usize,
|
||||||
|
) -> anyhow::Result<Vec<(Url, serde_json::Value, DateTime<Utc>)>>;
|
||||||
|
|
||||||
/// Incoming Create activity — persist remote content.
|
/// Incoming Create activity — persist remote content.
|
||||||
async fn on_create(
|
async fn on_create(
|
||||||
&self,
|
&self,
|
||||||
|
|||||||
@@ -1,9 +1,20 @@
|
|||||||
use activitypub_federation::{axum::json::FederationJson, config::Data};
|
use axum::extract::{Path, Query};
|
||||||
use axum::extract::Path;
|
use axum::response::IntoResponse;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
use crate::data::FederationData;
|
use activitypub_federation::{config::Data, fetch::object_id::ObjectId, kinds::activity::CreateType};
|
||||||
use crate::error::Error;
|
|
||||||
|
use crate::{activities::CreateActivity, data::FederationData, error::Error};
|
||||||
|
|
||||||
|
const PAGE_SIZE: usize = 20;
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct OutboxQuery {
|
||||||
|
page: Option<bool>,
|
||||||
|
before: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize)]
|
#[derive(Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
@@ -14,13 +25,28 @@ pub struct OrderedCollection {
|
|||||||
kind: String,
|
kind: String,
|
||||||
id: String,
|
id: String,
|
||||||
total_items: u64,
|
total_items: u64,
|
||||||
|
first: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct OrderedCollectionPage {
|
||||||
|
#[serde(rename = "@context")]
|
||||||
|
context: String,
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
kind: String,
|
||||||
|
id: String,
|
||||||
|
part_of: String,
|
||||||
ordered_items: Vec<serde_json::Value>,
|
ordered_items: Vec<serde_json::Value>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
next: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn outbox_handler(
|
pub async fn outbox_handler(
|
||||||
Path(user_id_str): Path<String>,
|
Path(user_id_str): Path<String>,
|
||||||
|
Query(query): Query<OutboxQuery>,
|
||||||
data: Data<FederationData>,
|
data: Data<FederationData>,
|
||||||
) -> Result<FederationJson<OrderedCollection>, Error> {
|
) -> Result<axum::response::Response, Error> {
|
||||||
let uuid = uuid::Uuid::parse_str(&user_id_str)
|
let uuid = uuid::Uuid::parse_str(&user_id_str)
|
||||||
.map_err(|_| Error::bad_request(anyhow::anyhow!("invalid user id")))?;
|
.map_err(|_| Error::bad_request(anyhow::anyhow!("invalid user id")))?;
|
||||||
|
|
||||||
@@ -30,19 +56,80 @@ pub async fn outbox_handler(
|
|||||||
.map_err(Error::from)?
|
.map_err(Error::from)?
|
||||||
.ok_or_else(|| Error::not_found(anyhow::anyhow!("user not found")))?;
|
.ok_or_else(|| Error::not_found(anyhow::anyhow!("user not found")))?;
|
||||||
|
|
||||||
let objects = data
|
let outbox_url = format!("{}/users/{}/outbox", data.base_url, user_id_str);
|
||||||
|
|
||||||
|
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
|
.object_handler
|
||||||
.get_local_objects_for_user(uuid)
|
.get_local_objects_for_user(uuid)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| Error::from(anyhow::anyhow!(e)))?;
|
.map_err(|e| Error::from(anyhow::anyhow!("{}", e)))?
|
||||||
|
.len() as u64;
|
||||||
|
|
||||||
let outbox_url = format!("{}/users/{}/outbox", data.base_url, user_id_str);
|
Ok(axum::Json(OrderedCollection {
|
||||||
|
|
||||||
Ok(FederationJson(OrderedCollection {
|
|
||||||
context: "https://www.w3.org/ns/activitystreams".to_string(),
|
context: "https://www.w3.org/ns/activitystreams".to_string(),
|
||||||
kind: "OrderedCollection".to_string(),
|
kind: "OrderedCollection".to_string(),
|
||||||
id: outbox_url,
|
id: outbox_url.clone(),
|
||||||
total_items: objects.len() as u64,
|
total_items: total,
|
||||||
ordered_items: vec![],
|
first: format!("{}?page=true", outbox_url),
|
||||||
}))
|
})
|
||||||
|
.into_response())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ pub struct RemoteActor {
|
|||||||
pub inbox_url: String,
|
pub inbox_url: String,
|
||||||
pub shared_inbox_url: Option<String>,
|
pub shared_inbox_url: Option<String>,
|
||||||
pub display_name: Option<String>,
|
pub display_name: Option<String>,
|
||||||
|
pub avatar_url: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -88,4 +89,12 @@ pub trait FederationRepository: Send + Sync {
|
|||||||
remote_actor_url: &str,
|
remote_actor_url: &str,
|
||||||
status: FollowingStatus,
|
status: FollowingStatus,
|
||||||
) -> Result<()>;
|
) -> Result<()>;
|
||||||
|
async fn add_announce(
|
||||||
|
&self,
|
||||||
|
activity_id: &str,
|
||||||
|
object_url: &str,
|
||||||
|
actor_url: &str,
|
||||||
|
announced_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
) -> Result<()>;
|
||||||
|
async fn count_announces(&self, object_url: &str) -> Result<usize>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ use axum::{Router, routing::get, routing::post};
|
|||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
activities::{AcceptActivity, CreateActivity, FollowActivity, RejectActivity, UndoActivity},
|
activities::{AcceptActivity, CreateActivity, FollowActivity, RejectActivity, UndoActivity, UpdateActivity},
|
||||||
actors::{DbActor, get_local_actor},
|
actors::{DbActor, get_local_actor},
|
||||||
content::ApObjectHandler,
|
content::ApObjectHandler,
|
||||||
data::FederationData,
|
data::FederationData,
|
||||||
@@ -24,6 +24,24 @@ use crate::{
|
|||||||
webfinger::webfinger_handler,
|
webfinger::webfinger_handler,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
fn collect_inboxes(followers: &[crate::repository::Follower]) -> Vec<Url> {
|
||||||
|
let mut seen = std::collections::HashSet::new();
|
||||||
|
let mut inboxes = Vec::new();
|
||||||
|
for f in followers {
|
||||||
|
let inbox_str = f
|
||||||
|
.actor
|
||||||
|
.shared_inbox_url
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or(&f.actor.inbox_url);
|
||||||
|
if seen.insert(inbox_str.to_string()) {
|
||||||
|
if let Ok(url) = Url::parse(inbox_str) {
|
||||||
|
inboxes.push(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
inboxes
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) async fn send_with_retry(
|
pub(crate) async fn send_with_retry(
|
||||||
sends: Vec<SendActivityTask>,
|
sends: Vec<SendActivityTask>,
|
||||||
data: &activitypub_federation::config::Data<FederationData>,
|
data: &activitypub_federation::config::Data<FederationData>,
|
||||||
@@ -150,6 +168,7 @@ impl ActivityPubService {
|
|||||||
inbox_url: remote_actor.inbox_url.to_string(),
|
inbox_url: remote_actor.inbox_url.to_string(),
|
||||||
shared_inbox_url: None,
|
shared_inbox_url: None,
|
||||||
display_name: Some(remote_actor.username.clone()),
|
display_name: Some(remote_actor.username.clone()),
|
||||||
|
avatar_url: None,
|
||||||
};
|
};
|
||||||
data.federation_repo
|
data.federation_repo
|
||||||
.add_following(local_user_id, remote, &follow_id_str)
|
.add_following(local_user_id, remote, &follow_id_str)
|
||||||
@@ -289,7 +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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -437,10 +460,7 @@ impl ActivityPubService {
|
|||||||
};
|
};
|
||||||
let create_with_ctx = WithContext::new_default(create);
|
let create_with_ctx = WithContext::new_default(create);
|
||||||
|
|
||||||
let inboxes: Vec<Url> = accepted
|
let inboxes = collect_inboxes(&accepted);
|
||||||
.iter()
|
|
||||||
.filter_map(|f| Url::parse(&f.actor.inbox_url).ok())
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let sends =
|
let sends =
|
||||||
SendActivityTask::prepare(&create_with_ctx, &local_actor, inboxes, &data).await?;
|
SendActivityTask::prepare(&create_with_ctx, &local_actor, inboxes, &data).await?;
|
||||||
@@ -455,6 +475,57 @@ impl ActivityPubService {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn broadcast_actor_update(&self, user_id: uuid::Uuid) -> anyhow::Result<()> {
|
||||||
|
use activitypub_federation::traits::Object;
|
||||||
|
|
||||||
|
let data = self.federation_config.to_request_data();
|
||||||
|
let local_actor = get_local_actor(user_id, &data)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||||
|
|
||||||
|
let person = local_actor.clone().into_json(&data).await
|
||||||
|
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||||
|
let person_json = serde_json::to_value(&person)?;
|
||||||
|
|
||||||
|
let update_id = Url::parse(&format!(
|
||||||
|
"{}/activities/update/{}",
|
||||||
|
self.base_url,
|
||||||
|
uuid::Uuid::new_v4()
|
||||||
|
))?;
|
||||||
|
|
||||||
|
let update = UpdateActivity {
|
||||||
|
id: update_id,
|
||||||
|
kind: Default::default(),
|
||||||
|
actor: ObjectId::from(local_actor.ap_id.clone()),
|
||||||
|
object: person_json,
|
||||||
|
};
|
||||||
|
|
||||||
|
let followers = data.federation_repo.get_followers(user_id).await?;
|
||||||
|
let accepted: Vec<_> = followers
|
||||||
|
.into_iter()
|
||||||
|
.filter(|f| f.status == FollowerStatus::Accepted)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if accepted.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let inboxes = collect_inboxes(&accepted);
|
||||||
|
let sends = SendActivityTask::prepare(
|
||||||
|
&WithContext::new_default(update),
|
||||||
|
&local_actor,
|
||||||
|
inboxes,
|
||||||
|
&data,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let failures = send_with_retry(sends, &data).await;
|
||||||
|
if !failures.is_empty() {
|
||||||
|
tracing::warn!(count = failures.len(), "actor update delivery failures");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
async fn follow_local(
|
async fn follow_local(
|
||||||
&self,
|
&self,
|
||||||
local_user_id: uuid::Uuid,
|
local_user_id: uuid::Uuid,
|
||||||
@@ -493,6 +564,7 @@ impl ActivityPubService {
|
|||||||
inbox_url: target_inbox_url,
|
inbox_url: target_inbox_url,
|
||||||
shared_inbox_url: None,
|
shared_inbox_url: None,
|
||||||
display_name: Some(target.username),
|
display_name: Some(target.username),
|
||||||
|
avatar_url: None,
|
||||||
};
|
};
|
||||||
data.federation_repo
|
data.federation_repo
|
||||||
.add_following(local_user_id, target_as_remote, &follow_id)
|
.add_following(local_user_id, target_as_remote, &follow_id)
|
||||||
@@ -618,3 +690,47 @@ impl ActivityPubService {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::repository::{Follower, FollowerStatus, RemoteActor};
|
||||||
|
|
||||||
|
fn make_follower(inbox: &str, shared: Option<&str>) -> Follower {
|
||||||
|
Follower {
|
||||||
|
actor: RemoteActor {
|
||||||
|
url: format!("https://remote/{}", inbox),
|
||||||
|
handle: "user".to_string(),
|
||||||
|
inbox_url: inbox.to_string(),
|
||||||
|
shared_inbox_url: shared.map(|s| s.to_string()),
|
||||||
|
display_name: None,
|
||||||
|
avatar_url: None,
|
||||||
|
},
|
||||||
|
status: FollowerStatus::Accepted,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn collect_inboxes_deduplicates_shared() {
|
||||||
|
let followers = vec![
|
||||||
|
make_follower("https://mastodon.social/users/a/inbox", Some("https://mastodon.social/inbox")),
|
||||||
|
make_follower("https://mastodon.social/users/b/inbox", Some("https://mastodon.social/inbox")),
|
||||||
|
make_follower("https://other.instance/users/c/inbox", None),
|
||||||
|
];
|
||||||
|
let inboxes = collect_inboxes(&followers);
|
||||||
|
assert_eq!(inboxes.len(), 2);
|
||||||
|
let strs: Vec<_> = inboxes.iter().map(|u| u.as_str()).collect();
|
||||||
|
assert!(strs.contains(&"https://mastodon.social/inbox"));
|
||||||
|
assert!(strs.contains(&"https://other.instance/users/c/inbox"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn collect_inboxes_falls_back_to_individual_inbox() {
|
||||||
|
let followers = vec![
|
||||||
|
make_follower("https://example.com/users/x/inbox", None),
|
||||||
|
];
|
||||||
|
let inboxes = collect_inboxes(&followers);
|
||||||
|
assert_eq!(inboxes.len(), 1);
|
||||||
|
assert_eq!(inboxes[0].as_str(), "https://example.com/users/x/inbox");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ use async_trait::async_trait;
|
|||||||
pub struct ApUser {
|
pub struct ApUser {
|
||||||
pub id: uuid::Uuid,
|
pub id: uuid::Uuid,
|
||||||
pub username: String,
|
pub username: String,
|
||||||
|
pub bio: Option<String>,
|
||||||
|
pub avatar_path: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
|
|||||||
@@ -46,6 +46,11 @@ impl EventHandler for ActivityPubEventHandler {
|
|||||||
.on_review_logged(user_id, review_id)
|
.on_review_logged(user_id, review_id)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| DomainError::InfrastructureError(e.to_string())),
|
.map_err(|e| DomainError::InfrastructureError(e.to_string())),
|
||||||
|
DomainEvent::UserUpdated { user_id } => self
|
||||||
|
.ap_service
|
||||||
|
.broadcast_actor_update(user_id.value())
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::InfrastructureError(e.to_string())),
|
||||||
_ => Ok(()),
|
_ => Ok(()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -78,7 +83,7 @@ impl ActivityPubEventHandler {
|
|||||||
let poster_url = movie
|
let poster_url = movie
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|m| m.poster_path())
|
.and_then(|m| m.poster_path())
|
||||||
.map(|p| format!("{}/posters/{}", self.base_url, p.value()));
|
.map(|p| format!("{}/images/{}", self.base_url, p.value()));
|
||||||
|
|
||||||
let obj = review_to_ap_object(
|
let obj = review_to_ap_object(
|
||||||
&review,
|
&review,
|
||||||
|
|||||||
@@ -75,6 +75,73 @@ impl ApObjectHandler for ReviewObjectHandler {
|
|||||||
Ok(results)
|
Ok(results)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn get_local_objects_page(
|
||||||
|
&self,
|
||||||
|
user_id: uuid::Uuid,
|
||||||
|
before: Option<chrono::DateTime<chrono::Utc>>,
|
||||||
|
limit: usize,
|
||||||
|
) -> anyhow::Result<Vec<(url::Url, serde_json::Value, chrono::DateTime<chrono::Utc>)>> {
|
||||||
|
use domain::value_objects::UserId;
|
||||||
|
|
||||||
|
let domain_user_id = UserId::from_uuid(user_id);
|
||||||
|
let history = self
|
||||||
|
.diary_repository
|
||||||
|
.get_user_history(&domain_user_id)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let mut results = Vec::new();
|
||||||
|
for entry in history {
|
||||||
|
let review = entry.review();
|
||||||
|
if !matches!(review.source(), ReviewSource::Local) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let published =
|
||||||
|
chrono::DateTime::from_naive_utc_and_offset(*review.watched_at(), chrono::Utc);
|
||||||
|
|
||||||
|
if let Some(cutoff) = before {
|
||||||
|
if published >= cutoff {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let ap_id = review_url(&self.base_url, review.id());
|
||||||
|
let actor_url = actor_url(&self.base_url, user_id);
|
||||||
|
|
||||||
|
let movie = self
|
||||||
|
.movie_repository
|
||||||
|
.get_movie_by_id(review.movie_id())
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten();
|
||||||
|
let movie_title = movie
|
||||||
|
.as_ref()
|
||||||
|
.map(|m| m.title().value().to_string())
|
||||||
|
.unwrap_or_else(|| "Unknown".to_string());
|
||||||
|
let release_year = movie.as_ref().map(|m| m.release_year().value()).unwrap_or(0);
|
||||||
|
let poster_url = movie
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|m| m.poster_path())
|
||||||
|
.map(|p| format!("{}/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(
|
async fn on_create(
|
||||||
&self,
|
&self,
|
||||||
_ap_id: &Url,
|
_ap_id: &Url,
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ impl ApUserRepository for DomainUserRepoAdapter {
|
|||||||
Ok(self.0.find_by_id(&user_id).await?.map(|u| ApUser {
|
Ok(self.0.find_by_id(&user_id).await?.map(|u| ApUser {
|
||||||
id: u.id().value(),
|
id: u.id().value(),
|
||||||
username: u.username().value().to_string(),
|
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 {
|
Ok(self.0.find_by_username(&uname).await?.map(|u| ApUser {
|
||||||
id: u.id().value(),
|
id: u.id().value(),
|
||||||
username: u.username().value().to_string(),
|
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,
|
movie_id: String,
|
||||||
poster_path: Option<String>,
|
poster_path: Option<String>,
|
||||||
},
|
},
|
||||||
|
UserUpdated {
|
||||||
|
user_id: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
impl EventPayload {
|
impl EventPayload {
|
||||||
@@ -41,6 +44,7 @@ impl EventPayload {
|
|||||||
EventPayload::ReviewUpdated { .. } => "ReviewUpdated",
|
EventPayload::ReviewUpdated { .. } => "ReviewUpdated",
|
||||||
EventPayload::MovieDiscovered { .. } => "MovieDiscovered",
|
EventPayload::MovieDiscovered { .. } => "MovieDiscovered",
|
||||||
EventPayload::MovieDeleted { .. } => "MovieDeleted",
|
EventPayload::MovieDeleted { .. } => "MovieDeleted",
|
||||||
|
EventPayload::UserUpdated { .. } => "UserUpdated",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -87,6 +91,9 @@ impl From<&DomainEvent> for EventPayload {
|
|||||||
movie_id: movie_id.value().to_string(),
|
movie_id: movie_id.value().to_string(),
|
||||||
poster_path: poster_path.as_ref().map(|p| p.value().to_string()),
|
poster_path: poster_path.as_ref().map(|p| p.value().to_string()),
|
||||||
},
|
},
|
||||||
|
DomainEvent::UserUpdated { user_id } => EventPayload::UserUpdated {
|
||||||
|
user_id: user_id.value().to_string(),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -127,6 +134,11 @@ impl TryFrom<EventPayload> for DomainEvent {
|
|||||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||||
Ok(DomainEvent::MovieDeleted { movie_id, poster_path })
|
Ok(DomainEvent::MovieDeleted { movie_id, poster_path })
|
||||||
}
|
}
|
||||||
|
EventPayload::UserUpdated { user_id } => {
|
||||||
|
Ok(DomainEvent::UserUpdated {
|
||||||
|
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "poster-storage"
|
name = "image-storage"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
@@ -6,8 +6,8 @@ pub struct StorageConfig(Arc<dyn ObjectStore>);
|
|||||||
|
|
||||||
impl StorageConfig {
|
impl StorageConfig {
|
||||||
pub fn from_env() -> anyhow::Result<Self> {
|
pub fn from_env() -> anyhow::Result<Self> {
|
||||||
let backend = std::env::var("POSTER_STORAGE_BACKEND")
|
let backend = std::env::var("IMAGE_STORAGE_BACKEND")
|
||||||
.context("POSTER_STORAGE_BACKEND required (valid values: s3, local)")?;
|
.context("IMAGE_STORAGE_BACKEND required (valid values: s3, local)")?;
|
||||||
|
|
||||||
let store: Arc<dyn ObjectStore> = match backend.as_str() {
|
let store: Arc<dyn ObjectStore> = match backend.as_str() {
|
||||||
"s3" => build_s3_store(
|
"s3" => build_s3_store(
|
||||||
@@ -19,11 +19,11 @@ impl StorageConfig {
|
|||||||
&std::env::var("MINIO_REGION").unwrap_or_else(|_| "minio".to_string()),
|
&std::env::var("MINIO_REGION").unwrap_or_else(|_| "minio".to_string()),
|
||||||
)?,
|
)?,
|
||||||
"local" => build_local_store(
|
"local" => build_local_store(
|
||||||
&std::env::var("POSTER_STORAGE_PATH")
|
&std::env::var("IMAGE_STORAGE_PATH")
|
||||||
.context("POSTER_STORAGE_PATH required when POSTER_STORAGE_BACKEND=local")?,
|
.context("IMAGE_STORAGE_PATH required when IMAGE_STORAGE_BACKEND=local")?,
|
||||||
)?,
|
)?,
|
||||||
other => {
|
other => {
|
||||||
anyhow::bail!("Unknown POSTER_STORAGE_BACKEND: {other:?}. Valid values: s3, local")
|
anyhow::bail!("Unknown IMAGE_STORAGE_BACKEND: {other:?}. Valid values: s3, local")
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -55,7 +55,7 @@ fn build_s3_store(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn build_local_store(path: &str) -> anyhow::Result<Arc<dyn ObjectStore>> {
|
fn build_local_store(path: &str) -> anyhow::Result<Arc<dyn ObjectStore>> {
|
||||||
std::fs::create_dir_all(path).context("Failed to create poster storage directory")?;
|
std::fs::create_dir_all(path).context("Failed to create image storage directory")?;
|
||||||
let store = LocalFileSystem::new_with_prefix(path)
|
let store = LocalFileSystem::new_with_prefix(path)
|
||||||
.context("Failed to initialise local file system store")?;
|
.context("Failed to initialise local file system store")?;
|
||||||
Ok(Arc::new(store))
|
Ok(Arc::new(store))
|
||||||
@@ -67,7 +67,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn local_store_creates_dir_and_succeeds() {
|
fn local_store_creates_dir_and_succeeds() {
|
||||||
let dir = std::env::temp_dir().join(format!("poster_test_{}", uuid::Uuid::new_v4()));
|
let dir = std::env::temp_dir().join(format!("image_test_{}", uuid::Uuid::new_v4()));
|
||||||
let result = build_local_store(dir.to_str().unwrap());
|
let result = build_local_store(dir.to_str().unwrap());
|
||||||
assert!(result.is_ok(), "expected Ok, got: {:?}", result.err());
|
assert!(result.is_ok(), "expected Ok, got: {:?}", result.err());
|
||||||
assert!(dir.exists(), "directory should have been created");
|
assert!(dir.exists(), "directory should have been created");
|
||||||
@@ -75,7 +75,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn local_store_succeeds_if_dir_already_exists() {
|
fn local_store_succeeds_if_dir_already_exists() {
|
||||||
let dir = std::env::temp_dir().join(format!("poster_test_{}", uuid::Uuid::new_v4()));
|
let dir = std::env::temp_dir().join(format!("image_test_{}", uuid::Uuid::new_v4()));
|
||||||
std::fs::create_dir_all(&dir).unwrap();
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
let result = build_local_store(dir.to_str().unwrap());
|
let result = build_local_store(dir.to_str().unwrap());
|
||||||
assert!(result.is_ok());
|
assert!(result.is_ok());
|
||||||
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::ReviewUpdated { .. } => "review.updated",
|
||||||
DomainEvent::MovieDiscovered { .. } => "movie.discovered",
|
DomainEvent::MovieDiscovered { .. } => "movie.discovered",
|
||||||
DomainEvent::MovieDeleted { .. } => "movie.deleted",
|
DomainEvent::MovieDeleted { .. } => "movie.deleted",
|
||||||
|
DomainEvent::UserUpdated { .. } => "user.updated",
|
||||||
};
|
};
|
||||||
format!("{prefix}.{suffix}")
|
format!("{prefix}.{suffix}")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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::{
|
use domain::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
events::DomainEvent,
|
events::DomainEvent,
|
||||||
ports::{EventHandler, MetadataClient, MovieRepository, PosterFetcherClient, PosterStorage},
|
ports::{EventHandler, ImageStorage, MetadataClient, MovieRepository, PosterFetcherClient},
|
||||||
value_objects::{ExternalMetadataId, MovieId},
|
value_objects::{ExternalMetadataId, MovieId, PosterPath},
|
||||||
};
|
};
|
||||||
|
|
||||||
pub struct PosterSyncHandler {
|
pub struct PosterSyncHandler {
|
||||||
movie_repository: Arc<dyn MovieRepository>,
|
movie_repository: Arc<dyn MovieRepository>,
|
||||||
metadata_client: Arc<dyn MetadataClient>,
|
metadata_client: Arc<dyn MetadataClient>,
|
||||||
poster_fetcher: Arc<dyn PosterFetcherClient>,
|
poster_fetcher: Arc<dyn PosterFetcherClient>,
|
||||||
poster_storage: Arc<dyn PosterStorage>,
|
image_storage: Arc<dyn ImageStorage>,
|
||||||
max_retries: u32,
|
max_retries: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,10 +21,10 @@ impl PosterSyncHandler {
|
|||||||
movie_repository: Arc<dyn MovieRepository>,
|
movie_repository: Arc<dyn MovieRepository>,
|
||||||
metadata_client: Arc<dyn MetadataClient>,
|
metadata_client: Arc<dyn MetadataClient>,
|
||||||
poster_fetcher: Arc<dyn PosterFetcherClient>,
|
poster_fetcher: Arc<dyn PosterFetcherClient>,
|
||||||
poster_storage: Arc<dyn PosterStorage>,
|
image_storage: Arc<dyn ImageStorage>,
|
||||||
max_retries: u32,
|
max_retries: u32,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self { movie_repository, metadata_client, poster_fetcher, poster_storage, max_retries }
|
Self { movie_repository, metadata_client, poster_fetcher, image_storage, max_retries }
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn sync(&self, movie_id: MovieId, external_metadata_id: ExternalMetadataId) -> Result<(), DomainError> {
|
async fn sync(&self, movie_id: MovieId, external_metadata_id: ExternalMetadataId) -> Result<(), DomainError> {
|
||||||
@@ -46,9 +46,10 @@ impl PosterSyncHandler {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let image_bytes = self.poster_fetcher.fetch_poster_bytes(&poster_url).await?;
|
let image_bytes = self.poster_fetcher.fetch_poster_bytes(&poster_url).await?;
|
||||||
let stored_path = self.poster_storage.store_poster(&movie_id, &image_bytes).await?;
|
let stored_path = self.image_storage.store(&movie_id.value().to_string(), &image_bytes).await?;
|
||||||
|
let poster_path = PosterPath::new(stored_path)?;
|
||||||
|
|
||||||
movie.update_poster(stored_path);
|
movie.update_poster(poster_path);
|
||||||
self.movie_repository.upsert_movie(&movie).await
|
self.movie_repository.upsert_movie(&movie).await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ impl FederationRepository for PostgresFederationRepository {
|
|||||||
let uid = local_user_id.to_string();
|
let uid = local_user_id.to_string();
|
||||||
let rows = sqlx::query(
|
let rows = sqlx::query(
|
||||||
"SELECT f.remote_actor_url, f.status,
|
"SELECT f.remote_actor_url, f.status,
|
||||||
a.handle, a.inbox_url, a.shared_inbox_url, a.display_name
|
a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.avatar_url
|
||||||
FROM ap_followers f
|
FROM ap_followers f
|
||||||
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
|
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
|
||||||
WHERE f.local_user_id = $1",
|
WHERE f.local_user_id = $1",
|
||||||
@@ -118,8 +118,9 @@ impl FederationRepository for PostgresFederationRepository {
|
|||||||
let inbox_url: String = row.try_get("inbox_url").unwrap_or_default();
|
let inbox_url: String = row.try_get("inbox_url").unwrap_or_default();
|
||||||
let shared_inbox_url: Option<String> = row.try_get("shared_inbox_url").ok().flatten();
|
let shared_inbox_url: Option<String> = row.try_get("shared_inbox_url").ok().flatten();
|
||||||
let display_name: Option<String> = row.try_get("display_name").ok().flatten();
|
let display_name: Option<String> = row.try_get("display_name").ok().flatten();
|
||||||
|
let avatar_url: Option<String> = row.try_get("avatar_url").ok().flatten();
|
||||||
Follower {
|
Follower {
|
||||||
actor: RemoteActor { url, handle, inbox_url, shared_inbox_url, display_name },
|
actor: RemoteActor { url, handle, inbox_url, shared_inbox_url, display_name, avatar_url },
|
||||||
status: str_to_status(&status_str),
|
status: str_to_status(&status_str),
|
||||||
}
|
}
|
||||||
}).collect())
|
}).collect())
|
||||||
@@ -200,7 +201,7 @@ impl FederationRepository for PostgresFederationRepository {
|
|||||||
async fn get_following(&self, local_user_id: uuid::Uuid) -> Result<Vec<RemoteActor>> {
|
async fn get_following(&self, local_user_id: uuid::Uuid) -> Result<Vec<RemoteActor>> {
|
||||||
let uid = local_user_id.to_string();
|
let uid = local_user_id.to_string();
|
||||||
let rows = sqlx::query(
|
let rows = sqlx::query(
|
||||||
"SELECT a.url, a.handle, a.inbox_url, a.shared_inbox_url, a.display_name
|
"SELECT a.url, a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.avatar_url
|
||||||
FROM ap_following f
|
FROM ap_following f
|
||||||
INNER JOIN ap_remote_actors a ON a.url = f.remote_actor_url
|
INNER JOIN ap_remote_actors a ON a.url = f.remote_actor_url
|
||||||
WHERE f.local_user_id = $1 AND f.status = 'accepted'",
|
WHERE f.local_user_id = $1 AND f.status = 'accepted'",
|
||||||
@@ -214,6 +215,7 @@ impl FederationRepository for PostgresFederationRepository {
|
|||||||
inbox_url: row.get("inbox_url"),
|
inbox_url: row.get("inbox_url"),
|
||||||
shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(),
|
shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(),
|
||||||
display_name: row.try_get("display_name").ok().flatten(),
|
display_name: row.try_get("display_name").ok().flatten(),
|
||||||
|
avatar_url: row.try_get("avatar_url").ok().flatten(),
|
||||||
}).collect())
|
}).collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -232,13 +234,14 @@ impl FederationRepository for PostgresFederationRepository {
|
|||||||
let now = Utc::now().naive_utc();
|
let now = Utc::now().naive_utc();
|
||||||
let fetched_at = datetime_to_str(&now);
|
let fetched_at = datetime_to_str(&now);
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO ap_remote_actors (url, handle, inbox_url, shared_inbox_url, display_name, fetched_at)
|
"INSERT INTO ap_remote_actors (url, handle, inbox_url, shared_inbox_url, display_name, avatar_url, fetched_at)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6::timestamptz)
|
VALUES ($1, $2, $3, $4, $5, $6, $7::timestamptz)
|
||||||
ON CONFLICT(url) DO UPDATE SET
|
ON CONFLICT(url) DO UPDATE SET
|
||||||
handle = EXCLUDED.handle,
|
handle = EXCLUDED.handle,
|
||||||
inbox_url = EXCLUDED.inbox_url,
|
inbox_url = EXCLUDED.inbox_url,
|
||||||
shared_inbox_url = EXCLUDED.shared_inbox_url,
|
shared_inbox_url = EXCLUDED.shared_inbox_url,
|
||||||
display_name = EXCLUDED.display_name,
|
display_name = EXCLUDED.display_name,
|
||||||
|
avatar_url = EXCLUDED.avatar_url,
|
||||||
fetched_at = EXCLUDED.fetched_at",
|
fetched_at = EXCLUDED.fetched_at",
|
||||||
)
|
)
|
||||||
.bind(&actor.url)
|
.bind(&actor.url)
|
||||||
@@ -246,6 +249,7 @@ impl FederationRepository for PostgresFederationRepository {
|
|||||||
.bind(&actor.inbox_url)
|
.bind(&actor.inbox_url)
|
||||||
.bind(&actor.shared_inbox_url)
|
.bind(&actor.shared_inbox_url)
|
||||||
.bind(&actor.display_name)
|
.bind(&actor.display_name)
|
||||||
|
.bind(&actor.avatar_url)
|
||||||
.bind(&fetched_at)
|
.bind(&fetched_at)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -254,7 +258,7 @@ impl FederationRepository for PostgresFederationRepository {
|
|||||||
|
|
||||||
async fn get_remote_actor(&self, actor_url: &str) -> Result<Option<RemoteActor>> {
|
async fn get_remote_actor(&self, actor_url: &str) -> Result<Option<RemoteActor>> {
|
||||||
let row = sqlx::query(
|
let row = sqlx::query(
|
||||||
"SELECT url, handle, inbox_url, shared_inbox_url, display_name
|
"SELECT url, handle, inbox_url, shared_inbox_url, display_name, avatar_url
|
||||||
FROM ap_remote_actors WHERE url = $1",
|
FROM ap_remote_actors WHERE url = $1",
|
||||||
)
|
)
|
||||||
.bind(actor_url)
|
.bind(actor_url)
|
||||||
@@ -266,6 +270,7 @@ impl FederationRepository for PostgresFederationRepository {
|
|||||||
inbox_url: row.get("inbox_url"),
|
inbox_url: row.get("inbox_url"),
|
||||||
shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(),
|
shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(),
|
||||||
display_name: row.try_get("display_name").ok().flatten(),
|
display_name: row.try_get("display_name").ok().flatten(),
|
||||||
|
avatar_url: row.try_get("avatar_url").ok().flatten(),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -306,7 +311,7 @@ impl FederationRepository for PostgresFederationRepository {
|
|||||||
async fn get_pending_followers(&self, local_user_id: uuid::Uuid) -> Result<Vec<RemoteActor>> {
|
async fn get_pending_followers(&self, local_user_id: uuid::Uuid) -> Result<Vec<RemoteActor>> {
|
||||||
let uid = local_user_id.to_string();
|
let uid = local_user_id.to_string();
|
||||||
let rows = sqlx::query(
|
let rows = sqlx::query(
|
||||||
"SELECT f.remote_actor_url, a.handle, a.inbox_url, a.shared_inbox_url, a.display_name
|
"SELECT f.remote_actor_url, a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.avatar_url
|
||||||
FROM ap_followers f
|
FROM ap_followers f
|
||||||
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
|
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
|
||||||
WHERE f.local_user_id = $1 AND f.status = 'pending'",
|
WHERE f.local_user_id = $1 AND f.status = 'pending'",
|
||||||
@@ -320,6 +325,7 @@ impl FederationRepository for PostgresFederationRepository {
|
|||||||
inbox_url: row.try_get("inbox_url").unwrap_or_default(),
|
inbox_url: row.try_get("inbox_url").unwrap_or_default(),
|
||||||
shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(),
|
shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(),
|
||||||
display_name: row.try_get("display_name").ok().flatten(),
|
display_name: row.try_get("display_name").ok().flatten(),
|
||||||
|
avatar_url: row.try_get("avatar_url").ok().flatten(),
|
||||||
}).collect())
|
}).collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -347,6 +353,34 @@ impl FederationRepository for PostgresFederationRepository {
|
|||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn add_announce(
|
||||||
|
&self,
|
||||||
|
activity_id: &str,
|
||||||
|
object_url: &str,
|
||||||
|
actor_url: &str,
|
||||||
|
announced_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let ts = announced_at.format("%Y-%m-%d %H:%M:%S").to_string();
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO ap_announces (id, object_url, actor_url, announced_at) VALUES ($1, $2, $3, $4) ON CONFLICT (id) DO NOTHING",
|
||||||
|
)
|
||||||
|
.bind(activity_id)
|
||||||
|
.bind(object_url)
|
||||||
|
.bind(actor_url)
|
||||||
|
.bind(&ts)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn count_announces(&self, object_url: &str) -> Result<usize> {
|
||||||
|
let row = sqlx::query("SELECT COUNT(*) as cnt FROM ap_announces WHERE object_url = $1")
|
||||||
|
.bind(object_url)
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(row.get::<i64, _>("cnt") as usize)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[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,
|
username_str: String,
|
||||||
hash_str: String,
|
hash_str: String,
|
||||||
role: UserRole,
|
role: UserRole,
|
||||||
|
bio: Option<String>,
|
||||||
|
avatar_path: Option<String>,
|
||||||
) -> Result<User, DomainError> {
|
) -> Result<User, DomainError> {
|
||||||
let id = uuid::Uuid::parse_str(&id_str)
|
let id = uuid::Uuid::parse_str(&id_str)
|
||||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||||
@@ -53,6 +55,8 @@ impl PostgresUserRepository {
|
|||||||
username,
|
username,
|
||||||
hash,
|
hash,
|
||||||
role,
|
role,
|
||||||
|
bio,
|
||||||
|
avatar_path,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -68,9 +72,11 @@ impl UserRepository for PostgresUserRepository {
|
|||||||
username: String,
|
username: String,
|
||||||
password_hash: String,
|
password_hash: String,
|
||||||
role: String,
|
role: String,
|
||||||
|
bio: Option<String>,
|
||||||
|
avatar_path: Option<String>,
|
||||||
}
|
}
|
||||||
let row = sqlx::query_as::<_, Row>(
|
let row = sqlx::query_as::<_, Row>(
|
||||||
"SELECT id, email, username, password_hash, role FROM users WHERE email = $1",
|
"SELECT id, email, username, password_hash, role, bio, avatar_path FROM users WHERE email = $1",
|
||||||
)
|
)
|
||||||
.bind(email_str)
|
.bind(email_str)
|
||||||
.fetch_optional(&self.pool)
|
.fetch_optional(&self.pool)
|
||||||
@@ -83,6 +89,8 @@ impl UserRepository for PostgresUserRepository {
|
|||||||
r.username,
|
r.username,
|
||||||
r.password_hash,
|
r.password_hash,
|
||||||
Self::parse_role(&r.role),
|
Self::parse_role(&r.role),
|
||||||
|
r.bio,
|
||||||
|
r.avatar_path,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.transpose()
|
.transpose()
|
||||||
@@ -97,9 +105,11 @@ impl UserRepository for PostgresUserRepository {
|
|||||||
username: String,
|
username: String,
|
||||||
password_hash: String,
|
password_hash: String,
|
||||||
role: String,
|
role: String,
|
||||||
|
bio: Option<String>,
|
||||||
|
avatar_path: Option<String>,
|
||||||
}
|
}
|
||||||
let row = sqlx::query_as::<_, Row>(
|
let row = sqlx::query_as::<_, Row>(
|
||||||
"SELECT id, email, username, password_hash, role FROM users WHERE username = $1",
|
"SELECT id, email, username, password_hash, role, bio, avatar_path FROM users WHERE username = $1",
|
||||||
)
|
)
|
||||||
.bind(username_str)
|
.bind(username_str)
|
||||||
.fetch_optional(&self.pool)
|
.fetch_optional(&self.pool)
|
||||||
@@ -112,6 +122,8 @@ impl UserRepository for PostgresUserRepository {
|
|||||||
r.username,
|
r.username,
|
||||||
r.password_hash,
|
r.password_hash,
|
||||||
Self::parse_role(&r.role),
|
Self::parse_role(&r.role),
|
||||||
|
r.bio,
|
||||||
|
r.avatar_path,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.transpose()
|
.transpose()
|
||||||
@@ -164,9 +176,11 @@ impl UserRepository for PostgresUserRepository {
|
|||||||
username: String,
|
username: String,
|
||||||
password_hash: String,
|
password_hash: String,
|
||||||
role: String,
|
role: String,
|
||||||
|
bio: Option<String>,
|
||||||
|
avatar_path: Option<String>,
|
||||||
}
|
}
|
||||||
let row = sqlx::query_as::<_, Row>(
|
let row = sqlx::query_as::<_, Row>(
|
||||||
"SELECT id, email, username, password_hash, role FROM users WHERE id = $1",
|
"SELECT id, email, username, password_hash, role, bio, avatar_path FROM users WHERE id = $1",
|
||||||
)
|
)
|
||||||
.bind(&id_str)
|
.bind(&id_str)
|
||||||
.fetch_optional(&self.pool)
|
.fetch_optional(&self.pool)
|
||||||
@@ -179,11 +193,30 @@ impl UserRepository for PostgresUserRepository {
|
|||||||
r.username,
|
r.username,
|
||||||
r.password_hash,
|
r.password_hash,
|
||||||
Self::parse_role(&r.role),
|
Self::parse_role(&r.role),
|
||||||
|
r.bio,
|
||||||
|
r.avatar_path,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.transpose()
|
.transpose()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn update_profile(
|
||||||
|
&self,
|
||||||
|
user_id: &UserId,
|
||||||
|
bio: Option<String>,
|
||||||
|
avatar_path: Option<String>,
|
||||||
|
) -> Result<(), DomainError> {
|
||||||
|
let id_str = user_id.value().to_string();
|
||||||
|
sqlx::query("UPDATE users SET bio = $1, avatar_path = $2 WHERE id = $3")
|
||||||
|
.bind(&bio)
|
||||||
|
.bind(&avatar_path)
|
||||||
|
.bind(&id_str)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
async fn list_with_stats(&self) -> Result<Vec<domain::models::UserSummary>, DomainError> {
|
async fn list_with_stats(&self) -> Result<Vec<domain::models::UserSummary>, DomainError> {
|
||||||
sqlx::query_as::<_, UserSummaryRow>(
|
sqlx::query_as::<_, UserSummaryRow>(
|
||||||
r#"SELECT u.id, u.email,
|
r#"SELECT u.id, u.email,
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ impl FederationRepository for SqliteFederationRepository {
|
|||||||
|
|
||||||
let rows = sqlx::query(
|
let rows = sqlx::query(
|
||||||
"SELECT f.remote_actor_url, f.status,
|
"SELECT f.remote_actor_url, f.status,
|
||||||
a.handle, a.inbox_url, a.shared_inbox_url, a.display_name
|
a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.avatar_url
|
||||||
FROM ap_followers f
|
FROM ap_followers f
|
||||||
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
|
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
|
||||||
WHERE f.local_user_id = ?",
|
WHERE f.local_user_id = ?",
|
||||||
@@ -125,6 +125,7 @@ impl FederationRepository for SqliteFederationRepository {
|
|||||||
let shared_inbox_url: Option<String> =
|
let shared_inbox_url: Option<String> =
|
||||||
row.try_get("shared_inbox_url").ok().flatten();
|
row.try_get("shared_inbox_url").ok().flatten();
|
||||||
let display_name: Option<String> = row.try_get("display_name").ok().flatten();
|
let display_name: Option<String> = row.try_get("display_name").ok().flatten();
|
||||||
|
let avatar_url: Option<String> = row.try_get("avatar_url").ok().flatten();
|
||||||
|
|
||||||
Follower {
|
Follower {
|
||||||
actor: RemoteActor {
|
actor: RemoteActor {
|
||||||
@@ -133,6 +134,7 @@ impl FederationRepository for SqliteFederationRepository {
|
|||||||
inbox_url,
|
inbox_url,
|
||||||
shared_inbox_url,
|
shared_inbox_url,
|
||||||
display_name,
|
display_name,
|
||||||
|
avatar_url,
|
||||||
},
|
},
|
||||||
status: str_to_status(&status_str),
|
status: str_to_status(&status_str),
|
||||||
}
|
}
|
||||||
@@ -223,7 +225,7 @@ impl FederationRepository for SqliteFederationRepository {
|
|||||||
let uid = local_user_id.to_string();
|
let uid = local_user_id.to_string();
|
||||||
|
|
||||||
let rows = sqlx::query(
|
let rows = sqlx::query(
|
||||||
"SELECT a.url, a.handle, a.inbox_url, a.shared_inbox_url, a.display_name
|
"SELECT a.url, a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.avatar_url
|
||||||
FROM ap_following f
|
FROM ap_following f
|
||||||
INNER JOIN ap_remote_actors a ON a.url = f.remote_actor_url
|
INNER JOIN ap_remote_actors a ON a.url = f.remote_actor_url
|
||||||
WHERE f.local_user_id = ? AND f.status = 'accepted'",
|
WHERE f.local_user_id = ? AND f.status = 'accepted'",
|
||||||
@@ -240,6 +242,7 @@ impl FederationRepository for SqliteFederationRepository {
|
|||||||
inbox_url: row.get("inbox_url"),
|
inbox_url: row.get("inbox_url"),
|
||||||
shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(),
|
shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(),
|
||||||
display_name: row.try_get("display_name").ok().flatten(),
|
display_name: row.try_get("display_name").ok().flatten(),
|
||||||
|
avatar_url: row.try_get("avatar_url").ok().flatten(),
|
||||||
})
|
})
|
||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
@@ -260,13 +263,14 @@ impl FederationRepository for SqliteFederationRepository {
|
|||||||
let fetched_at = datetime_to_str(&now);
|
let fetched_at = datetime_to_str(&now);
|
||||||
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO ap_remote_actors (url, handle, inbox_url, shared_inbox_url, display_name, fetched_at)
|
"INSERT INTO ap_remote_actors (url, handle, inbox_url, shared_inbox_url, display_name, avatar_url, fetched_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(url) DO UPDATE SET
|
ON CONFLICT(url) DO UPDATE SET
|
||||||
handle = excluded.handle,
|
handle = excluded.handle,
|
||||||
inbox_url = excluded.inbox_url,
|
inbox_url = excluded.inbox_url,
|
||||||
shared_inbox_url = excluded.shared_inbox_url,
|
shared_inbox_url = excluded.shared_inbox_url,
|
||||||
display_name = excluded.display_name,
|
display_name = excluded.display_name,
|
||||||
|
avatar_url = excluded.avatar_url,
|
||||||
fetched_at = excluded.fetched_at",
|
fetched_at = excluded.fetched_at",
|
||||||
)
|
)
|
||||||
.bind(&actor.url)
|
.bind(&actor.url)
|
||||||
@@ -274,6 +278,7 @@ impl FederationRepository for SqliteFederationRepository {
|
|||||||
.bind(&actor.inbox_url)
|
.bind(&actor.inbox_url)
|
||||||
.bind(&actor.shared_inbox_url)
|
.bind(&actor.shared_inbox_url)
|
||||||
.bind(&actor.display_name)
|
.bind(&actor.display_name)
|
||||||
|
.bind(&actor.avatar_url)
|
||||||
.bind(&fetched_at)
|
.bind(&fetched_at)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -283,7 +288,7 @@ impl FederationRepository for SqliteFederationRepository {
|
|||||||
|
|
||||||
async fn get_remote_actor(&self, actor_url: &str) -> Result<Option<RemoteActor>> {
|
async fn get_remote_actor(&self, actor_url: &str) -> Result<Option<RemoteActor>> {
|
||||||
let row = sqlx::query(
|
let row = sqlx::query(
|
||||||
"SELECT url, handle, inbox_url, shared_inbox_url, display_name
|
"SELECT url, handle, inbox_url, shared_inbox_url, display_name, avatar_url
|
||||||
FROM ap_remote_actors WHERE url = ?",
|
FROM ap_remote_actors WHERE url = ?",
|
||||||
)
|
)
|
||||||
.bind(actor_url)
|
.bind(actor_url)
|
||||||
@@ -296,6 +301,7 @@ impl FederationRepository for SqliteFederationRepository {
|
|||||||
inbox_url: row.get("inbox_url"),
|
inbox_url: row.get("inbox_url"),
|
||||||
shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(),
|
shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(),
|
||||||
display_name: row.try_get("display_name").ok().flatten(),
|
display_name: row.try_get("display_name").ok().flatten(),
|
||||||
|
avatar_url: row.try_get("avatar_url").ok().flatten(),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -344,7 +350,7 @@ impl FederationRepository for SqliteFederationRepository {
|
|||||||
|
|
||||||
let rows = sqlx::query(
|
let rows = sqlx::query(
|
||||||
"SELECT f.remote_actor_url,
|
"SELECT f.remote_actor_url,
|
||||||
a.handle, a.inbox_url, a.shared_inbox_url, a.display_name
|
a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.avatar_url
|
||||||
FROM ap_followers f
|
FROM ap_followers f
|
||||||
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
|
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
|
||||||
WHERE f.local_user_id = ? AND f.status = 'pending'",
|
WHERE f.local_user_id = ? AND f.status = 'pending'",
|
||||||
@@ -361,6 +367,7 @@ impl FederationRepository for SqliteFederationRepository {
|
|||||||
inbox_url: row.try_get("inbox_url").unwrap_or_default(),
|
inbox_url: row.try_get("inbox_url").unwrap_or_default(),
|
||||||
shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(),
|
shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(),
|
||||||
display_name: row.try_get("display_name").ok().flatten(),
|
display_name: row.try_get("display_name").ok().flatten(),
|
||||||
|
avatar_url: row.try_get("avatar_url").ok().flatten(),
|
||||||
})
|
})
|
||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
@@ -392,6 +399,35 @@ impl FederationRepository for SqliteFederationRepository {
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn add_announce(
|
||||||
|
&self,
|
||||||
|
activity_id: &str,
|
||||||
|
object_url: &str,
|
||||||
|
actor_url: &str,
|
||||||
|
announced_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let ts = announced_at.format("%Y-%m-%d %H:%M:%S").to_string();
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT OR IGNORE INTO ap_announces (id, object_url, actor_url, announced_at)
|
||||||
|
VALUES (?1, ?2, ?3, ?4)",
|
||||||
|
)
|
||||||
|
.bind(activity_id)
|
||||||
|
.bind(object_url)
|
||||||
|
.bind(actor_url)
|
||||||
|
.bind(&ts)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn count_announces(&self, object_url: &str) -> Result<usize> {
|
||||||
|
let row = sqlx::query("SELECT COUNT(*) as cnt FROM ap_announces WHERE object_url = ?1")
|
||||||
|
.bind(object_url)
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(row.get::<i64, _>("cnt") as usize)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Content-specific repository (movies-diary) ---
|
// --- Content-specific repository (movies-diary) ---
|
||||||
@@ -553,9 +589,34 @@ pub fn wire(pool: sqlx::SqlitePool) -> (
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use chrono::Utc;
|
||||||
use domain::ports::SocialQueryPort;
|
use domain::ports::SocialQueryPort;
|
||||||
use sqlx::SqlitePool;
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
async fn test_pool() -> SqlitePool {
|
||||||
|
let pool = SqlitePool::connect("sqlite::memory:").await.unwrap();
|
||||||
|
sqlx::query("CREATE TABLE ap_announces (id TEXT PRIMARY KEY, object_url TEXT NOT NULL, actor_url TEXT NOT NULL, announced_at TEXT NOT NULL)")
|
||||||
|
.execute(&pool).await.unwrap();
|
||||||
|
pool
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn add_announce_stores_and_counts() {
|
||||||
|
let pool = test_pool().await;
|
||||||
|
let repo = SqliteFederationRepository::new(pool);
|
||||||
|
repo.add_announce("https://remote/ann/1", "https://local/r/1", "https://remote/u/1", Utc::now()).await.unwrap();
|
||||||
|
assert_eq!(repo.count_announces("https://local/r/1").await.unwrap(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn duplicate_announce_is_ignored() {
|
||||||
|
let pool = test_pool().await;
|
||||||
|
let repo = SqliteFederationRepository::new(pool);
|
||||||
|
repo.add_announce("https://remote/ann/1", "https://local/r/1", "https://remote/u/1", Utc::now()).await.unwrap();
|
||||||
|
repo.add_announce("https://remote/ann/1", "https://local/r/1", "https://remote/u/1", Utc::now()).await.unwrap();
|
||||||
|
assert_eq!(repo.count_announces("https://local/r/1").await.unwrap(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
async fn setup_db(pool: &SqlitePool) {
|
async fn setup_db(pool: &SqlitePool) {
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"CREATE TABLE IF NOT EXISTS ap_remote_actors (
|
"CREATE TABLE IF NOT EXISTS ap_remote_actors (
|
||||||
@@ -564,6 +625,7 @@ mod tests {
|
|||||||
inbox_url TEXT NOT NULL,
|
inbox_url TEXT NOT NULL,
|
||||||
shared_inbox_url TEXT,
|
shared_inbox_url TEXT,
|
||||||
display_name TEXT,
|
display_name TEXT,
|
||||||
|
avatar_url TEXT,
|
||||||
fetched_at TEXT NOT NULL
|
fetched_at TEXT NOT NULL
|
||||||
)",
|
)",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"db_name": "SQLite",
|
"db_name": "SQLite",
|
||||||
"query": "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,\n r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at\n FROM reviews r\n INNER JOIN movies m ON m.id = r.movie_id\n WHERE r.movie_id = ?\n ORDER BY r.watched_at ASC\n LIMIT ? OFFSET ?",
|
"query": "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,\n r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url\n FROM reviews r\n INNER JOIN movies m ON m.id = r.movie_id\n WHERE r.movie_id = ?\n ORDER BY r.watched_at DESC\n LIMIT ? OFFSET ?",
|
||||||
"describe": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
@@ -67,6 +67,11 @@
|
|||||||
"name": "created_at",
|
"name": "created_at",
|
||||||
"ordinal": 12,
|
"ordinal": 12,
|
||||||
"type_info": "Text"
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "remote_actor_url",
|
||||||
|
"ordinal": 13,
|
||||||
|
"type_info": "Text"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
@@ -85,8 +90,9 @@
|
|||||||
false,
|
false,
|
||||||
true,
|
true,
|
||||||
false,
|
false,
|
||||||
false
|
false,
|
||||||
|
true
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "01a08873b7fa815ad98a56a0902b60414cfcdc2c7a8570351320c4bc425347c6"
|
"hash": "05d958c1fa38095ae2b5b81ede48fc85702d8c39c6301839de7b4d27f4a4d41b"
|
||||||
}
|
}
|
||||||
@@ -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",
|
"db_name": "SQLite",
|
||||||
"query": "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,\n r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at\n FROM reviews r\n INNER JOIN movies m ON m.id = r.movie_id\n ORDER BY r.watched_at DESC\n LIMIT ? OFFSET ?",
|
"query": "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,\n r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url\n FROM reviews r\n INNER JOIN movies m ON m.id = r.movie_id\n ORDER BY r.watched_at ASC\n LIMIT ? OFFSET ?",
|
||||||
"describe": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
@@ -67,6 +67,11 @@
|
|||||||
"name": "created_at",
|
"name": "created_at",
|
||||||
"ordinal": 12,
|
"ordinal": 12,
|
||||||
"type_info": "Text"
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "remote_actor_url",
|
||||||
|
"ordinal": 13,
|
||||||
|
"type_info": "Text"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
@@ -85,8 +90,9 @@
|
|||||||
false,
|
false,
|
||||||
true,
|
true,
|
||||||
false,
|
false,
|
||||||
false
|
false,
|
||||||
|
true
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "026e2afeb573707cb360fcdab8f6137aabfaf603b5ed57b98ac2888b4a0389ff"
|
"hash": "25fd01355c929a83daf2c802b8ae3adaa4ce73fc037e2bf2a87d60187aeb7361"
|
||||||
}
|
}
|
||||||
@@ -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",
|
"db_name": "SQLite",
|
||||||
"query": "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,\n r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at\n FROM reviews r\n INNER JOIN movies m ON m.id = r.movie_id\n WHERE r.movie_id = ?\n ORDER BY r.watched_at DESC\n LIMIT ? OFFSET ?",
|
"query": "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,\n r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url\n FROM reviews r\n INNER JOIN movies m ON m.id = r.movie_id\n WHERE r.movie_id = ?\n ORDER BY r.watched_at ASC\n LIMIT ? OFFSET ?",
|
||||||
"describe": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
@@ -67,6 +67,11 @@
|
|||||||
"name": "created_at",
|
"name": "created_at",
|
||||||
"ordinal": 12,
|
"ordinal": 12,
|
||||||
"type_info": "Text"
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "remote_actor_url",
|
||||||
|
"ordinal": 13,
|
||||||
|
"type_info": "Text"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
@@ -85,8 +90,9 @@
|
|||||||
false,
|
false,
|
||||||
true,
|
true,
|
||||||
false,
|
false,
|
||||||
false
|
false,
|
||||||
|
true
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "47f7cf95ce3450635b643ab710cadba96f40319140834d510bc5207b2552e055"
|
"hash": "8a70f21c39d203867c06dc0bf74a54745b3331b84ce9a2178f7812f1ed7262cc"
|
||||||
}
|
}
|
||||||
@@ -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",
|
"db_name": "SQLite",
|
||||||
"query": "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,\n r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at\n FROM reviews r\n INNER JOIN movies m ON m.id = r.movie_id\n ORDER BY r.watched_at ASC\n LIMIT ? OFFSET ?",
|
"query": "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,\n r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url\n FROM reviews r\n INNER JOIN movies m ON m.id = r.movie_id\n ORDER BY r.watched_at DESC\n LIMIT ? OFFSET ?",
|
||||||
"describe": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
@@ -67,6 +67,11 @@
|
|||||||
"name": "created_at",
|
"name": "created_at",
|
||||||
"ordinal": 12,
|
"ordinal": 12,
|
||||||
"type_info": "Text"
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "remote_actor_url",
|
||||||
|
"ordinal": 13,
|
||||||
|
"type_info": "Text"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
@@ -85,8 +90,9 @@
|
|||||||
false,
|
false,
|
||||||
true,
|
true,
|
||||||
false,
|
false,
|
||||||
false
|
false,
|
||||||
|
true
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "affe1eb261283c09d4b1ce6e684681755f079a044ffec8ff2bd79cfd8efe16b8"
|
"hash": "a7c424c26663e4e51b1c563fa977f28e1d55234a242a7ddba50db13cf73b488d"
|
||||||
}
|
}
|
||||||
@@ -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",
|
"db_name": "SQLite",
|
||||||
"query": "SELECT id, movie_id, user_id, rating, comment, watched_at, created_at\n FROM reviews WHERE movie_id = ? ORDER BY watched_at ASC",
|
"query": "SELECT id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url\n FROM reviews WHERE id = ?",
|
||||||
"describe": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
@@ -37,6 +37,11 @@
|
|||||||
"name": "created_at",
|
"name": "created_at",
|
||||||
"ordinal": 6,
|
"ordinal": 6,
|
||||||
"type_info": "Text"
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "remote_actor_url",
|
||||||
|
"ordinal": 7,
|
||||||
|
"type_info": "Text"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
@@ -49,8 +54,9 @@
|
|||||||
false,
|
false,
|
||||||
true,
|
true,
|
||||||
false,
|
false,
|
||||||
false
|
false,
|
||||||
|
true
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "af883f8b78f185077e2d3dcfaa0a6e62fbdfbf00c97c9b33b699dc631476181d"
|
"hash": "ae983138ad90fda3794b784fbf62c31fddcf182850782e9bfbc4ff3ee8b7d4bb"
|
||||||
}
|
}
|
||||||
@@ -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(
|
let rows = sqlx::query(
|
||||||
"SELECT f.remote_actor_url, f.status,
|
"SELECT f.remote_actor_url, f.status,
|
||||||
a.handle, a.inbox_url, a.shared_inbox_url, a.display_name
|
a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.avatar_url
|
||||||
FROM ap_followers f
|
FROM ap_followers f
|
||||||
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
|
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
|
||||||
WHERE f.local_user_id = ?",
|
WHERE f.local_user_id = ?",
|
||||||
@@ -116,9 +116,10 @@ impl FederationRepository for SqliteFederationRepository {
|
|||||||
let inbox_url: String = row.try_get("inbox_url").unwrap_or_default();
|
let inbox_url: String = row.try_get("inbox_url").unwrap_or_default();
|
||||||
let shared_inbox_url: Option<String> = row.try_get("shared_inbox_url").ok().flatten();
|
let shared_inbox_url: Option<String> = row.try_get("shared_inbox_url").ok().flatten();
|
||||||
let display_name: Option<String> = row.try_get("display_name").ok().flatten();
|
let display_name: Option<String> = row.try_get("display_name").ok().flatten();
|
||||||
|
let avatar_url: Option<String> = row.try_get("avatar_url").ok().flatten();
|
||||||
|
|
||||||
Follower {
|
Follower {
|
||||||
actor: RemoteActor { url, handle, inbox_url, shared_inbox_url, display_name },
|
actor: RemoteActor { url, handle, inbox_url, shared_inbox_url, display_name, avatar_url },
|
||||||
status: str_to_status(&status_str),
|
status: str_to_status(&status_str),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -199,7 +200,7 @@ impl FederationRepository for SqliteFederationRepository {
|
|||||||
let uid = local_user_id.to_string();
|
let uid = local_user_id.to_string();
|
||||||
|
|
||||||
let rows = sqlx::query(
|
let rows = sqlx::query(
|
||||||
"SELECT a.url, a.handle, a.inbox_url, a.shared_inbox_url, a.display_name
|
"SELECT a.url, a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.avatar_url
|
||||||
FROM ap_following f
|
FROM ap_following f
|
||||||
INNER JOIN ap_remote_actors a ON a.url = f.remote_actor_url
|
INNER JOIN ap_remote_actors a ON a.url = f.remote_actor_url
|
||||||
WHERE f.local_user_id = ? AND f.status = 'accepted'",
|
WHERE f.local_user_id = ? AND f.status = 'accepted'",
|
||||||
@@ -214,6 +215,7 @@ impl FederationRepository for SqliteFederationRepository {
|
|||||||
inbox_url: row.get("inbox_url"),
|
inbox_url: row.get("inbox_url"),
|
||||||
shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(),
|
shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(),
|
||||||
display_name: row.try_get("display_name").ok().flatten(),
|
display_name: row.try_get("display_name").ok().flatten(),
|
||||||
|
avatar_url: row.try_get("avatar_url").ok().flatten(),
|
||||||
}).collect())
|
}).collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,13 +235,14 @@ impl FederationRepository for SqliteFederationRepository {
|
|||||||
let fetched_at = datetime_to_str(&now);
|
let fetched_at = datetime_to_str(&now);
|
||||||
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO ap_remote_actors (url, handle, inbox_url, shared_inbox_url, display_name, fetched_at)
|
"INSERT INTO ap_remote_actors (url, handle, inbox_url, shared_inbox_url, display_name, avatar_url, fetched_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(url) DO UPDATE SET
|
ON CONFLICT(url) DO UPDATE SET
|
||||||
handle = excluded.handle,
|
handle = excluded.handle,
|
||||||
inbox_url = excluded.inbox_url,
|
inbox_url = excluded.inbox_url,
|
||||||
shared_inbox_url = excluded.shared_inbox_url,
|
shared_inbox_url = excluded.shared_inbox_url,
|
||||||
display_name = excluded.display_name,
|
display_name = excluded.display_name,
|
||||||
|
avatar_url = excluded.avatar_url,
|
||||||
fetched_at = excluded.fetched_at",
|
fetched_at = excluded.fetched_at",
|
||||||
)
|
)
|
||||||
.bind(&actor.url)
|
.bind(&actor.url)
|
||||||
@@ -247,6 +250,7 @@ impl FederationRepository for SqliteFederationRepository {
|
|||||||
.bind(&actor.inbox_url)
|
.bind(&actor.inbox_url)
|
||||||
.bind(&actor.shared_inbox_url)
|
.bind(&actor.shared_inbox_url)
|
||||||
.bind(&actor.display_name)
|
.bind(&actor.display_name)
|
||||||
|
.bind(&actor.avatar_url)
|
||||||
.bind(&fetched_at)
|
.bind(&fetched_at)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -256,7 +260,7 @@ impl FederationRepository for SqliteFederationRepository {
|
|||||||
|
|
||||||
async fn get_remote_actor(&self, actor_url: &str) -> Result<Option<RemoteActor>> {
|
async fn get_remote_actor(&self, actor_url: &str) -> Result<Option<RemoteActor>> {
|
||||||
let row = sqlx::query(
|
let row = sqlx::query(
|
||||||
"SELECT url, handle, inbox_url, shared_inbox_url, display_name
|
"SELECT url, handle, inbox_url, shared_inbox_url, display_name, avatar_url
|
||||||
FROM ap_remote_actors WHERE url = ?",
|
FROM ap_remote_actors WHERE url = ?",
|
||||||
)
|
)
|
||||||
.bind(actor_url)
|
.bind(actor_url)
|
||||||
@@ -269,6 +273,7 @@ impl FederationRepository for SqliteFederationRepository {
|
|||||||
inbox_url: row.get("inbox_url"),
|
inbox_url: row.get("inbox_url"),
|
||||||
shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(),
|
shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(),
|
||||||
display_name: row.try_get("display_name").ok().flatten(),
|
display_name: row.try_get("display_name").ok().flatten(),
|
||||||
|
avatar_url: row.try_get("avatar_url").ok().flatten(),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -308,7 +313,7 @@ impl FederationRepository for SqliteFederationRepository {
|
|||||||
|
|
||||||
let rows = sqlx::query(
|
let rows = sqlx::query(
|
||||||
"SELECT f.remote_actor_url,
|
"SELECT f.remote_actor_url,
|
||||||
a.handle, a.inbox_url, a.shared_inbox_url, a.display_name
|
a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.avatar_url
|
||||||
FROM ap_followers f
|
FROM ap_followers f
|
||||||
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
|
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
|
||||||
WHERE f.local_user_id = ? AND f.status = 'pending'",
|
WHERE f.local_user_id = ? AND f.status = 'pending'",
|
||||||
@@ -323,6 +328,7 @@ impl FederationRepository for SqliteFederationRepository {
|
|||||||
inbox_url: row.try_get("inbox_url").unwrap_or_default(),
|
inbox_url: row.try_get("inbox_url").unwrap_or_default(),
|
||||||
shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(),
|
shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(),
|
||||||
display_name: row.try_get("display_name").ok().flatten(),
|
display_name: row.try_get("display_name").ok().flatten(),
|
||||||
|
avatar_url: row.try_get("avatar_url").ok().flatten(),
|
||||||
}).collect())
|
}).collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -353,6 +359,35 @@ impl FederationRepository for SqliteFederationRepository {
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn add_announce(
|
||||||
|
&self,
|
||||||
|
activity_id: &str,
|
||||||
|
object_url: &str,
|
||||||
|
actor_url: &str,
|
||||||
|
announced_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let ts = announced_at.format("%Y-%m-%d %H:%M:%S").to_string();
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT OR IGNORE INTO ap_announces (id, object_url, actor_url, announced_at)
|
||||||
|
VALUES (?1, ?2, ?3, ?4)",
|
||||||
|
)
|
||||||
|
.bind(activity_id)
|
||||||
|
.bind(object_url)
|
||||||
|
.bind(actor_url)
|
||||||
|
.bind(&ts)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn count_announces(&self, object_url: &str) -> Result<usize> {
|
||||||
|
let row = sqlx::query("SELECT COUNT(*) as cnt FROM ap_announces WHERE object_url = ?1")
|
||||||
|
.bind(object_url)
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(row.get::<i64, _>("cnt") as usize)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Content-specific repository (movies-diary) ---
|
// --- Content-specific repository (movies-diary) ---
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ impl SqliteUserRepository {
|
|||||||
username_str: String,
|
username_str: String,
|
||||||
hash_str: String,
|
hash_str: String,
|
||||||
role: UserRole,
|
role: UserRole,
|
||||||
|
bio: Option<String>,
|
||||||
|
avatar_path: Option<String>,
|
||||||
) -> Result<User, DomainError> {
|
) -> Result<User, DomainError> {
|
||||||
let id = uuid::Uuid::parse_str(&id_str)
|
let id = uuid::Uuid::parse_str(&id_str)
|
||||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||||
@@ -52,6 +54,8 @@ impl SqliteUserRepository {
|
|||||||
username,
|
username,
|
||||||
hash,
|
hash,
|
||||||
role,
|
role,
|
||||||
|
bio,
|
||||||
|
avatar_path,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -61,7 +65,7 @@ impl UserRepository for SqliteUserRepository {
|
|||||||
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError> {
|
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError> {
|
||||||
let email_str = email.value();
|
let email_str = email.value();
|
||||||
let row = sqlx::query!(
|
let row = sqlx::query!(
|
||||||
"SELECT id, email, username, password_hash, role FROM users WHERE email = ?",
|
"SELECT id, email, username, password_hash, role, bio, avatar_path FROM users WHERE email = ?",
|
||||||
email_str
|
email_str
|
||||||
)
|
)
|
||||||
.fetch_optional(&self.pool)
|
.fetch_optional(&self.pool)
|
||||||
@@ -75,6 +79,8 @@ impl UserRepository for SqliteUserRepository {
|
|||||||
r.username,
|
r.username,
|
||||||
r.password_hash,
|
r.password_hash,
|
||||||
Self::parse_role(&r.role),
|
Self::parse_role(&r.role),
|
||||||
|
r.bio,
|
||||||
|
r.avatar_path,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.transpose()
|
.transpose()
|
||||||
@@ -83,7 +89,7 @@ impl UserRepository for SqliteUserRepository {
|
|||||||
async fn find_by_username(&self, username: &Username) -> Result<Option<User>, DomainError> {
|
async fn find_by_username(&self, username: &Username) -> Result<Option<User>, DomainError> {
|
||||||
let username_str = username.value();
|
let username_str = username.value();
|
||||||
let row = sqlx::query!(
|
let row = sqlx::query!(
|
||||||
"SELECT id, email, username, password_hash, role FROM users WHERE username = ?",
|
"SELECT id, email, username, password_hash, role, bio, avatar_path FROM users WHERE username = ?",
|
||||||
username_str
|
username_str
|
||||||
)
|
)
|
||||||
.fetch_optional(&self.pool)
|
.fetch_optional(&self.pool)
|
||||||
@@ -97,6 +103,8 @@ impl UserRepository for SqliteUserRepository {
|
|||||||
r.username,
|
r.username,
|
||||||
r.password_hash,
|
r.password_hash,
|
||||||
Self::parse_role(&r.role),
|
Self::parse_role(&r.role),
|
||||||
|
r.bio,
|
||||||
|
r.avatar_path,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.transpose()
|
.transpose()
|
||||||
@@ -140,7 +148,7 @@ impl UserRepository for SqliteUserRepository {
|
|||||||
async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError> {
|
async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError> {
|
||||||
let id_str = id.value().to_string();
|
let id_str = id.value().to_string();
|
||||||
let row = sqlx::query!(
|
let row = sqlx::query!(
|
||||||
"SELECT id, email, username, password_hash, role FROM users WHERE id = ?",
|
"SELECT id, email, username, password_hash, role, bio, avatar_path FROM users WHERE id = ?",
|
||||||
id_str
|
id_str
|
||||||
)
|
)
|
||||||
.fetch_optional(&self.pool)
|
.fetch_optional(&self.pool)
|
||||||
@@ -154,11 +162,30 @@ impl UserRepository for SqliteUserRepository {
|
|||||||
r.username,
|
r.username,
|
||||||
r.password_hash,
|
r.password_hash,
|
||||||
Self::parse_role(&r.role),
|
Self::parse_role(&r.role),
|
||||||
|
r.bio,
|
||||||
|
r.avatar_path,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.transpose()
|
.transpose()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn update_profile(
|
||||||
|
&self,
|
||||||
|
user_id: &UserId,
|
||||||
|
bio: Option<String>,
|
||||||
|
avatar_path: Option<String>,
|
||||||
|
) -> Result<(), DomainError> {
|
||||||
|
let id_str = user_id.value().to_string();
|
||||||
|
sqlx::query("UPDATE users SET bio = ?, avatar_path = ? WHERE id = ?")
|
||||||
|
.bind(&bio)
|
||||||
|
.bind(&avatar_path)
|
||||||
|
.bind(&id_str)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
async fn list_with_stats(&self) -> Result<Vec<domain::models::UserSummary>, DomainError> {
|
async fn list_with_stats(&self) -> Result<Vec<domain::models::UserSummary>, DomainError> {
|
||||||
sqlx::query_as!(
|
sqlx::query_as!(
|
||||||
UserSummaryRow,
|
UserSummaryRow,
|
||||||
@@ -183,12 +210,14 @@ impl UserRepository for SqliteUserRepository {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use domain::models::UserRole;
|
||||||
|
use domain::value_objects::{Email, PasswordHash, Username};
|
||||||
use sqlx::SqlitePool;
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
async fn setup() -> (SqlitePool, SqliteUserRepository) {
|
async fn setup() -> (SqlitePool, SqliteUserRepository) {
|
||||||
let pool = SqlitePool::connect(":memory:").await.unwrap();
|
let pool = SqlitePool::connect(":memory:").await.unwrap();
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"CREATE TABLE users (id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, created_at TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'standard')"
|
"CREATE TABLE users (id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, created_at TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'standard', bio TEXT, avatar_path TEXT)"
|
||||||
)
|
)
|
||||||
.execute(&pool)
|
.execute(&pool)
|
||||||
.await
|
.await
|
||||||
@@ -227,4 +256,48 @@ mod tests {
|
|||||||
assert!(result.is_some());
|
assert!(result.is_some());
|
||||||
assert_eq!(result.unwrap().email().value(), "test@example.com");
|
assert_eq!(result.unwrap().email().value(), "test@example.com");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn update_profile_persists_bio_and_avatar() {
|
||||||
|
let (_, repo) = setup().await;
|
||||||
|
let user = domain::models::User::new(
|
||||||
|
Email::new("test@example.com".to_string()).unwrap(),
|
||||||
|
Username::new("testuser".to_string()).unwrap(),
|
||||||
|
PasswordHash::new("hash".to_string()).unwrap(),
|
||||||
|
UserRole::Standard,
|
||||||
|
);
|
||||||
|
repo.save(&user).await.unwrap();
|
||||||
|
|
||||||
|
repo.update_profile(
|
||||||
|
user.id(),
|
||||||
|
Some("My biography".to_string()),
|
||||||
|
Some("avatars/user1".to_string()),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let found = repo.find_by_id(user.id()).await.unwrap().unwrap();
|
||||||
|
assert_eq!(found.bio(), Some("My biography"));
|
||||||
|
assert_eq!(found.avatar_path(), Some("avatars/user1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn update_profile_clears_fields_with_none() {
|
||||||
|
let (_, repo) = setup().await;
|
||||||
|
let user = domain::models::User::new(
|
||||||
|
Email::new("test2@example.com".to_string()).unwrap(),
|
||||||
|
Username::new("testuser2".to_string()).unwrap(),
|
||||||
|
PasswordHash::new("hash".to_string()).unwrap(),
|
||||||
|
UserRole::Standard,
|
||||||
|
);
|
||||||
|
repo.save(&user).await.unwrap();
|
||||||
|
repo.update_profile(user.id(), Some("bio".to_string()), Some("path".to_string()))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
repo.update_profile(user.id(), None, None).await.unwrap();
|
||||||
|
|
||||||
|
let found = repo.find_by_id(user.id()).await.unwrap().unwrap();
|
||||||
|
assert_eq!(found.bio(), None);
|
||||||
|
assert_eq!(found.avatar_path(), None);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use application::ports::{
|
|||||||
ActivityFeedPageData, FollowersPageData, FollowingPageData, HtmlPageContext, HtmlRenderer,
|
ActivityFeedPageData, FollowersPageData, FollowingPageData, HtmlPageContext, HtmlRenderer,
|
||||||
ImportMappingPageData, ImportPreviewPageData, ImportPreviewRow, ImportProfileView,
|
ImportMappingPageData, ImportPreviewPageData, ImportPreviewRow, ImportProfileView,
|
||||||
ImportRowStatus, ImportUploadPageData, LoginPageData, MovieDetailPageData, NewReviewPageData,
|
ImportRowStatus, ImportUploadPageData, LoginPageData, MovieDetailPageData, NewReviewPageData,
|
||||||
ProfilePageData, RegisterPageData, UsersPageData,
|
ProfilePageData, ProfileSettingsPageData, RegisterPageData, UsersPageData,
|
||||||
};
|
};
|
||||||
use askama::Template;
|
use askama::Template;
|
||||||
use chrono::Datelike;
|
use chrono::Datelike;
|
||||||
@@ -305,6 +305,15 @@ fn bar_height_px(avg_rating: f64) -> i64 {
|
|||||||
(avg_rating / 5.0 * 60.0) as i64
|
(avg_rating / 5.0 * 60.0) as i64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "profile_settings.html")]
|
||||||
|
struct ProfileSettingsTemplate<'a> {
|
||||||
|
ctx: &'a HtmlPageContext,
|
||||||
|
bio: Option<&'a str>,
|
||||||
|
avatar_url: Option<&'a str>,
|
||||||
|
saved: bool,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Template)]
|
#[derive(Template)]
|
||||||
#[template(path = "import_upload.html")]
|
#[template(path = "import_upload.html")]
|
||||||
struct ImportUploadTemplate<'a> {
|
struct ImportUploadTemplate<'a> {
|
||||||
@@ -649,4 +658,18 @@ impl HtmlRenderer for AskamaHtmlRenderer {
|
|||||||
.render()
|
.render()
|
||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn render_profile_settings_page(
|
||||||
|
&self,
|
||||||
|
data: ProfileSettingsPageData,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
ProfileSettingsTemplate {
|
||||||
|
ctx: &data.ctx,
|
||||||
|
bio: data.bio.as_deref(),
|
||||||
|
avatar_url: data.avatar_url.as_deref(),
|
||||||
|
saved: data.saved,
|
||||||
|
}
|
||||||
|
.render()
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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::{
|
use domain::ports::{
|
||||||
AuthService, DiaryExporter, DiaryRepository, DocumentParser, EventPublisher,
|
AuthService, DiaryExporter, DiaryRepository, DocumentParser, EventPublisher,
|
||||||
|
ImageStorage,
|
||||||
ImportProfileRepository, ImportSessionRepository,
|
ImportProfileRepository, ImportSessionRepository,
|
||||||
MetadataClient, MovieRepository, PasswordHasher, PosterFetcherClient,
|
MetadataClient, MovieRepository, PasswordHasher, PosterFetcherClient,
|
||||||
PosterStorage, ReviewRepository, StatsRepository, UserRepository,
|
ReviewRepository, StatsRepository, UserRepository,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::config::AppConfig;
|
use crate::config::AppConfig;
|
||||||
@@ -19,7 +20,7 @@ pub struct AppContext {
|
|||||||
pub stats_repository: Arc<dyn StatsRepository>,
|
pub stats_repository: Arc<dyn StatsRepository>,
|
||||||
pub metadata_client: Arc<dyn MetadataClient>,
|
pub metadata_client: Arc<dyn MetadataClient>,
|
||||||
pub poster_fetcher: Arc<dyn PosterFetcherClient>,
|
pub poster_fetcher: Arc<dyn PosterFetcherClient>,
|
||||||
pub poster_storage: Arc<dyn PosterStorage>,
|
pub image_storage: Arc<dyn ImageStorage>,
|
||||||
pub event_publisher: Arc<dyn EventPublisher>,
|
pub event_publisher: Arc<dyn EventPublisher>,
|
||||||
pub auth_service: Arc<dyn AuthService>,
|
pub auth_service: Arc<dyn AuthService>,
|
||||||
pub password_hasher: Arc<dyn PasswordHasher>,
|
pub password_hasher: Arc<dyn PasswordHasher>,
|
||||||
|
|||||||
@@ -145,6 +145,13 @@ pub struct ImportPreviewPageData {
|
|||||||
pub rows: Vec<ImportPreviewRow>,
|
pub rows: Vec<ImportPreviewRow>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub struct ProfileSettingsPageData {
|
||||||
|
pub ctx: HtmlPageContext,
|
||||||
|
pub bio: Option<String>,
|
||||||
|
pub avatar_url: Option<String>,
|
||||||
|
pub saved: bool,
|
||||||
|
}
|
||||||
|
|
||||||
pub trait HtmlRenderer: Send + Sync {
|
pub trait HtmlRenderer: Send + Sync {
|
||||||
fn render_diary_page(
|
fn render_diary_page(
|
||||||
&self,
|
&self,
|
||||||
@@ -163,6 +170,10 @@ pub trait HtmlRenderer: Send + Sync {
|
|||||||
fn render_import_upload_page(&self, data: ImportUploadPageData) -> Result<String, String>;
|
fn render_import_upload_page(&self, data: ImportUploadPageData) -> Result<String, String>;
|
||||||
fn render_import_mapping_page(&self, data: ImportMappingPageData) -> Result<String, String>;
|
fn render_import_mapping_page(&self, data: ImportMappingPageData) -> Result<String, String>;
|
||||||
fn render_import_preview_page(&self, data: ImportPreviewPageData) -> Result<String, String>;
|
fn render_import_preview_page(&self, data: ImportPreviewPageData) -> Result<String, String>;
|
||||||
|
fn render_profile_settings_page(
|
||||||
|
&self,
|
||||||
|
data: ProfileSettingsPageData,
|
||||||
|
) -> Result<String, String>;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub trait RssFeedRenderer: Send + Sync {
|
pub trait RssFeedRenderer: Send + Sync {
|
||||||
|
|||||||
@@ -18,3 +18,4 @@ pub mod log_review;
|
|||||||
pub mod login;
|
pub mod login;
|
||||||
pub mod register;
|
pub mod register;
|
||||||
pub mod sync_poster;
|
pub mod sync_poster;
|
||||||
|
pub mod update_profile;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use domain::{
|
use domain::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
value_objects::{ExternalMetadataId, MovieId},
|
value_objects::{ExternalMetadataId, MovieId, PosterPath},
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::{commands::SyncPosterCommand, context::AppContext};
|
use crate::{commands::SyncPosterCommand, context::AppContext};
|
||||||
@@ -36,11 +36,12 @@ pub async fn execute(ctx: &AppContext, cmd: SyncPosterCommand) -> Result<(), Dom
|
|||||||
let image_bytes = ctx.poster_fetcher.fetch_poster_bytes(&poster_url).await?;
|
let image_bytes = ctx.poster_fetcher.fetch_poster_bytes(&poster_url).await?;
|
||||||
|
|
||||||
let stored_path = ctx
|
let stored_path = ctx
|
||||||
.poster_storage
|
.image_storage
|
||||||
.store_poster(&movie_id, &image_bytes)
|
.store(&movie_id.value().to_string(), &image_bytes)
|
||||||
.await?;
|
.await?;
|
||||||
|
let poster_path = PosterPath::new(stored_path)?;
|
||||||
|
|
||||||
movie.update_poster(stored_path);
|
movie.update_poster(poster_path);
|
||||||
ctx.movie_repository.upsert_movie(&movie).await?;
|
ctx.movie_repository.upsert_movie(&movie).await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
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::ReviewLogged { .. } => "review_logged",
|
||||||
DomainEvent::ReviewUpdated { .. } => "review_updated",
|
DomainEvent::ReviewUpdated { .. } => "review_updated",
|
||||||
DomainEvent::MovieDeleted { .. } => "movie_deleted",
|
DomainEvent::MovieDeleted { .. } => "movie_deleted",
|
||||||
|
DomainEvent::UserUpdated { .. } => "user_updated",
|
||||||
};
|
};
|
||||||
self.calls.lock().unwrap().push(label);
|
self.calls.lock().unwrap().push(label);
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ pub enum DomainEvent {
|
|||||||
movie_id: MovieId,
|
movie_id: MovieId,
|
||||||
poster_path: Option<PosterPath>,
|
poster_path: Option<PosterPath>,
|
||||||
},
|
},
|
||||||
|
UserUpdated {
|
||||||
|
user_id: UserId,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
|
|||||||
@@ -290,6 +290,8 @@ pub struct User {
|
|||||||
username: Username,
|
username: Username,
|
||||||
password_hash: PasswordHash,
|
password_hash: PasswordHash,
|
||||||
role: UserRole,
|
role: UserRole,
|
||||||
|
bio: Option<String>,
|
||||||
|
avatar_path: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl User {
|
impl User {
|
||||||
@@ -305,6 +307,8 @@ impl User {
|
|||||||
username,
|
username,
|
||||||
password_hash,
|
password_hash,
|
||||||
role,
|
role,
|
||||||
|
bio: None,
|
||||||
|
avatar_path: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -314,6 +318,8 @@ impl User {
|
|||||||
username: Username,
|
username: Username,
|
||||||
password_hash: PasswordHash,
|
password_hash: PasswordHash,
|
||||||
role: UserRole,
|
role: UserRole,
|
||||||
|
bio: Option<String>,
|
||||||
|
avatar_path: Option<String>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
id,
|
id,
|
||||||
@@ -321,6 +327,8 @@ impl User {
|
|||||||
username,
|
username,
|
||||||
password_hash,
|
password_hash,
|
||||||
role,
|
role,
|
||||||
|
bio,
|
||||||
|
avatar_path,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -328,6 +336,11 @@ impl User {
|
|||||||
self.password_hash = new_hash;
|
self.password_hash = new_hash;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn update_profile(&mut self, bio: Option<String>, avatar_path: Option<String>) {
|
||||||
|
self.bio = bio;
|
||||||
|
self.avatar_path = avatar_path;
|
||||||
|
}
|
||||||
|
|
||||||
pub fn email(&self) -> &Email {
|
pub fn email(&self) -> &Email {
|
||||||
&self.email
|
&self.email
|
||||||
}
|
}
|
||||||
@@ -343,6 +356,13 @@ impl User {
|
|||||||
pub fn role(&self) -> &UserRole {
|
pub fn role(&self) -> &UserRole {
|
||||||
&self.role
|
&self.role
|
||||||
}
|
}
|
||||||
|
pub fn bio(&self) -> Option<&str> {
|
||||||
|
self.bio.as_deref()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn avatar_path(&self) -> Option<&str> {
|
||||||
|
self.avatar_path.as_deref()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
@@ -435,3 +455,38 @@ pub enum ExportFormat {
|
|||||||
Csv,
|
Csv,
|
||||||
Json,
|
Json,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::value_objects::{Email, PasswordHash, UserId, Username};
|
||||||
|
|
||||||
|
fn make_user() -> User {
|
||||||
|
User::from_persistence(
|
||||||
|
UserId::generate(),
|
||||||
|
Email::new("a@b.com".to_string()).unwrap(),
|
||||||
|
Username::new("alice".to_string()).unwrap(),
|
||||||
|
PasswordHash::new("hash".to_string()).unwrap(),
|
||||||
|
UserRole::Standard,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn update_profile_sets_fields() {
|
||||||
|
let mut user = make_user();
|
||||||
|
user.update_profile(Some("My bio".to_string()), Some("avatars/abc".to_string()));
|
||||||
|
assert_eq!(user.bio(), Some("My bio"));
|
||||||
|
assert_eq!(user.avatar_path(), Some("avatars/abc"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn update_profile_clears_with_none() {
|
||||||
|
let mut user = make_user();
|
||||||
|
user.update_profile(Some("bio".to_string()), Some("path".to_string()));
|
||||||
|
user.update_profile(None, None);
|
||||||
|
assert_eq!(user.bio(), None);
|
||||||
|
assert_eq!(user.avatar_path(), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ use crate::{
|
|||||||
},
|
},
|
||||||
value_objects::{
|
value_objects::{
|
||||||
Email, ExternalMetadataId, ImportProfileId, ImportSessionId, MovieId, MovieTitle,
|
Email, ExternalMetadataId, ImportProfileId, ImportSessionId, MovieId, MovieTitle,
|
||||||
PasswordHash, PosterPath, PosterUrl, ReleaseYear, ReviewId, UserId, Username,
|
PasswordHash, PosterUrl, ReleaseYear, ReviewId, UserId, Username,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -150,16 +150,11 @@ pub trait PosterFetcherClient: Send + Sync {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait PosterStorage: Send + Sync {
|
pub trait ImageStorage: Send + Sync {
|
||||||
async fn store_poster(
|
/// Stores `image_bytes` at `key` and returns the stored key.
|
||||||
&self,
|
async fn store(&self, key: &str, image_bytes: &[u8]) -> Result<String, DomainError>;
|
||||||
movie_id: &MovieId,
|
async fn get(&self, key: &str) -> Result<Vec<u8>, DomainError>;
|
||||||
image_bytes: &[u8],
|
async fn delete(&self, key: &str) -> Result<(), DomainError>;
|
||||||
) -> Result<PosterPath, DomainError>;
|
|
||||||
|
|
||||||
async fn get_poster(&self, poster_path: &PosterPath) -> Result<Vec<u8>, DomainError>;
|
|
||||||
|
|
||||||
async fn delete_poster(&self, path: &PosterPath) -> Result<(), DomainError>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct GeneratedToken {
|
pub struct GeneratedToken {
|
||||||
@@ -180,6 +175,12 @@ pub trait UserRepository: Send + Sync {
|
|||||||
async fn save(&self, user: &User) -> Result<(), DomainError>;
|
async fn save(&self, user: &User) -> Result<(), DomainError>;
|
||||||
async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError>;
|
async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError>;
|
||||||
async fn list_with_stats(&self) -> Result<Vec<UserSummary>, DomainError>;
|
async fn list_with_stats(&self) -> Result<Vec<UserSummary>, DomainError>;
|
||||||
|
async fn update_profile(
|
||||||
|
&self,
|
||||||
|
user_id: &UserId,
|
||||||
|
bio: Option<String>,
|
||||||
|
avatar_path: Option<String>,
|
||||||
|
) -> Result<(), DomainError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ application = { workspace = true }
|
|||||||
auth = { workspace = true }
|
auth = { workspace = true }
|
||||||
metadata = { workspace = true }
|
metadata = { workspace = true }
|
||||||
poster-fetcher = { workspace = true }
|
poster-fetcher = { workspace = true }
|
||||||
poster-storage = { workspace = true }
|
image-storage = { workspace = true }
|
||||||
template-askama = { workspace = true }
|
template-askama = { workspace = true }
|
||||||
nats = { workspace = true, optional = true }
|
nats = { workspace = true, optional = true }
|
||||||
rss = { workspace = true }
|
rss = { workspace = true }
|
||||||
|
|||||||
@@ -433,6 +433,13 @@ pub struct PaginationQueryParams {
|
|||||||
pub offset: Option<u32>,
|
pub offset: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Serialize, utoipa::ToSchema)]
|
||||||
|
pub struct ProfileResponse {
|
||||||
|
pub username: String,
|
||||||
|
pub bio: Option<String>,
|
||||||
|
pub avatar_url: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(serde::Serialize, utoipa::ToSchema)]
|
#[derive(serde::Serialize, utoipa::ToSchema)]
|
||||||
pub struct MovieStatsDto {
|
pub struct MovieStatsDto {
|
||||||
pub total_count: u64,
|
pub total_count: u64,
|
||||||
|
|||||||
@@ -137,12 +137,12 @@ mod tests {
|
|||||||
collections::{PageParams, Paginated},
|
collections::{PageParams, Paginated},
|
||||||
},
|
},
|
||||||
ports::{
|
ports::{
|
||||||
AuthService, DiaryRepository, EventPublisher, GeneratedToken, MetadataClient,
|
AuthService, DiaryRepository, EventPublisher, GeneratedToken, ImageStorage,
|
||||||
MovieRepository, PasswordHasher, PosterFetcherClient, PosterStorage, ReviewRepository,
|
MetadataClient, MovieRepository, PasswordHasher, PosterFetcherClient, ReviewRepository,
|
||||||
StatsRepository, UserRepository,
|
StatsRepository, UserRepository,
|
||||||
},
|
},
|
||||||
value_objects::{
|
value_objects::{
|
||||||
Email, ExternalMetadataId, MovieId, MovieTitle, PasswordHash, PosterPath, PosterUrl,
|
Email, ExternalMetadataId, MovieId, MovieTitle, PasswordHash, PosterUrl,
|
||||||
ReleaseYear, ReviewId, UserId,
|
ReleaseYear, ReviewId, UserId,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -279,16 +279,10 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
impl PosterStorage for Panic {
|
impl ImageStorage for Panic {
|
||||||
async fn store_poster(&self, _: &MovieId, _: &[u8]) -> Result<PosterPath, DomainError> {
|
async fn store(&self, _: &str, _: &[u8]) -> Result<String, DomainError> { panic!() }
|
||||||
panic!()
|
async fn get(&self, _: &str) -> Result<Vec<u8>, DomainError> { panic!() }
|
||||||
}
|
async fn delete(&self, _: &str) -> Result<(), DomainError> { panic!() }
|
||||||
async fn get_poster(&self, _: &PosterPath) -> Result<Vec<u8>, DomainError> {
|
|
||||||
panic!()
|
|
||||||
}
|
|
||||||
async fn delete_poster(&self, _: &PosterPath) -> Result<(), DomainError> {
|
|
||||||
panic!()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
impl AuthService for Panic {
|
impl AuthService for Panic {
|
||||||
@@ -334,6 +328,9 @@ mod tests {
|
|||||||
async fn list_with_stats(&self) -> Result<Vec<domain::models::UserSummary>, DomainError> {
|
async fn list_with_stats(&self) -> Result<Vec<domain::models::UserSummary>, DomainError> {
|
||||||
panic!()
|
panic!()
|
||||||
}
|
}
|
||||||
|
async fn update_profile(&self, _: &UserId, _: Option<String>, _: Option<String>) -> Result<(), DomainError> {
|
||||||
|
panic!()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
impl EventPublisher for Panic {
|
impl EventPublisher for Panic {
|
||||||
@@ -442,6 +439,7 @@ mod tests {
|
|||||||
fn render_import_upload_page(&self, _: application::ports::ImportUploadPageData) -> Result<String, String> { panic!() }
|
fn render_import_upload_page(&self, _: application::ports::ImportUploadPageData) -> Result<String, String> { panic!() }
|
||||||
fn render_import_mapping_page(&self, _: application::ports::ImportMappingPageData) -> Result<String, String> { panic!() }
|
fn render_import_mapping_page(&self, _: application::ports::ImportMappingPageData) -> Result<String, String> { panic!() }
|
||||||
fn render_import_preview_page(&self, _: application::ports::ImportPreviewPageData) -> Result<String, String> { panic!() }
|
fn render_import_preview_page(&self, _: application::ports::ImportPreviewPageData) -> Result<String, String> { panic!() }
|
||||||
|
fn render_profile_settings_page(&self, _: application::ports::ProfileSettingsPageData) -> Result<String, String> { panic!() }
|
||||||
}
|
}
|
||||||
impl crate::ports::RssFeedRenderer for Panic {
|
impl crate::ports::RssFeedRenderer for Panic {
|
||||||
fn render_feed(&self, _: &[DiaryEntry], _: &str) -> Result<String, String> {
|
fn render_feed(&self, _: &[DiaryEntry], _: &str) -> Result<String, String> {
|
||||||
@@ -474,7 +472,7 @@ mod tests {
|
|||||||
stats_repository: Arc::clone(&repo) as _,
|
stats_repository: Arc::clone(&repo) as _,
|
||||||
metadata_client: Arc::clone(&repo) as _,
|
metadata_client: Arc::clone(&repo) as _,
|
||||||
poster_fetcher: Arc::clone(&repo) as _,
|
poster_fetcher: Arc::clone(&repo) as _,
|
||||||
poster_storage: Arc::clone(&repo) as _,
|
image_storage: Arc::clone(&repo) as _,
|
||||||
event_publisher: Arc::clone(&repo) as _,
|
event_publisher: Arc::clone(&repo) as _,
|
||||||
password_hasher: Arc::clone(&repo) as _,
|
password_hasher: Arc::clone(&repo) as _,
|
||||||
user_repository: Arc::clone(&repo) as _,
|
user_repository: Arc::clone(&repo) as _,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use axum::{
|
use axum::{
|
||||||
Json,
|
Json,
|
||||||
extract::{Path, Query, State},
|
extract::{Multipart, Path, Query, State},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
response::IntoResponse,
|
response::IntoResponse,
|
||||||
};
|
};
|
||||||
@@ -20,7 +20,7 @@ use application::{
|
|||||||
delete_review, export_diary as export_diary_uc, get_activity_feed as get_feed_uc,
|
delete_review, export_diary as export_diary_uc, get_activity_feed as get_feed_uc,
|
||||||
get_diary, get_movie_social_page, get_review_history,
|
get_diary, get_movie_social_page, get_review_history,
|
||||||
get_user_profile as get_user_profile_uc, get_users, log_review, login as login_uc,
|
get_user_profile as get_user_profile_uc, get_users, log_review, login as login_uc,
|
||||||
register as register_uc, sync_poster,
|
register as register_uc, sync_poster, update_profile,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
use domain::{
|
use domain::{
|
||||||
@@ -37,8 +37,8 @@ use crate::{
|
|||||||
ActivityFeedQueryParams, ActivityFeedResponse, DiaryEntryDto, DiaryQueryParams,
|
ActivityFeedQueryParams, ActivityFeedResponse, DiaryEntryDto, DiaryQueryParams,
|
||||||
DiaryResponse, DirectorStatDto, ExportQueryParams, FeedEntryDto, LogReviewData,
|
DiaryResponse, DirectorStatDto, ExportQueryParams, FeedEntryDto, LogReviewData,
|
||||||
LogReviewRequest, LoginRequest, LoginResponse, MonthActivityDto, MonthlyRatingDto,
|
LogReviewRequest, LoginRequest, LoginResponse, MonthActivityDto, MonthlyRatingDto,
|
||||||
MovieDetailResponse, MovieDto, MovieStatsDto, PaginationQueryParams, RegisterRequest,
|
MovieDetailResponse, MovieDto, MovieStatsDto, PaginationQueryParams, ProfileResponse,
|
||||||
ReviewDto, ReviewHistoryResponse, SocialFeedResponse, SocialReviewDto,
|
RegisterRequest, ReviewDto, ReviewHistoryResponse, SocialFeedResponse, SocialReviewDto,
|
||||||
UserProfileQueryParams, UserProfileResponse, UserStatsDto, UserSummaryDto, UserTrendsDto,
|
UserProfileQueryParams, UserProfileResponse, UserStatsDto, UserSummaryDto, UserTrendsDto,
|
||||||
UsersResponse,
|
UsersResponse,
|
||||||
},
|
},
|
||||||
@@ -290,6 +290,101 @@ pub async fn get_movie_detail(
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
get, path = "/api/v1/profile",
|
||||||
|
responses(
|
||||||
|
(status = 200, body = ProfileResponse),
|
||||||
|
(status = 401, description = "Unauthorized"),
|
||||||
|
(status = 404, description = "User not found"),
|
||||||
|
),
|
||||||
|
security(("bearer_auth" = []))
|
||||||
|
)]
|
||||||
|
pub async fn get_profile(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let user = match state.app_ctx.user_repository.find_by_id(&user_id).await {
|
||||||
|
Ok(Some(u)) => u,
|
||||||
|
Ok(None) => return StatusCode::NOT_FOUND.into_response(),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("get_profile user lookup: {:?}", e);
|
||||||
|
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let base_url = &state.app_ctx.config.base_url;
|
||||||
|
let avatar_url = user
|
||||||
|
.avatar_path()
|
||||||
|
.map(|path| format!("{}/images/{}", base_url, path));
|
||||||
|
|
||||||
|
Json(ProfileResponse {
|
||||||
|
username: user.username().value().to_string(),
|
||||||
|
bio: user.bio().map(|s| s.to_string()),
|
||||||
|
avatar_url,
|
||||||
|
})
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
put, path = "/api/v1/profile",
|
||||||
|
responses(
|
||||||
|
(status = 204, description = "Profile updated"),
|
||||||
|
(status = 400, description = "Invalid input"),
|
||||||
|
(status = 401, description = "Unauthorized"),
|
||||||
|
(status = 500, description = "Internal server error"),
|
||||||
|
),
|
||||||
|
security(("bearer_auth" = []))
|
||||||
|
)]
|
||||||
|
pub async fn update_profile_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||||
|
mut multipart: Multipart,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let mut bio: Option<String> = None;
|
||||||
|
let mut avatar_bytes: Option<Vec<u8>> = None;
|
||||||
|
let mut avatar_content_type: Option<String> = None;
|
||||||
|
|
||||||
|
while let Ok(Some(field)) = multipart.next_field().await {
|
||||||
|
let name = field.name().unwrap_or("").to_string();
|
||||||
|
match name.as_str() {
|
||||||
|
"bio" => {
|
||||||
|
if let Ok(text) = field.text().await {
|
||||||
|
bio = Some(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"avatar" => {
|
||||||
|
let content_type = field.content_type().map(|s| s.to_string());
|
||||||
|
if let Ok(bytes) = field.bytes().await {
|
||||||
|
if !bytes.is_empty() {
|
||||||
|
avatar_bytes = Some(bytes.to_vec());
|
||||||
|
avatar_content_type = content_type;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let cmd = update_profile::UpdateProfileCommand {
|
||||||
|
user_id: user_id.value(),
|
||||||
|
bio,
|
||||||
|
avatar_bytes,
|
||||||
|
avatar_content_type,
|
||||||
|
};
|
||||||
|
|
||||||
|
match update_profile::execute(&state.app_ctx, cmd).await {
|
||||||
|
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
||||||
|
Err(domain::errors::DomainError::ValidationError(msg)) => {
|
||||||
|
tracing::warn!("update_profile validation: {}", msg);
|
||||||
|
StatusCode::BAD_REQUEST.into_response()
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("update_profile error: {:?}", e);
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR.into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn movie_to_dto(movie: &Movie) -> MovieDto {
|
fn movie_to_dto(movie: &Movie) -> MovieDto {
|
||||||
MovieDto {
|
MovieDto {
|
||||||
id: movie.id().value(),
|
id: movie.id().value(),
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use std::str::FromStr;
|
|||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
Form,
|
Form,
|
||||||
extract::{Extension, Path, Query, State},
|
extract::{Extension, Multipart, Path, Query, State},
|
||||||
http::{HeaderValue, StatusCode, header::SET_COOKIE},
|
http::{HeaderValue, StatusCode, header::SET_COOKIE},
|
||||||
response::{Html, IntoResponse, Redirect},
|
response::{Html, IntoResponse, Redirect},
|
||||||
};
|
};
|
||||||
@@ -14,13 +14,13 @@ use application::ports::{FollowersPageData, FollowingPageData};
|
|||||||
use application::{
|
use application::{
|
||||||
commands::{DeleteReviewCommand, ExportCommand, LoginCommand, RegisterCommand},
|
commands::{DeleteReviewCommand, ExportCommand, LoginCommand, RegisterCommand},
|
||||||
ports::{
|
ports::{
|
||||||
HtmlPageContext, LoginPageData, MovieDetailPageData, NewReviewPageData, RegisterPageData,
|
HtmlPageContext, LoginPageData, MovieDetailPageData, NewReviewPageData,
|
||||||
RemoteActorView,
|
ProfileSettingsPageData, RegisterPageData, RemoteActorView,
|
||||||
},
|
},
|
||||||
queries::GetMovieSocialPageQuery,
|
queries::GetMovieSocialPageQuery,
|
||||||
use_cases::{
|
use_cases::{
|
||||||
delete_review, export_diary as export_diary_uc, get_movie_social_page, log_review,
|
delete_review, export_diary as export_diary_uc, get_movie_social_page, log_review,
|
||||||
login as login_uc, register as register_uc,
|
login as login_uc, register as register_uc, update_profile,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
use domain::models::ExportFormat;
|
use domain::models::ExportFormat;
|
||||||
@@ -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 html;
|
||||||
pub mod posters;
|
pub mod images;
|
||||||
pub mod rss;
|
pub mod rss;
|
||||||
pub mod api;
|
pub mod api;
|
||||||
pub mod import;
|
pub mod import;
|
||||||
|
|||||||
@@ -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 (auth_service, password_hasher) = auth::create()?;
|
||||||
let metadata_client = metadata::create()?;
|
let metadata_client = metadata::create()?;
|
||||||
let poster_fetcher = poster_fetcher::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) =
|
let (movie_repository, review_repository, diary_repository, stats_repository, user_repository, import_session_repository, import_profile_repository, db_pool) =
|
||||||
match backend.as_str() {
|
match backend.as_str() {
|
||||||
@@ -155,7 +155,7 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
|
|||||||
stats_repository,
|
stats_repository,
|
||||||
metadata_client,
|
metadata_client,
|
||||||
poster_fetcher,
|
poster_fetcher,
|
||||||
poster_storage,
|
image_storage,
|
||||||
event_publisher: event_publisher_arc,
|
event_publisher: event_publisher_arc,
|
||||||
auth_service,
|
auth_service,
|
||||||
password_hasher,
|
password_hasher,
|
||||||
|
|||||||
@@ -74,8 +74,14 @@ fn html_routes(rate_limit: u64) -> Router<AppState> {
|
|||||||
routing::post(handlers::html::post_delete_review),
|
routing::post(handlers::html::post_delete_review),
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/posters/{*path}",
|
"/images/{*key}",
|
||||||
routing::get(handlers::posters::get_poster),
|
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("/diary/export", routing::get(handlers::html::get_export))
|
||||||
.route("/import", routing::get(handlers::import::get_import_page))
|
.route("/import", routing::get(handlers::import::get_import_page))
|
||||||
@@ -89,6 +95,11 @@ fn html_routes(rate_limit: u64) -> Router<AppState> {
|
|||||||
.route(
|
.route(
|
||||||
"/users/{id}/feed.rss",
|
"/users/{id}/feed.rss",
|
||||||
routing::get(handlers::rss::get_user_feed),
|
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")]
|
#[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}/mapping", routing::put(handlers::import::api_put_mapping))
|
||||||
.route("/import/sessions/{id}/confirm", routing::post(handlers::import::api_post_confirm))
|
.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", 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")]
|
#[cfg(feature = "federation")]
|
||||||
let base = base.merge(federation_api_routes());
|
let base = base.merge(federation_api_routes());
|
||||||
|
|||||||
@@ -12,11 +12,11 @@ use domain::{
|
|||||||
events::DomainEvent,
|
events::DomainEvent,
|
||||||
models::{Movie, User},
|
models::{Movie, User},
|
||||||
ports::{
|
ports::{
|
||||||
AuthService, EventPublisher, GeneratedToken, MetadataClient, MetadataSearchCriteria,
|
AuthService, EventPublisher, GeneratedToken, ImageStorage, MetadataClient, MetadataSearchCriteria,
|
||||||
PasswordHasher, PosterFetcherClient, PosterStorage, UserRepository,
|
PasswordHasher, PosterFetcherClient, UserRepository,
|
||||||
},
|
},
|
||||||
value_objects::{
|
value_objects::{
|
||||||
Email, ExternalMetadataId, MovieId, PasswordHash, PosterPath, PosterUrl, UserId,
|
Email, ExternalMetadataId, PasswordHash, PosterUrl, UserId,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
use http_body_util::BodyExt;
|
use http_body_util::BodyExt;
|
||||||
@@ -57,18 +57,12 @@ impl PosterFetcherClient for PanicFetcher {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct PanicStorage;
|
struct PanicImageStorage;
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl PosterStorage for PanicStorage {
|
impl ImageStorage for PanicImageStorage {
|
||||||
async fn store_poster(&self, _: &MovieId, _: &[u8]) -> Result<PosterPath, DomainError> {
|
async fn store(&self, _: &str, _: &[u8]) -> Result<String, DomainError> { panic!() }
|
||||||
panic!()
|
async fn get(&self, _: &str) -> Result<Vec<u8>, DomainError> { panic!() }
|
||||||
}
|
async fn delete(&self, _: &str) -> Result<(), DomainError> { panic!() }
|
||||||
async fn get_poster(&self, _: &PosterPath) -> Result<Vec<u8>, DomainError> {
|
|
||||||
panic!()
|
|
||||||
}
|
|
||||||
async fn delete_poster(&self, _: &PosterPath) -> Result<(), DomainError> {
|
|
||||||
panic!()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
struct PanicHasher;
|
struct PanicHasher;
|
||||||
@@ -114,6 +108,9 @@ impl UserRepository for NobodyUserRepo {
|
|||||||
async fn list_with_stats(&self) -> Result<Vec<domain::models::UserSummary>, DomainError> {
|
async fn list_with_stats(&self) -> Result<Vec<domain::models::UserSummary>, DomainError> {
|
||||||
panic!()
|
panic!()
|
||||||
}
|
}
|
||||||
|
async fn update_profile(&self, _: &UserId, _: Option<String>, _: Option<String>) -> Result<(), DomainError> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct PanicExporter;
|
struct PanicExporter;
|
||||||
@@ -194,7 +191,7 @@ async fn test_app() -> Router {
|
|||||||
stats_repository: Arc::clone(&repo) as _,
|
stats_repository: Arc::clone(&repo) as _,
|
||||||
metadata_client: Arc::new(PanicMeta),
|
metadata_client: Arc::new(PanicMeta),
|
||||||
poster_fetcher: Arc::new(PanicFetcher),
|
poster_fetcher: Arc::new(PanicFetcher),
|
||||||
poster_storage: Arc::new(PanicStorage),
|
image_storage: Arc::new(PanicImageStorage),
|
||||||
event_publisher: Arc::new(NoopEventPublisher),
|
event_publisher: Arc::new(NoopEventPublisher),
|
||||||
auth_service: Arc::new(PanicAuth),
|
auth_service: Arc::new(PanicAuth),
|
||||||
password_hasher: Arc::new(PanicHasher),
|
password_hasher: Arc::new(PanicHasher),
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ async-trait = { workspace = true }
|
|||||||
auth = { workspace = true }
|
auth = { workspace = true }
|
||||||
metadata = { workspace = true }
|
metadata = { workspace = true }
|
||||||
poster-fetcher = { workspace = true }
|
poster-fetcher = { workspace = true }
|
||||||
poster-storage = { workspace = true }
|
image-storage = { workspace = true }
|
||||||
poster-sync = { workspace = true }
|
poster-sync = { workspace = true }
|
||||||
export = { workspace = true }
|
export = { workspace = true }
|
||||||
importer = { workspace = true }
|
importer = { workspace = true }
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
let (auth_service, password_hasher) = auth::create()?;
|
let (auth_service, password_hasher) = auth::create()?;
|
||||||
let metadata_client = metadata::create()?;
|
let metadata_client = metadata::create()?;
|
||||||
let poster_fetcher = poster_fetcher::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) =
|
let (movie_repository, review_repository, diary_repository, stats_repository, user_repository, import_session_repository, import_profile_repository, db_pool) =
|
||||||
match backend.as_str() {
|
match backend.as_str() {
|
||||||
@@ -82,7 +82,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
stats_repository,
|
stats_repository,
|
||||||
metadata_client,
|
metadata_client,
|
||||||
poster_fetcher,
|
poster_fetcher,
|
||||||
poster_storage,
|
image_storage,
|
||||||
event_publisher: event_publisher_arc,
|
event_publisher: event_publisher_arc,
|
||||||
auth_service,
|
auth_service,
|
||||||
password_hasher,
|
password_hasher,
|
||||||
@@ -112,12 +112,12 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
Arc::clone(&ctx.movie_repository),
|
Arc::clone(&ctx.movie_repository),
|
||||||
Arc::clone(&ctx.metadata_client),
|
Arc::clone(&ctx.metadata_client),
|
||||||
Arc::clone(&ctx.poster_fetcher),
|
Arc::clone(&ctx.poster_fetcher),
|
||||||
Arc::clone(&ctx.poster_storage),
|
Arc::clone(&ctx.image_storage),
|
||||||
3,
|
3,
|
||||||
)) as Arc<dyn EventHandler>;
|
)) as Arc<dyn EventHandler>;
|
||||||
|
|
||||||
let cleanup = Arc::new(poster_storage::PosterCleanupHandler::new(
|
let cleanup = Arc::new(image_storage::ImageCleanupHandler::new(
|
||||||
Arc::clone(&ctx.poster_storage),
|
Arc::clone(&ctx.image_storage),
|
||||||
)) as Arc<dyn EventHandler>;
|
)) as Arc<dyn EventHandler>;
|
||||||
|
|
||||||
#[cfg(not(feature = "federation"))]
|
#[cfg(not(feature = "federation"))]
|
||||||
|
|||||||
Reference in New Issue
Block a user