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

@@ -2,6 +2,7 @@ use crate::{
constants::{DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE},
errors::AppError,
extractors::{JwtClaims, UploadedAsset},
parsers,
state::AppState,
};
use api_types::{
@@ -10,8 +11,8 @@ use api_types::{
};
use application::{
catalog::{
GetAssetQuery, GetTimelineQuery, ReadAssetFileQuery, ReadDerivativeQuery,
RegisterAssetCommand, UpdateMetadataCommand,
DeleteAssetCommand, GetAssetQuery, GetTimelineQuery, ReadAssetFileQuery,
ReadDerivativeQuery, RegisterAssetCommand, SearchAssetsQuery, UpdateMetadataCommand,
},
organization::TagAssetCommand,
storage::IngestAssetCommand,
@@ -24,9 +25,9 @@ use axum::{
response::Response,
};
use domain::{
catalog::entities::AssetType,
catalog::entities::AssetFilters,
errors::DomainError,
value_objects::{MetadataValue, StructuredData, SystemId},
value_objects::{DateTimeStamp, MetadataValue, StructuredData, SystemId},
};
#[derive(Debug, serde::Deserialize)]
@@ -35,6 +36,79 @@ pub struct TimelineParams {
pub offset: Option<u32>,
}
#[derive(Debug, serde::Deserialize)]
pub struct SearchParams {
#[serde(rename = "type")]
pub asset_type: Option<String>,
pub mime_type: Option<String>,
pub date_from: Option<String>,
pub date_to: Option<String>,
pub is_processed: Option<bool>,
pub limit: Option<u32>,
pub offset: Option<u32>,
}
pub async fn search_assets(
State(state): State<AppState>,
claims: JwtClaims,
Query(params): Query<SearchParams>,
) -> Result<Json<TimelineResponse>, AppError> {
let asset_type = params
.asset_type
.as_deref()
.map(parsers::asset_type)
.transpose()?;
let date_from = params
.date_from
.as_deref()
.map(|s| {
let d = chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d")
.map_err(|_| AppError::from(DomainError::Validation("Invalid date_from".into())))?;
d.and_hms_opt(0, 0, 0)
.map(|dt| DateTimeStamp::from_datetime(dt.and_utc()))
.ok_or_else(|| AppError::from(DomainError::Validation("Invalid date_from".into())))
})
.transpose()?;
let date_to = params
.date_to
.as_deref()
.map(|s| {
let d = chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d")
.map_err(|_| AppError::from(DomainError::Validation("Invalid date_to".into())))?;
d.and_hms_opt(23, 59, 59)
.map(|dt| DateTimeStamp::from_datetime(dt.and_utc()))
.ok_or_else(|| AppError::from(DomainError::Validation("Invalid date_to".into())))
})
.transpose()?;
let filters = AssetFilters {
asset_type,
mime_type: params.mime_type,
date_from,
date_to,
is_processed: params.is_processed,
};
let limit = params.limit.unwrap_or(DEFAULT_PAGE_SIZE).min(MAX_PAGE_SIZE);
let offset = params.offset.unwrap_or(0);
let query = SearchAssetsQuery {
owner_id: claims.user_id,
filters,
limit,
offset,
};
let results = state.catalog.search_assets.execute(query).await?;
let total = results.len();
let assets = results
.iter()
.map(|a| AssetResponse::from_domain(a, &StructuredData::new()))
.collect();
Ok(Json(TimelineResponse { assets, total }))
}
pub async fn ingest(
State(state): State<AppState>,
claims: JwtClaims,
@@ -117,11 +191,12 @@ pub async fn update_metadata(
pub async fn serve_file(
State(state): State<AppState>,
_claims: JwtClaims,
claims: JwtClaims,
Path((asset_id,)): Path<(uuid::Uuid,)>,
) -> Result<Response, AppError> {
let query = ReadAssetFileQuery {
asset_id: SystemId::from_uuid(asset_id),
caller_id: claims.user_id,
};
let result = state.catalog.read_asset_file.execute(query).await?;
@@ -152,15 +227,29 @@ pub async fn tag_asset(
Ok((StatusCode::CREATED, Json(TagResponse::from_domain(&tag))))
}
pub async fn delete_asset(
State(state): State<AppState>,
claims: JwtClaims,
Path((asset_id,)): Path<(uuid::Uuid,)>,
) -> Result<StatusCode, AppError> {
let cmd = DeleteAssetCommand {
asset_id: SystemId::from_uuid(asset_id),
deleted_by: claims.user_id,
};
state.catalog.delete_asset.execute(cmd).await?;
Ok(StatusCode::NO_CONTENT)
}
pub async fn serve_derivative(
State(state): State<AppState>,
_claims: JwtClaims,
claims: JwtClaims,
Path((asset_id, profile)): Path<(uuid::Uuid, String)>,
) -> Result<Response, AppError> {
let profile = parse_derivative_profile(&profile)?;
let profile = parsers::derivative_profile(&profile)?;
let query = ReadDerivativeQuery {
asset_id: SystemId::from_uuid(asset_id),
profile,
caller_id: claims.user_id,
};
let result = state.catalog.read_derivative.execute(query).await?;
@@ -173,36 +262,12 @@ pub async fn serve_derivative(
.map_err(|e| AppError::from(DomainError::Internal(e.to_string())))
}
fn parse_derivative_profile(s: &str) -> Result<domain::entities::DerivativeProfile, AppError> {
use domain::entities::DerivativeProfile;
match s {
"thumbnail" | "thumbnail_square" => Ok(DerivativeProfile::ThumbnailSquare),
"thumbnail_large" => Ok(DerivativeProfile::ThumbnailLarge),
"web" | "web_optimized" => Ok(DerivativeProfile::WebOptimized),
"video_sd" => Ok(DerivativeProfile::VideoSd),
_ => Err(AppError::from(DomainError::Validation(format!(
"Unknown derivative profile: {s}"
)))),
}
}
fn parse_asset_type(s: &str) -> Result<AssetType, AppError> {
match s {
"image" => Ok(AssetType::Image),
"video" => Ok(AssetType::Video),
"live_photo" => Ok(AssetType::LivePhoto),
_ => Err(AppError::from(DomainError::Validation(format!(
"Invalid asset type: {s}"
)))),
}
}
pub async fn register_asset(
State(state): State<AppState>,
claims: JwtClaims,
Json(req): Json<RegisterAssetRequest>,
) -> Result<(StatusCode, Json<AssetResponse>), AppError> {
let asset_type = parse_asset_type(&req.asset_type)?;
let asset_type = parsers::asset_type(&req.asset_type)?;
let cmd = RegisterAssetCommand {
volume_id: SystemId::from_uuid(req.volume_id),
relative_path: req.relative_path,