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

@@ -1,24 +1,46 @@
use async_trait::async_trait;
use domain::{
entities::{
Album, Asset, AssetMetadata, AssetTag, DuplicateGroup, DuplicateStatus, Group,
IngestSession, InviteCode, Job, JobBatch, JobStatus, LibraryPath, MetadataSource, Plugin,
ProcessingPipeline, QuotaDefinition, Role, ShareLink, ShareScope, ShareTarget,
SidecarRecord, StorageVolume, SyncStatus, Tag, UsageLedgerEntry, UsageType, User,
Album, Asset, AssetFilters, AssetMetadata, AssetTag, DuplicateGroup, DuplicateStatus,
Group, IngestSession, InviteCode, Job, JobBatch, JobStatus, LibraryPath, MetadataSource,
Plugin, ProcessingPipeline, QuotaDefinition, RefreshToken, Role, ShareLink, ShareScope,
ShareTarget, SidecarRecord, StorageVolume, SyncStatus, Tag, UsageLedgerEntry, UsageType,
User,
},
errors::DomainError,
ports::{
AlbumRepository, AssetMetadataRepository, AssetRepository, DuplicateRepository,
GroupRepository, IngestSessionRepository, IngestTransaction, JobBatchRepository,
JobRepository, LibraryPathRepository, PipelineRepository, PluginRepository,
QuotaRepository, RoleRepository, ShareRepository, SidecarRepository,
StorageVolumeRepository, TagRepository, UsageLedgerRepository, UserRepository,
QuotaRepository, RefreshTokenRepository, RoleRepository, ShareRepository,
SidecarRepository, StorageVolumeRepository, TagRepository, UsageLedgerRepository,
UserRepository,
},
value_objects::{Checksum, DateTimeStamp, Email, SystemId},
};
use std::collections::HashMap;
use tokio::sync::Mutex;
macro_rules! in_memory_repo {
($name:ident, $entity:ty) => {
pub struct $name {
data: Mutex<HashMap<String, $entity>>,
}
impl $name {
pub fn new() -> Self {
Self {
data: Mutex::new(HashMap::new()),
}
}
}
impl Default for $name {
fn default() -> Self {
Self::new()
}
}
};
}
// --- InMemoryUserRepository ---
pub struct InMemoryUserRepository {
@@ -83,25 +105,7 @@ impl UserRepository for InMemoryUserRepository {
}
}
// --- InMemoryAssetRepository ---
pub struct InMemoryAssetRepository {
data: Mutex<HashMap<String, Asset>>,
}
impl InMemoryAssetRepository {
pub fn new() -> Self {
Self {
data: Mutex::new(HashMap::new()),
}
}
}
impl Default for InMemoryAssetRepository {
fn default() -> Self {
Self::new()
}
}
in_memory_repo!(InMemoryAssetRepository, Asset);
#[async_trait]
impl AssetRepository for InMemoryAssetRepository {
@@ -141,6 +145,16 @@ impl AssetRepository for InMemoryAssetRepository {
.collect())
}
async fn search(
&self,
owner_id: &SystemId,
_filters: &AssetFilters,
limit: u32,
offset: u32,
) -> Result<Vec<Asset>, DomainError> {
self.find_by_owner(owner_id, limit, offset).await
}
async fn save(&self, asset: &Asset) -> Result<(), DomainError> {
self.data
.lock()
@@ -155,25 +169,7 @@ impl AssetRepository for InMemoryAssetRepository {
}
}
// --- InMemoryAlbumRepository ---
pub struct InMemoryAlbumRepository {
data: Mutex<HashMap<String, Album>>,
}
impl InMemoryAlbumRepository {
pub fn new() -> Self {
Self {
data: Mutex::new(HashMap::new()),
}
}
}
impl Default for InMemoryAlbumRepository {
fn default() -> Self {
Self::new()
}
}
in_memory_repo!(InMemoryAlbumRepository, Album);
#[async_trait]
impl AlbumRepository for InMemoryAlbumRepository {
@@ -206,25 +202,7 @@ impl AlbumRepository for InMemoryAlbumRepository {
}
}
// --- InMemoryJobRepository ---
pub struct InMemoryJobRepository {
data: Mutex<HashMap<String, Job>>,
}
impl InMemoryJobRepository {
pub fn new() -> Self {
Self {
data: Mutex::new(HashMap::new()),
}
}
}
impl Default for InMemoryJobRepository {
fn default() -> Self {
Self::new()
}
}
in_memory_repo!(InMemoryJobRepository, Job);
#[async_trait]
impl JobRepository for InMemoryJobRepository {
@@ -252,6 +230,24 @@ impl JobRepository for InMemoryJobRepository {
.collect())
}
async fn find_all(
&self,
_status: Option<&str>,
limit: u32,
offset: u32,
) -> Result<Vec<Job>, DomainError> {
let all: Vec<Job> = self.data.lock().await.values().cloned().collect();
Ok(all
.into_iter()
.skip(offset as usize)
.take(limit as usize)
.collect())
}
async fn count(&self, _status: Option<&str>) -> Result<u64, DomainError> {
Ok(self.data.lock().await.len() as u64)
}
async fn save(&self, job: &Job) -> Result<(), DomainError> {
self.data
.lock()
@@ -261,25 +257,7 @@ impl JobRepository for InMemoryJobRepository {
}
}
// --- InMemoryRoleRepository ---
pub struct InMemoryRoleRepository {
data: Mutex<HashMap<String, Role>>,
}
impl InMemoryRoleRepository {
pub fn new() -> Self {
Self {
data: Mutex::new(HashMap::new()),
}
}
}
impl Default for InMemoryRoleRepository {
fn default() -> Self {
Self::new()
}
}
in_memory_repo!(InMemoryRoleRepository, Role);
#[async_trait]
impl RoleRepository for InMemoryRoleRepository {
@@ -322,25 +300,7 @@ impl RoleRepository for InMemoryRoleRepository {
}
}
// --- InMemoryGroupRepository ---
pub struct InMemoryGroupRepository {
data: Mutex<HashMap<String, Group>>,
}
impl InMemoryGroupRepository {
pub fn new() -> Self {
Self {
data: Mutex::new(HashMap::new()),
}
}
}
impl Default for InMemoryGroupRepository {
fn default() -> Self {
Self::new()
}
}
in_memory_repo!(InMemoryGroupRepository, Group);
#[async_trait]
impl GroupRepository for InMemoryGroupRepository {
@@ -373,25 +333,7 @@ impl GroupRepository for InMemoryGroupRepository {
}
}
// --- InMemoryStorageVolumeRepository ---
pub struct InMemoryStorageVolumeRepository {
data: Mutex<HashMap<String, StorageVolume>>,
}
impl InMemoryStorageVolumeRepository {
pub fn new() -> Self {
Self {
data: Mutex::new(HashMap::new()),
}
}
}
impl Default for InMemoryStorageVolumeRepository {
fn default() -> Self {
Self::new()
}
}
in_memory_repo!(InMemoryStorageVolumeRepository, StorageVolume);
#[async_trait]
impl StorageVolumeRepository for InMemoryStorageVolumeRepository {
@@ -417,25 +359,7 @@ impl StorageVolumeRepository for InMemoryStorageVolumeRepository {
}
}
// --- InMemoryLibraryPathRepository ---
pub struct InMemoryLibraryPathRepository {
data: Mutex<HashMap<String, LibraryPath>>,
}
impl InMemoryLibraryPathRepository {
pub fn new() -> Self {
Self {
data: Mutex::new(HashMap::new()),
}
}
}
impl Default for InMemoryLibraryPathRepository {
fn default() -> Self {
Self::new()
}
}
in_memory_repo!(InMemoryLibraryPathRepository, LibraryPath);
#[async_trait]
impl LibraryPathRepository for InMemoryLibraryPathRepository {
@@ -482,25 +406,7 @@ impl LibraryPathRepository for InMemoryLibraryPathRepository {
}
}
// --- InMemoryIngestSessionRepository ---
pub struct InMemoryIngestSessionRepository {
data: Mutex<HashMap<String, IngestSession>>,
}
impl InMemoryIngestSessionRepository {
pub fn new() -> Self {
Self {
data: Mutex::new(HashMap::new()),
}
}
}
impl Default for InMemoryIngestSessionRepository {
fn default() -> Self {
Self::new()
}
}
in_memory_repo!(InMemoryIngestSessionRepository, IngestSession);
#[async_trait]
impl IngestSessionRepository for InMemoryIngestSessionRepository {
@@ -528,25 +434,7 @@ impl IngestSessionRepository for InMemoryIngestSessionRepository {
}
}
// --- InMemoryQuotaRepository ---
pub struct InMemoryQuotaRepository {
data: Mutex<HashMap<String, QuotaDefinition>>,
}
impl InMemoryQuotaRepository {
pub fn new() -> Self {
Self {
data: Mutex::new(HashMap::new()),
}
}
}
impl Default for InMemoryQuotaRepository {
fn default() -> Self {
Self::new()
}
}
in_memory_repo!(InMemoryQuotaRepository, QuotaDefinition);
#[async_trait]
impl QuotaRepository for InMemoryQuotaRepository {
@@ -889,25 +777,7 @@ impl TagRepository for InMemoryTagRepository {
}
}
// --- InMemoryDuplicateRepository ---
pub struct InMemoryDuplicateRepository {
data: Mutex<HashMap<String, DuplicateGroup>>,
}
impl InMemoryDuplicateRepository {
pub fn new() -> Self {
Self {
data: Mutex::new(HashMap::new()),
}
}
}
impl Default for InMemoryDuplicateRepository {
fn default() -> Self {
Self::new()
}
}
in_memory_repo!(InMemoryDuplicateRepository, DuplicateGroup);
#[async_trait]
impl DuplicateRepository for InMemoryDuplicateRepository {
@@ -946,25 +816,7 @@ impl DuplicateRepository for InMemoryDuplicateRepository {
}
}
// --- InMemorySidecarRepository ---
pub struct InMemorySidecarRepository {
data: Mutex<HashMap<String, SidecarRecord>>,
}
impl InMemorySidecarRepository {
pub fn new() -> Self {
Self {
data: Mutex::new(HashMap::new()),
}
}
}
impl Default for InMemorySidecarRepository {
fn default() -> Self {
Self::new()
}
}
in_memory_repo!(InMemorySidecarRepository, SidecarRecord);
#[async_trait]
impl SidecarRepository for InMemorySidecarRepository {
@@ -1000,25 +852,7 @@ impl SidecarRepository for InMemorySidecarRepository {
}
}
// --- InMemoryJobBatchRepository ---
pub struct InMemoryJobBatchRepository {
data: Mutex<HashMap<String, JobBatch>>,
}
impl InMemoryJobBatchRepository {
pub fn new() -> Self {
Self {
data: Mutex::new(HashMap::new()),
}
}
}
impl Default for InMemoryJobBatchRepository {
fn default() -> Self {
Self::new()
}
}
in_memory_repo!(InMemoryJobBatchRepository, JobBatch);
#[async_trait]
impl JobBatchRepository for InMemoryJobBatchRepository {
@@ -1035,25 +869,7 @@ impl JobBatchRepository for InMemoryJobBatchRepository {
}
}
// --- InMemoryPluginRepository ---
pub struct InMemoryPluginRepository {
data: Mutex<HashMap<String, Plugin>>,
}
impl InMemoryPluginRepository {
pub fn new() -> Self {
Self {
data: Mutex::new(HashMap::new()),
}
}
}
impl Default for InMemoryPluginRepository {
fn default() -> Self {
Self::new()
}
}
in_memory_repo!(InMemoryPluginRepository, Plugin);
#[async_trait]
impl PluginRepository for InMemoryPluginRepository {
@@ -1081,25 +897,7 @@ impl PluginRepository for InMemoryPluginRepository {
}
}
// --- InMemoryPipelineRepository ---
pub struct InMemoryPipelineRepository {
data: Mutex<HashMap<String, ProcessingPipeline>>,
}
impl InMemoryPipelineRepository {
pub fn new() -> Self {
Self {
data: Mutex::new(HashMap::new()),
}
}
}
impl Default for InMemoryPipelineRepository {
fn default() -> Self {
Self::new()
}
}
in_memory_repo!(InMemoryPipelineRepository, ProcessingPipeline);
#[async_trait]
impl PipelineRepository for InMemoryPipelineRepository {
@@ -1216,3 +1014,36 @@ impl IngestTransaction for InMemoryIngestTransaction {
Ok(())
}
}
in_memory_repo!(InMemoryRefreshTokenRepository, RefreshToken);
#[async_trait]
impl RefreshTokenRepository for InMemoryRefreshTokenRepository {
async fn save(&self, token: &RefreshToken) -> Result<(), DomainError> {
self.data
.lock()
.await
.insert(token.token_id.to_string(), token.clone());
Ok(())
}
async fn find_by_hash(&self, token_hash: &str) -> Result<Option<RefreshToken>, DomainError> {
Ok(self
.data
.lock()
.await
.values()
.find(|t| t.token_hash == token_hash)
.cloned())
}
async fn delete_by_user(&self, user_id: &SystemId) -> Result<(), DomainError> {
self.data.lock().await.retain(|_, t| &t.user_id != user_id);
Ok(())
}
async fn delete(&self, id: &SystemId) -> Result<(), DomainError> {
self.data.lock().await.remove(&id.to_string());
Ok(())
}
}