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

@@ -3,12 +3,16 @@ use async_trait::async_trait;
use chrono::{DateTime, Utc};
use domain::{
entities::{
Asset, AssetMetadata, AssetType, DerivativeAsset, DerivativeProfile, DetectionMethod,
DuplicateCandidate, DuplicateGroup, DuplicateStatus, GenerationStatus, MetadataSource,
SourceReference,
Asset, AssetFilters, AssetMetadata, AssetStack, AssetStackMember, AssetType,
DerivativeAsset, DerivativeProfile, DetectionMethod, DuplicateCandidate, DuplicateGroup,
DuplicateStatus, GenerationStatus, MetadataSource, SourceReference, StackMemberRole,
StackType,
},
errors::DomainError,
ports::{AssetMetadataRepository, AssetRepository, DerivativeRepository, DuplicateRepository},
ports::{
AssetMetadataRepository, AssetRepository, AssetStackRepository, DerivativeRepository,
DuplicateRepository,
},
value_objects::{Checksum, DateTimeStamp, MetadataValue, StructuredData, SystemId},
};
use uuid::Uuid;
@@ -123,6 +127,75 @@ impl AssetRepository for PostgresAssetRepository {
rows.into_iter().map(TryInto::try_into).collect()
}
async fn search(
&self,
owner_id: &SystemId,
filters: &AssetFilters,
limit: u32,
offset: u32,
) -> Result<Vec<Asset>, DomainError> {
let mut sql = String::from(
"SELECT asset_id, volume_id, relative_path, checksum, asset_type, mime_type,
file_size, is_processed, owner_user_id, created_at
FROM assets WHERE owner_user_id = $1",
);
let mut param_idx = 2u32;
if filters.asset_type.is_some() {
sql.push_str(&format!(" AND asset_type = ${param_idx}"));
param_idx += 1;
}
if filters.mime_type.is_some() {
sql.push_str(&format!(" AND mime_type = ${param_idx}"));
param_idx += 1;
}
if filters.date_from.is_some() {
sql.push_str(&format!(" AND created_at >= ${param_idx}"));
param_idx += 1;
}
if filters.date_to.is_some() {
sql.push_str(&format!(" AND created_at <= ${param_idx}"));
param_idx += 1;
}
if filters.is_processed.is_some() {
sql.push_str(&format!(" AND is_processed = ${param_idx}"));
param_idx += 1;
}
sql.push_str(&format!(
" ORDER BY created_at DESC LIMIT ${} OFFSET ${}",
param_idx,
param_idx + 1
));
let mut query = sqlx::query_as::<_, AssetRow>(&sql).bind(*owner_id.as_uuid());
if let Some(ref t) = filters.asset_type {
query = query.bind(asset_type_to_str(t));
}
if let Some(ref m) = filters.mime_type {
query = query.bind(m.as_str());
}
if let Some(ref d) = filters.date_from {
query = query.bind(d.as_datetime());
}
if let Some(ref d) = filters.date_to {
query = query.bind(d.as_datetime());
}
if let Some(p) = filters.is_processed {
query = query.bind(p);
}
let rows = query
.bind(limit as i64)
.bind(offset as i64)
.fetch_all(&self.pool)
.await
.map_pg()?;
rows.into_iter().map(TryInto::try_into).collect()
}
async fn save(&self, asset: &Asset) -> Result<(), DomainError> {
sqlx::query(
"INSERT INTO assets (asset_id, volume_id, relative_path, checksum, asset_type,
@@ -597,3 +670,156 @@ impl DerivativeRepository for PostgresDerivativeRepository {
Ok(())
}
}
// ── AssetStack ──────────────────────────────────────────────────────
#[derive(sqlx::FromRow)]
struct StackRow {
stack_id: Uuid,
stack_type: String,
primary_asset_id: Uuid,
owner_user_id: Uuid,
members: serde_json::Value,
}
fn stack_type_from_str(s: &str) -> StackType {
match s {
"live_photo" => StackType::LivePhoto,
"format_pair" => StackType::FormatPair,
"burst_sequence" => StackType::BurstSequence,
"exposure_bracket" => StackType::ExposureBracket,
"manual_group" => StackType::ManualGroup,
_ => StackType::ManualGroup,
}
}
fn stack_type_to_str(t: &StackType) -> &'static str {
match t {
StackType::LivePhoto => "live_photo",
StackType::FormatPair => "format_pair",
StackType::BurstSequence => "burst_sequence",
StackType::ExposureBracket => "exposure_bracket",
StackType::ManualGroup => "manual_group",
}
}
fn member_role_from_str(s: &str) -> StackMemberRole {
match s {
"primary_display" => StackMemberRole::PrimaryDisplay,
"high_res_source" => StackMemberRole::HighResSource,
"motion_clip" => StackMemberRole::MotionClip,
"alternate_frame" => StackMemberRole::AlternateFrame,
_ => StackMemberRole::AlternateFrame,
}
}
fn member_role_to_str(r: &StackMemberRole) -> &'static str {
match r {
StackMemberRole::PrimaryDisplay => "primary_display",
StackMemberRole::HighResSource => "high_res_source",
StackMemberRole::MotionClip => "motion_clip",
StackMemberRole::AlternateFrame => "alternate_frame",
}
}
#[derive(serde::Serialize, serde::Deserialize)]
struct MemberJson {
asset_id: Uuid,
role: String,
sort_order: u32,
}
fn members_from_json(v: serde_json::Value) -> Vec<AssetStackMember> {
let arr: Vec<MemberJson> = serde_json::from_value(v).unwrap_or_default();
arr.into_iter()
.map(|m| AssetStackMember {
asset_id: SystemId::from_uuid(m.asset_id),
role: member_role_from_str(&m.role),
sort_order: m.sort_order,
})
.collect()
}
fn members_to_json(members: &[AssetStackMember]) -> serde_json::Value {
let arr: Vec<MemberJson> = members
.iter()
.map(|m| MemberJson {
asset_id: *m.asset_id.as_uuid(),
role: member_role_to_str(&m.role).to_string(),
sort_order: m.sort_order,
})
.collect();
serde_json::to_value(arr).unwrap_or(serde_json::Value::Array(vec![]))
}
impl From<StackRow> for AssetStack {
fn from(r: StackRow) -> Self {
Self {
stack_id: SystemId::from_uuid(r.stack_id),
stack_type: stack_type_from_str(&r.stack_type),
primary_asset_id: SystemId::from_uuid(r.primary_asset_id),
owner_user_id: SystemId::from_uuid(r.owner_user_id),
members: members_from_json(r.members),
}
}
}
pg_repo!(PostgresAssetStackRepository);
#[async_trait]
impl AssetStackRepository for PostgresAssetStackRepository {
async fn find_by_id(&self, id: &SystemId) -> Result<Option<AssetStack>, DomainError> {
let row = sqlx::query_as::<_, StackRow>(
"SELECT stack_id, stack_type, primary_asset_id, owner_user_id, members
FROM asset_stacks WHERE stack_id = $1",
)
.bind(*id.as_uuid())
.fetch_optional(&self.pool)
.await
.map_pg()?;
Ok(row.map(Into::into))
}
async fn find_by_asset(&self, asset_id: &SystemId) -> Result<Vec<AssetStack>, DomainError> {
let rows = sqlx::query_as::<_, StackRow>(
"SELECT stack_id, stack_type, primary_asset_id, owner_user_id, members
FROM asset_stacks WHERE members @> $1::jsonb",
)
.bind(serde_json::json!([{"asset_id": asset_id.as_uuid()}]))
.fetch_all(&self.pool)
.await
.map_pg()?;
Ok(rows.into_iter().map(Into::into).collect())
}
async fn save(&self, stack: &AssetStack) -> Result<(), DomainError> {
sqlx::query(
"INSERT INTO asset_stacks (stack_id, stack_type, primary_asset_id, owner_user_id, members)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (stack_id) DO UPDATE SET
stack_type = EXCLUDED.stack_type,
primary_asset_id = EXCLUDED.primary_asset_id,
members = EXCLUDED.members",
)
.bind(*stack.stack_id.as_uuid())
.bind(stack_type_to_str(&stack.stack_type))
.bind(*stack.primary_asset_id.as_uuid())
.bind(*stack.owner_user_id.as_uuid())
.bind(members_to_json(&stack.members))
.execute(&self.pool)
.await
.map_pg()?;
Ok(())
}
async fn delete(&self, id: &SystemId) -> Result<(), DomainError> {
sqlx::query("DELETE FROM asset_stacks WHERE stack_id = $1")
.bind(*id.as_uuid())
.execute(&self.pool)
.await
.map_pg()?;
Ok(())
}
}