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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user