feat: auth hardening + codebase quality sweep

Refresh tokens: RefreshToken entity, PostgresRefreshTokenRepository,
login returns refresh token, POST /auth/refresh (rotation), POST /auth/logout,
JWT expiry 24h→1h, configurable via with_expiry().

Route protection: require_auth middleware on protected routes,
public routes split (register, login, refresh, sharing/access).

Authorization: caller_id added to ReadAssetFileQuery, ReadDerivativeQuery,
GetStackQuery, DeleteStackCommand with ownership checks. Admin-only gates
on processing, storage, sidecar, duplicates handlers.

Quality fixes: visibility filtering bypass in search(), unwrap panics in
date parsing, DRY auth header parsing, centralized parsers module,
email validation via email_address crate, value objects (Username, MimeType,
RelativePath), domain events (UserCreated, UserDeleted, AlbumCreated,
TagCreated, DuplicateDetected), postgres error mapping for constraint
violations, OptionExt::or_not_found helper, in_memory_repo! macro,
GetStackQuery moved to queries, album add_entry 200→201.
This commit is contained in:
2026-05-31 22:26:02 +02:00
parent 84fb410316
commit c6f82090d2
71 changed files with 2311 additions and 563 deletions

View File

@@ -0,0 +1,81 @@
use std::sync::Arc;
use domain::{
entities::{AssetStack, StackMemberRole, StackType},
errors::DomainError,
ports::{AssetRepository, AssetStackRepository},
value_objects::SystemId,
};
pub struct CreateStackCommand {
pub stack_type: StackType,
pub primary_asset_id: SystemId,
pub additional_asset_ids: Vec<(SystemId, StackMemberRole)>,
pub owner_id: SystemId,
}
pub struct CreateStackHandler {
asset_repo: Arc<dyn AssetRepository>,
stack_repo: Arc<dyn AssetStackRepository>,
}
impl CreateStackHandler {
pub fn new(
asset_repo: Arc<dyn AssetRepository>,
stack_repo: Arc<dyn AssetStackRepository>,
) -> Self {
Self {
asset_repo,
stack_repo,
}
}
pub async fn execute(&self, cmd: CreateStackCommand) -> Result<AssetStack, DomainError> {
self.asset_repo
.find_by_id(&cmd.primary_asset_id)
.await?
.ok_or_else(|| DomainError::NotFound("Primary asset not found".into()))?;
let mut stack = AssetStack::new(cmd.stack_type, cmd.primary_asset_id, cmd.owner_id);
for (asset_id, role) in cmd.additional_asset_ids {
self.asset_repo
.find_by_id(&asset_id)
.await?
.ok_or_else(|| {
DomainError::NotFound(format!("Asset {} not found", asset_id.as_uuid()))
})?;
stack.add_member(asset_id, role)?;
}
self.stack_repo.save(&stack).await?;
Ok(stack)
}
}
pub struct DeleteStackCommand {
pub stack_id: SystemId,
pub caller_id: SystemId,
}
pub struct DeleteStackHandler {
stack_repo: Arc<dyn AssetStackRepository>,
}
impl DeleteStackHandler {
pub fn new(stack_repo: Arc<dyn AssetStackRepository>) -> Self {
Self { stack_repo }
}
pub async fn execute(&self, cmd: DeleteStackCommand) -> Result<(), DomainError> {
let stack = self
.stack_repo
.find_by_id(&cmd.stack_id)
.await?
.ok_or_else(|| DomainError::NotFound("Stack not found".into()))?;
if stack.owner_user_id != cmd.caller_id {
return Err(DomainError::Forbidden("Not your stack".into()));
}
self.stack_repo.delete(&cmd.stack_id).await
}
}

View File

@@ -0,0 +1,84 @@
use std::sync::Arc;
use domain::{
errors::DomainError,
events::DomainEvent,
ports::{
AssetRepository, DerivativeRepository, EventPublisher, FileStoragePort, SidecarRepository,
},
value_objects::{DateTimeStamp, SystemId},
};
pub struct DeleteAssetCommand {
pub asset_id: SystemId,
pub deleted_by: SystemId,
}
pub struct DeleteAssetHandler {
asset_repo: Arc<dyn AssetRepository>,
derivative_repo: Arc<dyn DerivativeRepository>,
sidecar_repo: Arc<dyn SidecarRepository>,
file_storage: Arc<dyn FileStoragePort>,
event_publisher: Arc<dyn EventPublisher>,
}
impl DeleteAssetHandler {
pub fn new(
asset_repo: Arc<dyn AssetRepository>,
derivative_repo: Arc<dyn DerivativeRepository>,
sidecar_repo: Arc<dyn SidecarRepository>,
file_storage: Arc<dyn FileStoragePort>,
event_publisher: Arc<dyn EventPublisher>,
) -> Self {
Self {
asset_repo,
derivative_repo,
sidecar_repo,
file_storage,
event_publisher,
}
}
pub async fn execute(&self, cmd: DeleteAssetCommand) -> Result<(), DomainError> {
let asset = self
.asset_repo
.find_by_id(&cmd.asset_id)
.await?
.ok_or_else(|| DomainError::NotFound("Asset not found".into()))?;
// Delete derivative files + DB records
let derivatives = self.derivative_repo.find_by_asset(&cmd.asset_id).await?;
for d in &derivatives {
let _ = self.file_storage.delete_file(&d.storage_path).await;
self.derivative_repo.delete(&d.derivative_id).await?;
}
// Delete sidecar file + DB record
if let Some(sidecar) = self.sidecar_repo.find_by_asset(&cmd.asset_id).await? {
let _ = self
.file_storage
.delete_file(&sidecar.sidecar_storage_path)
.await;
self.sidecar_repo.delete(&cmd.asset_id).await?;
}
// Delete asset file
let _ = self
.file_storage
.delete_file(&asset.source_reference.relative_path)
.await;
// Delete asset DB record
self.asset_repo.delete(&cmd.asset_id).await?;
self.event_publisher
.publish(&DomainEvent::AssetDeleted {
asset_id: cmd.asset_id,
deleted_by: cmd.deleted_by,
timestamp: DateTimeStamp::now(),
})
.await?;
Ok(())
}
}

View File

@@ -0,0 +1,87 @@
use std::collections::HashMap;
use std::sync::Arc;
use domain::{
entities::{AssetStack, AssetType, StackMemberRole, StackType},
errors::DomainError,
ports::{AssetRepository, AssetStackRepository},
value_objects::SystemId,
};
pub struct DetectLivePhotosCommand {
pub owner_id: SystemId,
}
pub struct DetectLivePhotosHandler {
asset_repo: Arc<dyn AssetRepository>,
stack_repo: Arc<dyn AssetStackRepository>,
}
impl DetectLivePhotosHandler {
pub fn new(
asset_repo: Arc<dyn AssetRepository>,
stack_repo: Arc<dyn AssetStackRepository>,
) -> Self {
Self {
asset_repo,
stack_repo,
}
}
pub async fn execute(
&self,
cmd: DetectLivePhotosCommand,
) -> Result<Vec<AssetStack>, DomainError> {
let assets = self
.asset_repo
.find_by_owner(&cmd.owner_id, 10_000, 0)
.await?;
let mut by_basename: HashMap<String, Vec<(SystemId, AssetType, String)>> = HashMap::new();
for asset in &assets {
let path = &asset.source_reference.relative_path;
if let Some(stem) = std::path::Path::new(path)
.file_stem()
.and_then(|s| s.to_str())
{
let key = stem.to_lowercase();
by_basename.entry(key).or_default().push((
asset.asset_id,
asset.asset_type,
asset.mime_type.clone(),
));
}
}
let mut created = Vec::new();
for group in by_basename.values() {
if group.len() < 2 {
continue;
}
let image = group.iter().find(|(_, t, _)| *t == AssetType::Image);
let video = group
.iter()
.find(|(_, t, m)| *t == AssetType::Video || m.starts_with("video/"));
if let (Some((img_id, _, _)), Some((vid_id, _, _))) = (image, video) {
let existing = self.stack_repo.find_by_asset(img_id).await?;
if existing
.iter()
.any(|s| s.stack_type == StackType::LivePhoto)
{
continue;
}
let mut stack = AssetStack::new(StackType::LivePhoto, *img_id, cmd.owner_id);
stack.add_member(*vid_id, StackMemberRole::MotionClip)?;
self.stack_repo.save(&stack).await?;
created.push(stack);
}
}
Ok(created)
}
}

View File

@@ -1,2 +1,6 @@
pub mod create_stack;
pub mod delete_asset;
pub mod detect_live_photos;
pub mod register_asset;
pub mod resolve_duplicate;
pub mod update_metadata;

View File

@@ -0,0 +1,86 @@
use std::sync::Arc;
use domain::{errors::DomainError, ports::DuplicateRepository, value_objects::SystemId};
use super::delete_asset::{DeleteAssetCommand, DeleteAssetHandler};
pub struct ResolveDuplicateCommand {
pub group_id: SystemId,
pub keep_asset_id: SystemId,
pub resolved_by: SystemId,
}
pub struct ResolveDuplicateHandler {
duplicate_repo: Arc<dyn DuplicateRepository>,
delete_handler: Arc<DeleteAssetHandler>,
}
impl ResolveDuplicateHandler {
pub fn new(
duplicate_repo: Arc<dyn DuplicateRepository>,
delete_handler: Arc<DeleteAssetHandler>,
) -> Self {
Self {
duplicate_repo,
delete_handler,
}
}
pub async fn execute(&self, cmd: ResolveDuplicateCommand) -> Result<(), DomainError> {
let mut group = self
.duplicate_repo
.find_by_id(&cmd.group_id)
.await?
.ok_or_else(|| DomainError::NotFound("Duplicate group not found".into()))?;
if !group
.candidates
.iter()
.any(|c| c.asset_id == cmd.keep_asset_id)
{
return Err(DomainError::Validation(
"keep_asset_id not in duplicate group".into(),
));
}
let to_delete: Vec<SystemId> = group
.candidates
.iter()
.filter(|c| c.asset_id != cmd.keep_asset_id)
.map(|c| c.asset_id)
.collect();
for asset_id in to_delete {
self.delete_handler
.execute(DeleteAssetCommand {
asset_id,
deleted_by: cmd.resolved_by,
})
.await?;
}
group.resolve();
self.duplicate_repo.save(&group).await?;
Ok(())
}
}
pub struct ListDuplicatesQuery;
pub struct ListDuplicatesHandler {
duplicate_repo: Arc<dyn DuplicateRepository>,
}
impl ListDuplicatesHandler {
pub fn new(duplicate_repo: Arc<dyn DuplicateRepository>) -> Self {
Self { duplicate_repo }
}
pub async fn execute(
&self,
_query: ListDuplicatesQuery,
) -> Result<Vec<domain::entities::DuplicateGroup>, DomainError> {
self.duplicate_repo.find_unresolved().await
}
}

View File

@@ -2,12 +2,22 @@ pub mod commands;
pub mod queries;
pub mod visibility;
pub use commands::create_stack::{
CreateStackCommand, CreateStackHandler, DeleteStackCommand, DeleteStackHandler,
};
pub use commands::delete_asset::{DeleteAssetCommand, DeleteAssetHandler};
pub use commands::detect_live_photos::{DetectLivePhotosCommand, DetectLivePhotosHandler};
pub use commands::register_asset::{RegisterAssetCommand, RegisterAssetHandler};
pub use commands::resolve_duplicate::{
ListDuplicatesHandler, ListDuplicatesQuery, ResolveDuplicateCommand, ResolveDuplicateHandler,
};
pub use commands::update_metadata::{UpdateMetadataCommand, UpdateMetadataHandler};
pub use queries::get_asset::{GetAssetHandler, GetAssetQuery};
pub use queries::get_stack::{GetStackHandler, GetStackQuery};
pub use queries::get_timeline::{GetTimelineHandler, GetTimelineQuery};
pub use queries::read_asset_file::{AssetFileResult, ReadAssetFileHandler, ReadAssetFileQuery};
pub use queries::read_derivative::{
DerivativeFileResult, ReadDerivativeHandler, ReadDerivativeQuery,
};
pub use queries::search_assets::{SearchAssetsHandler, SearchAssetsQuery};
pub use visibility::VisibilityFilteredAssetRepository;

View File

@@ -0,0 +1,31 @@
use domain::{
entities::AssetStack, errors::DomainError, ports::AssetStackRepository, value_objects::SystemId,
};
use std::sync::Arc;
pub struct GetStackQuery {
pub stack_id: SystemId,
pub caller_id: SystemId,
}
pub struct GetStackHandler {
stack_repo: Arc<dyn AssetStackRepository>,
}
impl GetStackHandler {
pub fn new(stack_repo: Arc<dyn AssetStackRepository>) -> Self {
Self { stack_repo }
}
pub async fn execute(&self, query: GetStackQuery) -> Result<AssetStack, DomainError> {
let stack = self
.stack_repo
.find_by_id(&query.stack_id)
.await?
.ok_or_else(|| DomainError::NotFound("Stack not found".into()))?;
if stack.owner_user_id != query.caller_id {
return Err(DomainError::Forbidden("Not your stack".into()));
}
Ok(stack)
}
}

View File

@@ -1,4 +1,6 @@
pub mod get_asset;
pub mod get_stack;
pub mod get_timeline;
pub mod read_asset_file;
pub mod read_derivative;
pub mod search_assets;

View File

@@ -9,6 +9,7 @@ use std::sync::Arc;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ReadAssetFileQuery {
pub asset_id: SystemId,
pub caller_id: SystemId,
}
pub struct AssetFileResult {
@@ -40,6 +41,10 @@ impl ReadAssetFileHandler {
.await?
.ok_or_else(|| DomainError::NotFound(format!("Asset {} not found", query.asset_id)))?;
if asset.owner_user_id != query.caller_id {
return Err(DomainError::Forbidden("Access denied".into()));
}
let data = self
.file_storage
.read_file(&asset.source_reference.relative_path)

View File

@@ -10,6 +10,7 @@ use std::sync::Arc;
pub struct ReadDerivativeQuery {
pub asset_id: SystemId,
pub profile: DerivativeProfile,
pub caller_id: SystemId,
}
pub struct DerivativeFileResult {
@@ -19,16 +20,19 @@ pub struct DerivativeFileResult {
pub struct ReadDerivativeHandler {
derivative_repo: Arc<dyn DerivativeRepository>,
asset_repo: Arc<dyn domain::ports::AssetRepository>,
file_storage: Arc<dyn FileStoragePort>,
}
impl ReadDerivativeHandler {
pub fn new(
derivative_repo: Arc<dyn DerivativeRepository>,
asset_repo: Arc<dyn domain::ports::AssetRepository>,
file_storage: Arc<dyn FileStoragePort>,
) -> Self {
Self {
derivative_repo,
asset_repo,
file_storage,
}
}
@@ -37,6 +41,15 @@ impl ReadDerivativeHandler {
&self,
query: ReadDerivativeQuery,
) -> Result<DerivativeFileResult, DomainError> {
let asset = self
.asset_repo
.find_by_id(&query.asset_id)
.await?
.ok_or_else(|| DomainError::NotFound("Asset not found".into()))?;
if asset.owner_user_id != query.caller_id {
return Err(DomainError::Forbidden("Access denied".into()));
}
let derivative = self
.derivative_repo
.find_by_asset_and_profile(&query.asset_id, query.profile)

View File

@@ -0,0 +1,31 @@
use std::sync::Arc;
use domain::{
entities::{Asset, AssetFilters},
errors::DomainError,
ports::AssetRepository,
value_objects::SystemId,
};
pub struct SearchAssetsQuery {
pub owner_id: SystemId,
pub filters: AssetFilters,
pub limit: u32,
pub offset: u32,
}
pub struct SearchAssetsHandler {
asset_repo: Arc<dyn AssetRepository>,
}
impl SearchAssetsHandler {
pub fn new(asset_repo: Arc<dyn AssetRepository>) -> Self {
Self { asset_repo }
}
pub async fn execute(&self, query: SearchAssetsQuery) -> Result<Vec<Asset>, DomainError> {
self.asset_repo
.search(&query.owner_id, &query.filters, query.limit, query.offset)
.await
}
}

View File

@@ -1,6 +1,6 @@
use async_trait::async_trait;
use domain::{
catalog::entities::Asset,
catalog::entities::{Asset, AssetFilters},
errors::DomainError,
ports::{AssetRepository, ShareRepository},
value_objects::{Checksum, SystemId},
@@ -112,6 +112,27 @@ impl AssetRepository for VisibilityFilteredAssetRepository {
Ok(visible)
}
async fn search(
&self,
owner_id: &SystemId,
filters: &AssetFilters,
limit: u32,
offset: u32,
) -> Result<Vec<Asset>, DomainError> {
if owner_id == &self.caller_id {
return self.inner.search(owner_id, filters, limit, offset).await;
}
let assets = self.inner.search(owner_id, filters, limit, offset).await?;
let mut visible = Vec::with_capacity(assets.len());
for asset in assets {
if self.caller_can_access(&asset).await? {
visible.push(asset);
}
}
Ok(visible)
}
async fn save(&self, asset: &Asset) -> Result<(), DomainError> {
self.inner.save(asset).await
}