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:
2026-05-11 22:59:52 +02:00
parent 8a254346f4
commit 80f620c840
89 changed files with 2231 additions and 499 deletions

View File

@@ -9,6 +9,10 @@ use activitypub_federation::{
use serde::{Deserialize, Serialize};
use url::Url;
#[derive(Clone, Default, Debug, Serialize, Deserialize)]
#[serde(rename = "Announce")]
pub struct AnnounceType;
use crate::actors::DbActor;
use crate::data::FederationData;
use crate::error::Error;
@@ -330,6 +334,54 @@ impl Activity for UpdateActivity {
}
}
// --- Announce ---
#[derive(Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AnnounceActivity {
pub(crate) id: Url,
#[serde(rename = "type", default)]
pub(crate) kind: AnnounceType,
pub(crate) actor: ObjectId<DbActor>,
pub(crate) object: Url,
pub(crate) published: Option<chrono::DateTime<chrono::Utc>>,
}
#[async_trait::async_trait]
impl Activity for AnnounceActivity {
type DataType = FederationData;
type Error = Error;
fn id(&self) -> &Url {
&self.id
}
fn actor(&self) -> &Url {
self.actor.inner()
}
async fn verify(&self, _data: &Data<Self::DataType>) -> Result<(), Self::Error> {
Ok(())
}
async fn receive(self, data: &Data<Self::DataType>) -> Result<(), Self::Error> {
let object_domain = self.object.host_str().unwrap_or("");
if object_domain != data.domain {
return Ok(());
}
data.federation_repo
.add_announce(
self.id.as_str(),
self.object.as_str(),
self.actor.inner().as_str(),
self.published.unwrap_or_else(chrono::Utc::now),
)
.await?;
tracing::info!(actor = %self.actor.inner(), object = %self.object, "received announce");
Ok(())
}
}
// --- Inbox dispatch enum ---
#[derive(Debug, Deserialize, Serialize)]
@@ -350,4 +402,6 @@ pub enum InboxActivities {
Delete(DeleteActivity),
#[serde(rename = "Update")]
Update(UpdateActivity),
#[serde(rename = "Announce")]
Announce(AnnounceActivity),
}

View File

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

View File

@@ -1,4 +1,5 @@
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use url::Url;
#[async_trait]
@@ -10,6 +11,15 @@ pub trait ApObjectHandler: Send + Sync {
user_id: uuid::Uuid,
) -> anyhow::Result<Vec<(Url, serde_json::Value)>>;
/// Returns up to `limit` objects ordered newest-first, published before `before`.
/// Returns (ap_id, object_json, published_at).
async fn get_local_objects_page(
&self,
user_id: uuid::Uuid,
before: Option<DateTime<Utc>>,
limit: usize,
) -> anyhow::Result<Vec<(Url, serde_json::Value, DateTime<Utc>)>>;
/// Incoming Create activity — persist remote content.
async fn on_create(
&self,

View File

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

View File

@@ -21,6 +21,7 @@ pub struct RemoteActor {
pub inbox_url: String,
pub shared_inbox_url: Option<String>,
pub display_name: Option<String>,
pub avatar_url: Option<String>,
}
#[derive(Debug, Clone)]
@@ -88,4 +89,12 @@ pub trait FederationRepository: Send + Sync {
remote_actor_url: &str,
status: FollowingStatus,
) -> Result<()>;
async fn add_announce(
&self,
activity_id: &str,
object_url: &str,
actor_url: &str,
announced_at: chrono::DateTime<chrono::Utc>,
) -> Result<()>;
async fn count_announces(&self, object_url: &str) -> Result<usize>;
}

View File

@@ -10,7 +10,7 @@ use axum::{Router, routing::get, routing::post};
use url::Url;
use crate::{
activities::{AcceptActivity, CreateActivity, FollowActivity, RejectActivity, UndoActivity},
activities::{AcceptActivity, CreateActivity, FollowActivity, RejectActivity, UndoActivity, UpdateActivity},
actors::{DbActor, get_local_actor},
content::ApObjectHandler,
data::FederationData,
@@ -24,6 +24,24 @@ use crate::{
webfinger::webfinger_handler,
};
fn collect_inboxes(followers: &[crate::repository::Follower]) -> Vec<Url> {
let mut seen = std::collections::HashSet::new();
let mut inboxes = Vec::new();
for f in followers {
let inbox_str = f
.actor
.shared_inbox_url
.as_deref()
.unwrap_or(&f.actor.inbox_url);
if seen.insert(inbox_str.to_string()) {
if let Ok(url) = Url::parse(inbox_str) {
inboxes.push(url);
}
}
}
inboxes
}
pub(crate) async fn send_with_retry(
sends: Vec<SendActivityTask>,
data: &activitypub_federation::config::Data<FederationData>,
@@ -150,6 +168,7 @@ impl ActivityPubService {
inbox_url: remote_actor.inbox_url.to_string(),
shared_inbox_url: None,
display_name: Some(remote_actor.username.clone()),
avatar_url: None,
};
data.federation_repo
.add_following(local_user_id, remote, &follow_id_str)
@@ -289,7 +308,11 @@ impl ActivityPubService {
);
}
self.spawn_backfill(local_user_id, remote_actor.inbox_url.clone());
let target_inbox = remote_actor
.shared_inbox_url
.clone()
.unwrap_or_else(|| remote_actor.inbox_url.clone());
self.spawn_backfill(local_user_id, target_inbox);
Ok(())
}
@@ -437,10 +460,7 @@ impl ActivityPubService {
};
let create_with_ctx = WithContext::new_default(create);
let inboxes: Vec<Url> = accepted
.iter()
.filter_map(|f| Url::parse(&f.actor.inbox_url).ok())
.collect();
let inboxes = collect_inboxes(&accepted);
let sends =
SendActivityTask::prepare(&create_with_ctx, &local_actor, inboxes, &data).await?;
@@ -455,6 +475,57 @@ impl ActivityPubService {
Ok(())
}
pub async fn broadcast_actor_update(&self, user_id: uuid::Uuid) -> anyhow::Result<()> {
use activitypub_federation::traits::Object;
let data = self.federation_config.to_request_data();
let local_actor = get_local_actor(user_id, &data)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
let person = local_actor.clone().into_json(&data).await
.map_err(|e| anyhow::anyhow!("{e}"))?;
let person_json = serde_json::to_value(&person)?;
let update_id = Url::parse(&format!(
"{}/activities/update/{}",
self.base_url,
uuid::Uuid::new_v4()
))?;
let update = UpdateActivity {
id: update_id,
kind: Default::default(),
actor: ObjectId::from(local_actor.ap_id.clone()),
object: person_json,
};
let followers = data.federation_repo.get_followers(user_id).await?;
let accepted: Vec<_> = followers
.into_iter()
.filter(|f| f.status == FollowerStatus::Accepted)
.collect();
if accepted.is_empty() {
return Ok(());
}
let inboxes = collect_inboxes(&accepted);
let sends = SendActivityTask::prepare(
&WithContext::new_default(update),
&local_actor,
inboxes,
&data,
)
.await?;
let failures = send_with_retry(sends, &data).await;
if !failures.is_empty() {
tracing::warn!(count = failures.len(), "actor update delivery failures");
}
Ok(())
}
async fn follow_local(
&self,
local_user_id: uuid::Uuid,
@@ -493,6 +564,7 @@ impl ActivityPubService {
inbox_url: target_inbox_url,
shared_inbox_url: None,
display_name: Some(target.username),
avatar_url: None,
};
data.federation_repo
.add_following(local_user_id, target_as_remote, &follow_id)
@@ -618,3 +690,47 @@ impl ActivityPubService {
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::repository::{Follower, FollowerStatus, RemoteActor};
fn make_follower(inbox: &str, shared: Option<&str>) -> Follower {
Follower {
actor: RemoteActor {
url: format!("https://remote/{}", inbox),
handle: "user".to_string(),
inbox_url: inbox.to_string(),
shared_inbox_url: shared.map(|s| s.to_string()),
display_name: None,
avatar_url: None,
},
status: FollowerStatus::Accepted,
}
}
#[test]
fn collect_inboxes_deduplicates_shared() {
let followers = vec![
make_follower("https://mastodon.social/users/a/inbox", Some("https://mastodon.social/inbox")),
make_follower("https://mastodon.social/users/b/inbox", Some("https://mastodon.social/inbox")),
make_follower("https://other.instance/users/c/inbox", None),
];
let inboxes = collect_inboxes(&followers);
assert_eq!(inboxes.len(), 2);
let strs: Vec<_> = inboxes.iter().map(|u| u.as_str()).collect();
assert!(strs.contains(&"https://mastodon.social/inbox"));
assert!(strs.contains(&"https://other.instance/users/c/inbox"));
}
#[test]
fn collect_inboxes_falls_back_to_individual_inbox() {
let followers = vec![
make_follower("https://example.com/users/x/inbox", None),
];
let inboxes = collect_inboxes(&followers);
assert_eq!(inboxes.len(), 1);
assert_eq!(inboxes[0].as_str(), "https://example.com/users/x/inbox");
}
}

View File

@@ -4,6 +4,8 @@ use async_trait::async_trait;
pub struct ApUser {
pub id: uuid::Uuid,
pub username: String,
pub bio: Option<String>,
pub avatar_path: Option<String>,
}
#[async_trait]

View File

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

View File

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

View File

@@ -13,6 +13,8 @@ impl ApUserRepository for DomainUserRepoAdapter {
Ok(self.0.find_by_id(&user_id).await?.map(|u| ApUser {
id: u.id().value(),
username: u.username().value().to_string(),
bio: u.bio().map(|s| s.to_string()),
avatar_path: u.avatar_path().map(|s| s.to_string()),
}))
}
@@ -23,6 +25,8 @@ impl ApUserRepository for DomainUserRepoAdapter {
Ok(self.0.find_by_username(&uname).await?.map(|u| ApUser {
id: u.id().value(),
username: u.username().value().to_string(),
bio: u.bio().map(|s| s.to_string()),
avatar_path: u.avatar_path().map(|s| s.to_string()),
}))
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -103,7 +103,7 @@ impl FederationRepository for PostgresFederationRepository {
let uid = local_user_id.to_string();
let rows = sqlx::query(
"SELECT f.remote_actor_url, f.status,
a.handle, a.inbox_url, a.shared_inbox_url, a.display_name
a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.avatar_url
FROM ap_followers f
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
WHERE f.local_user_id = $1",
@@ -118,8 +118,9 @@ impl FederationRepository for PostgresFederationRepository {
let inbox_url: String = row.try_get("inbox_url").unwrap_or_default();
let shared_inbox_url: Option<String> = row.try_get("shared_inbox_url").ok().flatten();
let display_name: Option<String> = row.try_get("display_name").ok().flatten();
let avatar_url: Option<String> = row.try_get("avatar_url").ok().flatten();
Follower {
actor: RemoteActor { url, handle, inbox_url, shared_inbox_url, display_name },
actor: RemoteActor { url, handle, inbox_url, shared_inbox_url, display_name, avatar_url },
status: str_to_status(&status_str),
}
}).collect())
@@ -200,7 +201,7 @@ impl FederationRepository for PostgresFederationRepository {
async fn get_following(&self, local_user_id: uuid::Uuid) -> Result<Vec<RemoteActor>> {
let uid = local_user_id.to_string();
let rows = sqlx::query(
"SELECT a.url, a.handle, a.inbox_url, a.shared_inbox_url, a.display_name
"SELECT a.url, a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.avatar_url
FROM ap_following f
INNER JOIN ap_remote_actors a ON a.url = f.remote_actor_url
WHERE f.local_user_id = $1 AND f.status = 'accepted'",
@@ -214,6 +215,7 @@ impl FederationRepository for PostgresFederationRepository {
inbox_url: row.get("inbox_url"),
shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(),
display_name: row.try_get("display_name").ok().flatten(),
avatar_url: row.try_get("avatar_url").ok().flatten(),
}).collect())
}
@@ -232,13 +234,14 @@ impl FederationRepository for PostgresFederationRepository {
let now = Utc::now().naive_utc();
let fetched_at = datetime_to_str(&now);
sqlx::query(
"INSERT INTO ap_remote_actors (url, handle, inbox_url, shared_inbox_url, display_name, fetched_at)
VALUES ($1, $2, $3, $4, $5, $6::timestamptz)
"INSERT INTO ap_remote_actors (url, handle, inbox_url, shared_inbox_url, display_name, avatar_url, fetched_at)
VALUES ($1, $2, $3, $4, $5, $6, $7::timestamptz)
ON CONFLICT(url) DO UPDATE SET
handle = EXCLUDED.handle,
inbox_url = EXCLUDED.inbox_url,
shared_inbox_url = EXCLUDED.shared_inbox_url,
display_name = EXCLUDED.display_name,
avatar_url = EXCLUDED.avatar_url,
fetched_at = EXCLUDED.fetched_at",
)
.bind(&actor.url)
@@ -246,6 +249,7 @@ impl FederationRepository for PostgresFederationRepository {
.bind(&actor.inbox_url)
.bind(&actor.shared_inbox_url)
.bind(&actor.display_name)
.bind(&actor.avatar_url)
.bind(&fetched_at)
.execute(&self.pool)
.await?;
@@ -254,7 +258,7 @@ impl FederationRepository for PostgresFederationRepository {
async fn get_remote_actor(&self, actor_url: &str) -> Result<Option<RemoteActor>> {
let row = sqlx::query(
"SELECT url, handle, inbox_url, shared_inbox_url, display_name
"SELECT url, handle, inbox_url, shared_inbox_url, display_name, avatar_url
FROM ap_remote_actors WHERE url = $1",
)
.bind(actor_url)
@@ -266,6 +270,7 @@ impl FederationRepository for PostgresFederationRepository {
inbox_url: row.get("inbox_url"),
shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(),
display_name: row.try_get("display_name").ok().flatten(),
avatar_url: row.try_get("avatar_url").ok().flatten(),
}))
}
@@ -306,7 +311,7 @@ impl FederationRepository for PostgresFederationRepository {
async fn get_pending_followers(&self, local_user_id: uuid::Uuid) -> Result<Vec<RemoteActor>> {
let uid = local_user_id.to_string();
let rows = sqlx::query(
"SELECT f.remote_actor_url, a.handle, a.inbox_url, a.shared_inbox_url, a.display_name
"SELECT f.remote_actor_url, a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.avatar_url
FROM ap_followers f
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
WHERE f.local_user_id = $1 AND f.status = 'pending'",
@@ -320,6 +325,7 @@ impl FederationRepository for PostgresFederationRepository {
inbox_url: row.try_get("inbox_url").unwrap_or_default(),
shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(),
display_name: row.try_get("display_name").ok().flatten(),
avatar_url: row.try_get("avatar_url").ok().flatten(),
}).collect())
}
@@ -347,6 +353,34 @@ impl FederationRepository for PostgresFederationRepository {
}
Ok(())
}
async fn add_announce(
&self,
activity_id: &str,
object_url: &str,
actor_url: &str,
announced_at: chrono::DateTime<chrono::Utc>,
) -> Result<()> {
let ts = announced_at.format("%Y-%m-%d %H:%M:%S").to_string();
sqlx::query(
"INSERT INTO ap_announces (id, object_url, actor_url, announced_at) VALUES ($1, $2, $3, $4) ON CONFLICT (id) DO NOTHING",
)
.bind(activity_id)
.bind(object_url)
.bind(actor_url)
.bind(&ts)
.execute(&self.pool)
.await?;
Ok(())
}
async fn count_announces(&self, object_url: &str) -> Result<usize> {
let row = sqlx::query("SELECT COUNT(*) as cnt FROM ap_announces WHERE object_url = $1")
.bind(object_url)
.fetch_one(&self.pool)
.await?;
Ok(row.get::<i64, _>("cnt") as usize)
}
}
#[async_trait]

View File

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

View File

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

View File

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

View File

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

View File

@@ -106,7 +106,7 @@ impl FederationRepository for SqliteFederationRepository {
let rows = sqlx::query(
"SELECT f.remote_actor_url, f.status,
a.handle, a.inbox_url, a.shared_inbox_url, a.display_name
a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.avatar_url
FROM ap_followers f
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
WHERE f.local_user_id = ?",
@@ -125,6 +125,7 @@ impl FederationRepository for SqliteFederationRepository {
let shared_inbox_url: Option<String> =
row.try_get("shared_inbox_url").ok().flatten();
let display_name: Option<String> = row.try_get("display_name").ok().flatten();
let avatar_url: Option<String> = row.try_get("avatar_url").ok().flatten();
Follower {
actor: RemoteActor {
@@ -133,6 +134,7 @@ impl FederationRepository for SqliteFederationRepository {
inbox_url,
shared_inbox_url,
display_name,
avatar_url,
},
status: str_to_status(&status_str),
}
@@ -223,7 +225,7 @@ impl FederationRepository for SqliteFederationRepository {
let uid = local_user_id.to_string();
let rows = sqlx::query(
"SELECT a.url, a.handle, a.inbox_url, a.shared_inbox_url, a.display_name
"SELECT a.url, a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.avatar_url
FROM ap_following f
INNER JOIN ap_remote_actors a ON a.url = f.remote_actor_url
WHERE f.local_user_id = ? AND f.status = 'accepted'",
@@ -240,6 +242,7 @@ impl FederationRepository for SqliteFederationRepository {
inbox_url: row.get("inbox_url"),
shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(),
display_name: row.try_get("display_name").ok().flatten(),
avatar_url: row.try_get("avatar_url").ok().flatten(),
})
.collect())
}
@@ -260,13 +263,14 @@ impl FederationRepository for SqliteFederationRepository {
let fetched_at = datetime_to_str(&now);
sqlx::query(
"INSERT INTO ap_remote_actors (url, handle, inbox_url, shared_inbox_url, display_name, fetched_at)
VALUES (?, ?, ?, ?, ?, ?)
"INSERT INTO ap_remote_actors (url, handle, inbox_url, shared_inbox_url, display_name, avatar_url, fetched_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(url) DO UPDATE SET
handle = excluded.handle,
inbox_url = excluded.inbox_url,
shared_inbox_url = excluded.shared_inbox_url,
display_name = excluded.display_name,
avatar_url = excluded.avatar_url,
fetched_at = excluded.fetched_at",
)
.bind(&actor.url)
@@ -274,6 +278,7 @@ impl FederationRepository for SqliteFederationRepository {
.bind(&actor.inbox_url)
.bind(&actor.shared_inbox_url)
.bind(&actor.display_name)
.bind(&actor.avatar_url)
.bind(&fetched_at)
.execute(&self.pool)
.await?;
@@ -283,7 +288,7 @@ impl FederationRepository for SqliteFederationRepository {
async fn get_remote_actor(&self, actor_url: &str) -> Result<Option<RemoteActor>> {
let row = sqlx::query(
"SELECT url, handle, inbox_url, shared_inbox_url, display_name
"SELECT url, handle, inbox_url, shared_inbox_url, display_name, avatar_url
FROM ap_remote_actors WHERE url = ?",
)
.bind(actor_url)
@@ -296,6 +301,7 @@ impl FederationRepository for SqliteFederationRepository {
inbox_url: row.get("inbox_url"),
shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(),
display_name: row.try_get("display_name").ok().flatten(),
avatar_url: row.try_get("avatar_url").ok().flatten(),
}))
}
@@ -344,7 +350,7 @@ impl FederationRepository for SqliteFederationRepository {
let rows = sqlx::query(
"SELECT f.remote_actor_url,
a.handle, a.inbox_url, a.shared_inbox_url, a.display_name
a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.avatar_url
FROM ap_followers f
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
WHERE f.local_user_id = ? AND f.status = 'pending'",
@@ -361,6 +367,7 @@ impl FederationRepository for SqliteFederationRepository {
inbox_url: row.try_get("inbox_url").unwrap_or_default(),
shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(),
display_name: row.try_get("display_name").ok().flatten(),
avatar_url: row.try_get("avatar_url").ok().flatten(),
})
.collect())
}
@@ -392,6 +399,35 @@ impl FederationRepository for SqliteFederationRepository {
Ok(())
}
async fn add_announce(
&self,
activity_id: &str,
object_url: &str,
actor_url: &str,
announced_at: chrono::DateTime<chrono::Utc>,
) -> Result<()> {
let ts = announced_at.format("%Y-%m-%d %H:%M:%S").to_string();
sqlx::query(
"INSERT OR IGNORE INTO ap_announces (id, object_url, actor_url, announced_at)
VALUES (?1, ?2, ?3, ?4)",
)
.bind(activity_id)
.bind(object_url)
.bind(actor_url)
.bind(&ts)
.execute(&self.pool)
.await?;
Ok(())
}
async fn count_announces(&self, object_url: &str) -> Result<usize> {
let row = sqlx::query("SELECT COUNT(*) as cnt FROM ap_announces WHERE object_url = ?1")
.bind(object_url)
.fetch_one(&self.pool)
.await?;
Ok(row.get::<i64, _>("cnt") as usize)
}
}
// --- Content-specific repository (movies-diary) ---
@@ -553,9 +589,34 @@ pub fn wire(pool: sqlx::SqlitePool) -> (
#[cfg(test)]
mod tests {
use super::*;
use chrono::Utc;
use domain::ports::SocialQueryPort;
use sqlx::SqlitePool;
async fn test_pool() -> SqlitePool {
let pool = SqlitePool::connect("sqlite::memory:").await.unwrap();
sqlx::query("CREATE TABLE ap_announces (id TEXT PRIMARY KEY, object_url TEXT NOT NULL, actor_url TEXT NOT NULL, announced_at TEXT NOT NULL)")
.execute(&pool).await.unwrap();
pool
}
#[tokio::test]
async fn add_announce_stores_and_counts() {
let pool = test_pool().await;
let repo = SqliteFederationRepository::new(pool);
repo.add_announce("https://remote/ann/1", "https://local/r/1", "https://remote/u/1", Utc::now()).await.unwrap();
assert_eq!(repo.count_announces("https://local/r/1").await.unwrap(), 1);
}
#[tokio::test]
async fn duplicate_announce_is_ignored() {
let pool = test_pool().await;
let repo = SqliteFederationRepository::new(pool);
repo.add_announce("https://remote/ann/1", "https://local/r/1", "https://remote/u/1", Utc::now()).await.unwrap();
repo.add_announce("https://remote/ann/1", "https://local/r/1", "https://remote/u/1", Utc::now()).await.unwrap();
assert_eq!(repo.count_announces("https://local/r/1").await.unwrap(), 1);
}
async fn setup_db(pool: &SqlitePool) {
sqlx::query(
"CREATE TABLE IF NOT EXISTS ap_remote_actors (
@@ -564,6 +625,7 @@ mod tests {
inbox_url TEXT NOT NULL,
shared_inbox_url TEXT,
display_name TEXT,
avatar_url TEXT,
fetched_at TEXT NOT NULL
)",
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -98,7 +98,7 @@ impl FederationRepository for SqliteFederationRepository {
let rows = sqlx::query(
"SELECT f.remote_actor_url, f.status,
a.handle, a.inbox_url, a.shared_inbox_url, a.display_name
a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.avatar_url
FROM ap_followers f
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
WHERE f.local_user_id = ?",
@@ -116,9 +116,10 @@ impl FederationRepository for SqliteFederationRepository {
let inbox_url: String = row.try_get("inbox_url").unwrap_or_default();
let shared_inbox_url: Option<String> = row.try_get("shared_inbox_url").ok().flatten();
let display_name: Option<String> = row.try_get("display_name").ok().flatten();
let avatar_url: Option<String> = row.try_get("avatar_url").ok().flatten();
Follower {
actor: RemoteActor { url, handle, inbox_url, shared_inbox_url, display_name },
actor: RemoteActor { url, handle, inbox_url, shared_inbox_url, display_name, avatar_url },
status: str_to_status(&status_str),
}
})
@@ -199,7 +200,7 @@ impl FederationRepository for SqliteFederationRepository {
let uid = local_user_id.to_string();
let rows = sqlx::query(
"SELECT a.url, a.handle, a.inbox_url, a.shared_inbox_url, a.display_name
"SELECT a.url, a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.avatar_url
FROM ap_following f
INNER JOIN ap_remote_actors a ON a.url = f.remote_actor_url
WHERE f.local_user_id = ? AND f.status = 'accepted'",
@@ -214,6 +215,7 @@ impl FederationRepository for SqliteFederationRepository {
inbox_url: row.get("inbox_url"),
shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(),
display_name: row.try_get("display_name").ok().flatten(),
avatar_url: row.try_get("avatar_url").ok().flatten(),
}).collect())
}
@@ -233,13 +235,14 @@ impl FederationRepository for SqliteFederationRepository {
let fetched_at = datetime_to_str(&now);
sqlx::query(
"INSERT INTO ap_remote_actors (url, handle, inbox_url, shared_inbox_url, display_name, fetched_at)
VALUES (?, ?, ?, ?, ?, ?)
"INSERT INTO ap_remote_actors (url, handle, inbox_url, shared_inbox_url, display_name, avatar_url, fetched_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(url) DO UPDATE SET
handle = excluded.handle,
inbox_url = excluded.inbox_url,
shared_inbox_url = excluded.shared_inbox_url,
display_name = excluded.display_name,
avatar_url = excluded.avatar_url,
fetched_at = excluded.fetched_at",
)
.bind(&actor.url)
@@ -247,6 +250,7 @@ impl FederationRepository for SqliteFederationRepository {
.bind(&actor.inbox_url)
.bind(&actor.shared_inbox_url)
.bind(&actor.display_name)
.bind(&actor.avatar_url)
.bind(&fetched_at)
.execute(&self.pool)
.await?;
@@ -256,7 +260,7 @@ impl FederationRepository for SqliteFederationRepository {
async fn get_remote_actor(&self, actor_url: &str) -> Result<Option<RemoteActor>> {
let row = sqlx::query(
"SELECT url, handle, inbox_url, shared_inbox_url, display_name
"SELECT url, handle, inbox_url, shared_inbox_url, display_name, avatar_url
FROM ap_remote_actors WHERE url = ?",
)
.bind(actor_url)
@@ -269,6 +273,7 @@ impl FederationRepository for SqliteFederationRepository {
inbox_url: row.get("inbox_url"),
shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(),
display_name: row.try_get("display_name").ok().flatten(),
avatar_url: row.try_get("avatar_url").ok().flatten(),
}))
}
@@ -308,7 +313,7 @@ impl FederationRepository for SqliteFederationRepository {
let rows = sqlx::query(
"SELECT f.remote_actor_url,
a.handle, a.inbox_url, a.shared_inbox_url, a.display_name
a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.avatar_url
FROM ap_followers f
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
WHERE f.local_user_id = ? AND f.status = 'pending'",
@@ -323,6 +328,7 @@ impl FederationRepository for SqliteFederationRepository {
inbox_url: row.try_get("inbox_url").unwrap_or_default(),
shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(),
display_name: row.try_get("display_name").ok().flatten(),
avatar_url: row.try_get("avatar_url").ok().flatten(),
}).collect())
}
@@ -353,6 +359,35 @@ impl FederationRepository for SqliteFederationRepository {
Ok(())
}
async fn add_announce(
&self,
activity_id: &str,
object_url: &str,
actor_url: &str,
announced_at: chrono::DateTime<chrono::Utc>,
) -> Result<()> {
let ts = announced_at.format("%Y-%m-%d %H:%M:%S").to_string();
sqlx::query(
"INSERT OR IGNORE INTO ap_announces (id, object_url, actor_url, announced_at)
VALUES (?1, ?2, ?3, ?4)",
)
.bind(activity_id)
.bind(object_url)
.bind(actor_url)
.bind(&ts)
.execute(&self.pool)
.await?;
Ok(())
}
async fn count_announces(&self, object_url: &str) -> Result<usize> {
let row = sqlx::query("SELECT COUNT(*) as cnt FROM ap_announces WHERE object_url = ?1")
.bind(object_url)
.fetch_one(&self.pool)
.await?;
Ok(row.get::<i64, _>("cnt") as usize)
}
}
// --- Content-specific repository (movies-diary) ---

View File

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

View File

@@ -2,7 +2,7 @@ use application::ports::{
ActivityFeedPageData, FollowersPageData, FollowingPageData, HtmlPageContext, HtmlRenderer,
ImportMappingPageData, ImportPreviewPageData, ImportPreviewRow, ImportProfileView,
ImportRowStatus, ImportUploadPageData, LoginPageData, MovieDetailPageData, NewReviewPageData,
ProfilePageData, RegisterPageData, UsersPageData,
ProfilePageData, ProfileSettingsPageData, RegisterPageData, UsersPageData,
};
use askama::Template;
use chrono::Datelike;
@@ -305,6 +305,15 @@ fn bar_height_px(avg_rating: f64) -> i64 {
(avg_rating / 5.0 * 60.0) as i64
}
#[derive(Template)]
#[template(path = "profile_settings.html")]
struct ProfileSettingsTemplate<'a> {
ctx: &'a HtmlPageContext,
bio: Option<&'a str>,
avatar_url: Option<&'a str>,
saved: bool,
}
#[derive(Template)]
#[template(path = "import_upload.html")]
struct ImportUploadTemplate<'a> {
@@ -649,4 +658,18 @@ impl HtmlRenderer for AskamaHtmlRenderer {
.render()
.map_err(|e| e.to_string())
}
fn render_profile_settings_page(
&self,
data: ProfileSettingsPageData,
) -> Result<String, String> {
ProfileSettingsTemplate {
ctx: &data.ctx,
bio: data.bio.as_deref(),
avatar_url: data.avatar_url.as_deref(),
saved: data.saved,
}
.render()
.map_err(|e| e.to_string())
}
}

View File

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