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

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

View File

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

View File

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