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,12 +1,6 @@
use crate::state::AppState;
use axum::{
Json,
extract::FromRequestParts,
http::{StatusCode, request::Parts},
response::{IntoResponse, Response},
};
use crate::{middleware::auth::extract_bearer_token, state::AppState};
use axum::{extract::FromRequestParts, http::request::Parts, response::Response};
use domain::value_objects::SystemId;
use serde_json::json;
pub struct JwtClaims {
pub user_id: SystemId,
@@ -20,30 +14,13 @@ impl FromRequestParts<AppState> for JwtClaims {
parts: &mut Parts,
state: &AppState,
) -> Result<Self, Self::Rejection> {
let auth_header = parts
.headers
.get(axum::http::header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.ok_or_else(|| {
(
StatusCode::UNAUTHORIZED,
Json(json!({ "error": "Missing Authorization header" })),
)
.into_response()
})?;
let token = auth_header.strip_prefix("Bearer ").ok_or_else(|| {
(
StatusCode::UNAUTHORIZED,
Json(json!({ "error": "Invalid Authorization format" })),
)
.into_response()
})?;
let token = extract_bearer_token(&parts.headers)?;
let (user_id, role) = state.token_issuer.verify(token).await.map_err(|_| {
use axum::{Json, http::StatusCode, response::IntoResponse};
(
StatusCode::UNAUTHORIZED,
Json(json!({ "error": "Invalid or expired token" })),
Json(serde_json::json!({ "error": "Invalid or expired token" })),
)
.into_response()
})?;

View File

@@ -56,7 +56,10 @@ pub async fn add_entry(
user_id: claims.user_id,
};
let album = state.organization.manage_album_entries.execute(cmd).await?;
Ok((StatusCode::OK, Json(AlbumResponse::from_domain(&album))))
Ok((
StatusCode::CREATED,
Json(AlbumResponse::from_domain(&album)),
))
}
pub async fn remove_entry(

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,

View File

@@ -4,10 +4,13 @@ use crate::{
state::AppState,
};
use api_types::{
requests::{LoginRequest, RegisterRequest},
requests::{LoginRequest, RefreshTokenRequest, RegisterRequest},
responses::{AuthResponse, UserResponse},
};
use application::identity::{GetProfileQuery, LoginUserCommand, RegisterUserCommand};
use application::identity::{
GetProfileQuery, LoginUserCommand, RefreshTokenCommand, RegisterUserCommand,
generate_refresh_token,
};
use axum::{Json, extract::State, http::StatusCode};
#[utoipa::path(
@@ -34,10 +37,13 @@ pub async fn register(
.issue(&user.id, "user")
.await
.map_err(AppError::from)?;
let (refresh_token, _) =
generate_refresh_token(&state.identity.refresh_token_repo, &user.id).await?;
Ok((
StatusCode::CREATED,
Json(AuthResponse {
token,
refresh_token,
user: UserResponse::from_domain(&user),
}),
))
@@ -59,9 +65,10 @@ pub async fn login(
email: req.email,
password: req.password,
};
let (user, token) = state.identity.login.execute(cmd).await?;
let (user, token, refresh_token) = state.identity.login.execute(cmd).await?;
Ok(Json(AuthResponse {
token,
refresh_token,
user: UserResponse::from_domain(&user),
}))
}
@@ -84,3 +91,32 @@ pub async fn me(
let user = state.identity.get_profile.execute(query).await?;
Ok(Json(UserResponse::from_domain(&user)))
}
pub async fn refresh(
State(state): State<AppState>,
ValidatedJson(req): ValidatedJson<RefreshTokenRequest>,
) -> Result<Json<AuthResponse>, AppError> {
let cmd = RefreshTokenCommand {
refresh_token: req.refresh_token,
};
let (access_token, refresh_token) = state.identity.refresh.execute(cmd).await?;
let (user_id, _) = state.token_issuer.verify(&access_token).await?;
let user = state
.identity
.get_profile
.execute(GetProfileQuery { user_id })
.await?;
Ok(Json(AuthResponse {
token: access_token,
refresh_token,
user: UserResponse::from_domain(&user),
}))
}
pub async fn logout(
State(state): State<AppState>,
claims: JwtClaims,
) -> Result<StatusCode, AppError> {
state.identity.logout.execute(&claims.user_id).await?;
Ok(StatusCode::NO_CONTENT)
}

View File

@@ -0,0 +1,41 @@
use crate::{errors::AppError, extractors::JwtClaims, state::AppState};
use api_types::{requests::ResolveDuplicateRequest, responses::DuplicateGroupResponse};
use application::catalog::{ListDuplicatesQuery, ResolveDuplicateCommand};
use axum::{
Json,
extract::{Path, State},
http::StatusCode,
};
use domain::value_objects::SystemId;
pub async fn list_duplicates(
State(state): State<AppState>,
claims: JwtClaims,
) -> Result<Json<Vec<DuplicateGroupResponse>>, AppError> {
super::require_admin(&claims)?;
let groups = state
.catalog
.list_duplicates
.execute(ListDuplicatesQuery)
.await?;
let resp = groups
.iter()
.map(DuplicateGroupResponse::from_domain)
.collect();
Ok(Json(resp))
}
pub async fn resolve_duplicate(
State(state): State<AppState>,
claims: JwtClaims,
Path((group_id,)): Path<(uuid::Uuid,)>,
Json(req): Json<ResolveDuplicateRequest>,
) -> Result<StatusCode, AppError> {
let cmd = ResolveDuplicateCommand {
group_id: SystemId::from_uuid(group_id),
keep_asset_id: SystemId::from_uuid(req.keep_asset_id),
resolved_by: claims.user_id,
};
state.catalog.resolve_duplicate.execute(cmd).await?;
Ok(StatusCode::OK)
}

View File

@@ -1,8 +1,22 @@
pub mod albums;
pub mod assets;
pub mod auth;
pub mod duplicates;
pub mod health;
pub mod processing;
pub mod sharing;
pub mod sidecar;
pub mod stacks;
pub mod storage;
use crate::{errors::AppError, extractors::JwtClaims};
use domain::errors::DomainError;
pub(crate) fn require_admin(claims: &JwtClaims) -> Result<(), AppError> {
if claims.role != "admin" {
return Err(AppError::from(DomainError::Forbidden(
"Admin access required".into(),
)));
}
Ok(())
}

View File

@@ -1,49 +1,28 @@
use crate::{errors::AppError, extractors::JwtClaims, state::AppState};
use crate::{errors::AppError, extractors::JwtClaims, parsers, state::AppState};
use api_types::{
requests::{
CompleteJobRequest, ConfigurePipelineRequest, EnqueueJobRequest, FailJobRequest,
ManagePluginRequest,
},
responses::{BatchProgressResponse, JobResponse, PipelineResponse, PluginResponse},
responses::{
BatchProgressResponse, JobListResponse, JobResponse, PipelineResponse, PluginResponse,
},
};
use application::processing::{
CompleteJobCommand, ConfigurePipelineCommand, EnqueueJobCommand, FailJobCommand,
CompleteJobCommand, ConfigurePipelineCommand, EnqueueJobCommand, FailJobCommand, ListJobsQuery,
ManagePluginCommand, PipelineStepConfig, PluginAction, ReportBatchProgressQuery,
StartJobCommand,
};
use axum::{
Json,
extract::{Path, State},
extract::{Path, Query, State},
http::StatusCode,
};
use domain::{
entities::{JobType, PluginType},
errors::DomainError,
value_objects::{MetadataValue, StructuredData, SystemId},
};
fn parse_job_type(s: &str) -> JobType {
match s {
"scan_directory" => JobType::ScanDirectory,
"extract_metadata" => JobType::ExtractMetadata,
"generate_derivative" => JobType::GenerateDerivative,
"sync_sidecar" => JobType::SyncSidecar,
"detect_duplicates" => JobType::DetectDuplicates,
other => JobType::Custom(other.to_string()),
}
}
fn parse_plugin_type(s: &str) -> Result<PluginType, AppError> {
match s {
"media_processor" => Ok(PluginType::MediaProcessor),
"scheduled_task" => Ok(PluginType::ScheduledTask),
"sidecar_writer" => Ok(PluginType::SidecarWriter),
_ => Err(AppError::from(DomainError::Validation(format!(
"Invalid plugin type: {s}"
)))),
}
}
fn hashmap_to_structured(
map: &std::collections::HashMap<String, serde_json::Value>,
) -> StructuredData {
@@ -54,11 +33,38 @@ fn hashmap_to_structured(
sd
}
#[derive(Debug, serde::Deserialize)]
pub struct ListJobsParams {
pub status: Option<String>,
pub limit: Option<u32>,
pub offset: Option<u32>,
}
pub async fn list_jobs(
State(state): State<AppState>,
claims: JwtClaims,
Query(params): Query<ListJobsParams>,
) -> Result<Json<JobListResponse>, AppError> {
super::require_admin(&claims)?;
let query = ListJobsQuery {
status: params.status,
limit: params.limit.unwrap_or(20).min(100),
offset: params.offset.unwrap_or(0),
};
let result = state.processing.list_jobs.execute(query).await?;
let jobs = result.jobs.iter().map(JobResponse::from_domain).collect();
Ok(Json(JobListResponse {
jobs,
total: result.total,
}))
}
pub async fn enqueue_job(
State(state): State<AppState>,
_claims: JwtClaims,
claims: JwtClaims,
Json(req): Json<EnqueueJobRequest>,
) -> Result<(StatusCode, Json<JobResponse>), AppError> {
super::require_admin(&claims)?;
let payload = req
.payload
.as_ref()
@@ -66,7 +72,7 @@ pub async fn enqueue_job(
.unwrap_or_default();
let cmd = EnqueueJobCommand {
job_type: parse_job_type(&req.job_type),
job_type: parsers::job_type(&req.job_type),
priority: req.priority.unwrap_or(0),
payload,
target_asset_id: req.target_asset_id.map(SystemId::from_uuid),
@@ -78,9 +84,10 @@ pub async fn enqueue_job(
pub async fn start_job(
State(state): State<AppState>,
_claims: JwtClaims,
claims: JwtClaims,
Path((job_id,)): Path<(uuid::Uuid,)>,
) -> Result<Json<JobResponse>, AppError> {
super::require_admin(&claims)?;
let cmd = StartJobCommand {
job_id: SystemId::from_uuid(job_id),
};
@@ -90,10 +97,11 @@ pub async fn start_job(
pub async fn complete_job(
State(state): State<AppState>,
_claims: JwtClaims,
claims: JwtClaims,
Path((job_id,)): Path<(uuid::Uuid,)>,
Json(req): Json<CompleteJobRequest>,
) -> Result<Json<JobResponse>, AppError> {
super::require_admin(&claims)?;
let cmd = CompleteJobCommand {
job_id: SystemId::from_uuid(job_id),
result: hashmap_to_structured(&req.result),
@@ -104,10 +112,11 @@ pub async fn complete_job(
pub async fn fail_job(
State(state): State<AppState>,
_claims: JwtClaims,
claims: JwtClaims,
Path((job_id,)): Path<(uuid::Uuid,)>,
Json(req): Json<FailJobRequest>,
) -> Result<Json<JobResponse>, AppError> {
super::require_admin(&claims)?;
let cmd = FailJobCommand {
job_id: SystemId::from_uuid(job_id),
error: req.error,
@@ -118,9 +127,10 @@ pub async fn fail_job(
pub async fn batch_progress(
State(state): State<AppState>,
_claims: JwtClaims,
claims: JwtClaims,
Path((batch_id,)): Path<(uuid::Uuid,)>,
) -> Result<Json<BatchProgressResponse>, AppError> {
super::require_admin(&claims)?;
let query = ReportBatchProgressQuery {
batch_id: SystemId::from_uuid(batch_id),
};
@@ -130,16 +140,17 @@ pub async fn batch_progress(
pub async fn manage_plugin(
State(state): State<AppState>,
_claims: JwtClaims,
claims: JwtClaims,
Json(req): Json<ManagePluginRequest>,
) -> Result<(StatusCode, Json<PluginResponse>), AppError> {
super::require_admin(&claims)?;
let action = match req.action.as_str() {
"create" => {
let name = req.name.ok_or_else(|| {
AppError::from(DomainError::Validation("name required for create".into()))
})?;
let pt = req.plugin_type.as_deref().unwrap_or("media_processor");
let plugin_type = parse_plugin_type(pt)?;
let plugin_type = parsers::plugin_type(pt)?;
let config = req
.config
.as_ref()
@@ -173,9 +184,10 @@ pub async fn manage_plugin(
pub async fn configure_pipeline(
State(state): State<AppState>,
_claims: JwtClaims,
claims: JwtClaims,
Json(req): Json<ConfigurePipelineRequest>,
) -> Result<(StatusCode, Json<PipelineResponse>), AppError> {
super::require_admin(&claims)?;
let steps = req
.steps
.iter()

View File

@@ -1,4 +1,4 @@
use crate::{errors::AppError, extractors::JwtClaims, state::AppState};
use crate::{errors::AppError, extractors::JwtClaims, parsers, state::AppState};
use api_types::{
requests::{GenerateShareLinkRequest, ShareResourceRequest},
responses::{ShareLinkResponse, ShareScopeResponse, SharedResourceResponse},
@@ -11,53 +11,17 @@ use axum::{
extract::{Path, State},
http::StatusCode,
};
use domain::{
entities::{LinkAccessLevel, ShareableType, TargetType},
errors::DomainError,
value_objects::{DateTimeStamp, SystemId},
};
use domain::value_objects::{DateTimeStamp, SystemId};
const DEFAULT_ACCESS_LEVEL: &str = "view_only";
fn parse_shareable_type(s: &str) -> Result<ShareableType, AppError> {
match s {
"asset" => Ok(ShareableType::Asset),
"album" => Ok(ShareableType::Album),
"collection" => Ok(ShareableType::Collection),
"directory" => Ok(ShareableType::Directory),
_ => Err(AppError::from(DomainError::Validation(format!(
"Invalid shareable type: {s}"
)))),
}
}
fn parse_target_type(s: &str) -> Result<TargetType, AppError> {
match s {
"user" => Ok(TargetType::User),
"group" => Ok(TargetType::Group),
_ => Err(AppError::from(DomainError::Validation(format!(
"Invalid target type: {s}"
)))),
}
}
fn parse_access_level(s: &str) -> Result<LinkAccessLevel, AppError> {
match s {
"view_only" => Ok(LinkAccessLevel::ViewOnly),
"limited_search" => Ok(LinkAccessLevel::LimitedSearch),
_ => Err(AppError::from(DomainError::Validation(format!(
"Invalid access level: {s}"
)))),
}
}
pub async fn share_resource(
State(state): State<AppState>,
claims: JwtClaims,
Json(req): Json<ShareResourceRequest>,
) -> Result<(StatusCode, Json<ShareScopeResponse>), AppError> {
let shareable_type = parse_shareable_type(&req.shareable_type)?;
let target_type = parse_target_type(&req.target_type)?;
let shareable_type = parsers::shareable_type(&req.shareable_type)?;
let target_type = parsers::target_type(&req.target_type)?;
let cmd = ShareResourceCommand {
shareable_type,
@@ -79,9 +43,8 @@ pub async fn generate_link(
claims: JwtClaims,
Json(req): Json<GenerateShareLinkRequest>,
) -> Result<(StatusCode, Json<ShareLinkResponse>), AppError> {
let shareable_type = parse_shareable_type(&req.shareable_type)?;
let access_level =
parse_access_level(req.access_level.as_deref().unwrap_or(DEFAULT_ACCESS_LEVEL))?;
let shareable_type = parsers::shareable_type(&req.shareable_type)?;
let al = parsers::access_level(req.access_level.as_deref().unwrap_or(DEFAULT_ACCESS_LEVEL))?;
let expires_at = req.expires_in_hours.map(|h| {
DateTimeStamp::from_datetime(chrono::Utc::now() + chrono::Duration::hours(h as i64))
@@ -90,7 +53,7 @@ pub async fn generate_link(
let cmd = GenerateShareLinkCommand {
shareable_type,
shareable_id: SystemId::from_uuid(req.shareable_id),
access_level,
access_level: al,
created_by: claims.user_id,
expires_at,
max_uses: req.max_uses,

View File

@@ -1,4 +1,4 @@
use crate::{errors::AppError, extractors::JwtClaims, state::AppState};
use crate::{errors::AppError, extractors::JwtClaims, parsers, state::AppState};
use api_types::responses::{DetectChangesResponse, SidecarExportResponse, SidecarImportResponse};
use application::sidecar::{
DetectExternalChangesCommand, ExportSidecarCommand, FullExportCommand, FullImportCommand,
@@ -8,23 +8,14 @@ use axum::{
Json,
extract::{Path, State},
};
use domain::{entities::ConflictPolicy, errors::DomainError, value_objects::SystemId};
fn parse_conflict_policy(s: &str) -> Result<ConflictPolicy, AppError> {
match s {
"db_wins" => Ok(ConflictPolicy::DbWins),
"file_wins" => Ok(ConflictPolicy::FileWins),
_ => Err(AppError::from(DomainError::Validation(format!(
"Invalid conflict policy: {s}. Use db_wins or file_wins"
)))),
}
}
use domain::value_objects::SystemId;
pub async fn export_sidecar(
State(state): State<AppState>,
_claims: JwtClaims,
claims: JwtClaims,
Path((asset_id,)): Path<(uuid::Uuid,)>,
) -> Result<Json<SidecarExportResponse>, AppError> {
super::require_admin(&claims)?;
let cmd = ExportSidecarCommand {
asset_id: SystemId::from_uuid(asset_id),
};
@@ -34,8 +25,9 @@ pub async fn export_sidecar(
pub async fn detect_changes(
State(state): State<AppState>,
_claims: JwtClaims,
claims: JwtClaims,
) -> Result<Json<DetectChangesResponse>, AppError> {
super::require_admin(&claims)?;
let count = state
.sidecar
.detect_changes
@@ -48,9 +40,10 @@ pub async fn detect_changes(
pub async fn import_sidecar(
State(state): State<AppState>,
_claims: JwtClaims,
claims: JwtClaims,
Path((asset_id,)): Path<(uuid::Uuid,)>,
) -> Result<Json<SidecarImportResponse>, AppError> {
super::require_admin(&claims)?;
let cmd = ImportSidecarCommand {
asset_id: SystemId::from_uuid(asset_id),
};
@@ -63,11 +56,12 @@ pub async fn import_sidecar(
pub async fn resolve_conflict(
State(state): State<AppState>,
_claims: JwtClaims,
claims: JwtClaims,
Path((asset_id,)): Path<(uuid::Uuid,)>,
Json(req): Json<api_types::requests::ResolveConflictRequest>,
) -> Result<Json<SidecarExportResponse>, AppError> {
let policy = parse_conflict_policy(&req.policy)?;
super::require_admin(&claims)?;
let policy = parsers::conflict_policy(&req.policy)?;
let cmd = ResolveConflictCommand {
asset_id: SystemId::from_uuid(asset_id),
policy,

View File

@@ -0,0 +1,77 @@
use crate::{errors::AppError, extractors::JwtClaims, parsers, state::AppState};
use api_types::{requests::CreateStackRequest, responses::StackResponse};
use application::catalog::{
CreateStackCommand, DeleteStackCommand, DetectLivePhotosCommand, GetStackQuery,
};
use axum::{
Json,
extract::{Path, State},
http::StatusCode,
};
use domain::value_objects::SystemId;
pub async fn create_stack(
State(state): State<AppState>,
claims: JwtClaims,
Json(req): Json<CreateStackRequest>,
) -> Result<(StatusCode, Json<StackResponse>), AppError> {
let stack_type = parsers::stack_type(&req.stack_type)?;
let members = req
.members
.iter()
.map(|m| {
let role = parsers::member_role(&m.role)?;
Ok((SystemId::from_uuid(m.asset_id), role))
})
.collect::<Result<Vec<_>, AppError>>()?;
let cmd = CreateStackCommand {
stack_type,
primary_asset_id: SystemId::from_uuid(req.primary_asset_id),
additional_asset_ids: members,
owner_id: claims.user_id,
};
let stack = state.catalog.create_stack.execute(cmd).await?;
Ok((
StatusCode::CREATED,
Json(StackResponse::from_domain(&stack)),
))
}
pub async fn get_stack(
State(state): State<AppState>,
claims: JwtClaims,
Path((stack_id,)): Path<(uuid::Uuid,)>,
) -> Result<Json<StackResponse>, AppError> {
let query = GetStackQuery {
stack_id: SystemId::from_uuid(stack_id),
caller_id: claims.user_id,
};
let stack = state.catalog.get_stack.execute(query).await?;
Ok(Json(StackResponse::from_domain(&stack)))
}
pub async fn delete_stack(
State(state): State<AppState>,
claims: JwtClaims,
Path((stack_id,)): Path<(uuid::Uuid,)>,
) -> Result<StatusCode, AppError> {
let cmd = DeleteStackCommand {
stack_id: SystemId::from_uuid(stack_id),
caller_id: claims.user_id,
};
state.catalog.delete_stack.execute(cmd).await?;
Ok(StatusCode::NO_CONTENT)
}
pub async fn detect_live_photos(
State(state): State<AppState>,
claims: JwtClaims,
) -> Result<Json<Vec<StackResponse>>, AppError> {
let cmd = DetectLivePhotosCommand {
owner_id: claims.user_id,
};
let stacks = state.catalog.detect_live_photos.execute(cmd).await?;
let resp = stacks.iter().map(StackResponse::from_domain).collect();
Ok(Json(resp))
}

View File

@@ -1,4 +1,4 @@
use crate::{errors::AppError, extractors::JwtClaims, state::AppState};
use crate::{errors::AppError, extractors::JwtClaims, parsers, state::AppState};
use api_types::{
requests::{CheckQuotaParams, RegisterLibraryPathRequest, RegisterVolumeRequest},
responses::{LibraryPathResponse, QuotaCheckResponse, VolumeResponse},
@@ -9,13 +9,14 @@ use axum::{
extract::{Query, State},
http::StatusCode,
};
use domain::{entities::UsageType, errors::DomainError, value_objects::SystemId};
use domain::value_objects::SystemId;
pub async fn register_volume(
State(state): State<AppState>,
_claims: JwtClaims,
claims: JwtClaims,
Json(req): Json<RegisterVolumeRequest>,
) -> Result<(StatusCode, Json<VolumeResponse>), AppError> {
super::require_admin(&claims)?;
let cmd = RegisterVolumeCommand {
volume_name: req.volume_name,
uri_prefix: req.uri_prefix,
@@ -30,9 +31,10 @@ pub async fn register_volume(
pub async fn register_library_path(
State(state): State<AppState>,
_claims: JwtClaims,
claims: JwtClaims,
Json(req): Json<RegisterLibraryPathRequest>,
) -> Result<(StatusCode, Json<LibraryPathResponse>), AppError> {
super::require_admin(&claims)?;
let cmd = RegisterLibraryPathCommand {
volume_id: SystemId::from_uuid(req.volume_id),
relative_path: req.relative_path,
@@ -49,24 +51,12 @@ pub async fn register_library_path(
const DEFAULT_QUOTA_USAGE_TYPE: &str = "storage_bytes";
const DEFAULT_QUOTA_AMOUNT: u64 = 0;
fn parse_usage_type(s: &str) -> Result<UsageType, AppError> {
match s {
"storage_bytes" => Ok(UsageType::StorageBytes),
"process_jobs" => Ok(UsageType::ProcessJobs),
"api_calls" => Ok(UsageType::ApiCalls),
"indexing_size" => Ok(UsageType::IndexingSize),
_ => Err(AppError::from(DomainError::Validation(format!(
"Invalid usage type: {s}"
)))),
}
}
pub async fn check_quota(
State(state): State<AppState>,
claims: JwtClaims,
Query(params): Query<CheckQuotaParams>,
) -> Result<Json<QuotaCheckResponse>, AppError> {
let usage_type = parse_usage_type(
let usage_type = parsers::usage_type(
params
.usage_type
.as_deref()

View File

@@ -2,6 +2,8 @@ pub mod constants;
pub mod errors;
pub mod extractors;
pub mod handlers;
pub mod middleware;
pub mod openapi;
pub mod parsers;
pub mod routes;
pub mod state;

View File

@@ -0,0 +1,53 @@
use crate::state::AppState;
use axum::{
Json,
body::Body,
extract::State,
http::{Request, StatusCode},
middleware::Next,
response::{IntoResponse, Response},
};
use serde_json::json;
#[allow(clippy::result_large_err)]
pub fn extract_bearer_token(headers: &axum::http::HeaderMap) -> Result<&str, Response> {
let header = headers
.get(axum::http::header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.ok_or_else(|| {
(
StatusCode::UNAUTHORIZED,
Json(json!({ "error": "Missing Authorization header" })),
)
.into_response()
})?;
header.strip_prefix("Bearer ").ok_or_else(|| {
(
StatusCode::UNAUTHORIZED,
Json(json!({ "error": "Invalid Authorization format" })),
)
.into_response()
})
}
pub async fn require_auth(
State(state): State<AppState>,
req: Request<Body>,
next: Next,
) -> Response {
let token = match extract_bearer_token(req.headers()) {
Ok(t) => t.to_string(),
Err(r) => return r,
};
if state.token_issuer.verify(&token).await.is_err() {
return (
StatusCode::UNAUTHORIZED,
Json(json!({ "error": "Invalid or expired token" })),
)
.into_response();
}
next.run(req).await
}

View File

@@ -0,0 +1 @@
pub mod auth;

View File

@@ -0,0 +1,116 @@
use crate::errors::AppError;
use domain::{
entities::{
AssetType, ConflictPolicy, DerivativeProfile, JobType, LinkAccessLevel, PluginType,
ShareableType, StackMemberRole, StackType, TargetType, UsageType,
},
errors::DomainError,
};
fn parse_err(kind: &str, value: &str) -> AppError {
AppError::from(DomainError::Validation(format!("Invalid {kind}: {value}")))
}
pub fn asset_type(s: &str) -> Result<AssetType, AppError> {
match s {
"image" => Ok(AssetType::Image),
"video" => Ok(AssetType::Video),
"live_photo" => Ok(AssetType::LivePhoto),
_ => Err(parse_err("asset type", s)),
}
}
pub fn derivative_profile(s: &str) -> Result<DerivativeProfile, AppError> {
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(parse_err("derivative profile", s)),
}
}
pub fn stack_type(s: &str) -> Result<StackType, AppError> {
match s {
"live_photo" => Ok(StackType::LivePhoto),
"format_pair" => Ok(StackType::FormatPair),
"burst_sequence" => Ok(StackType::BurstSequence),
"exposure_bracket" => Ok(StackType::ExposureBracket),
"manual_group" => Ok(StackType::ManualGroup),
_ => Err(parse_err("stack type", s)),
}
}
pub fn member_role(s: &str) -> Result<StackMemberRole, AppError> {
match s {
"primary_display" => Ok(StackMemberRole::PrimaryDisplay),
"high_res_source" => Ok(StackMemberRole::HighResSource),
"motion_clip" => Ok(StackMemberRole::MotionClip),
"alternate_frame" => Ok(StackMemberRole::AlternateFrame),
_ => Err(parse_err("member role", s)),
}
}
pub fn shareable_type(s: &str) -> Result<ShareableType, AppError> {
match s {
"asset" => Ok(ShareableType::Asset),
"album" => Ok(ShareableType::Album),
"collection" => Ok(ShareableType::Collection),
"directory" => Ok(ShareableType::Directory),
_ => Err(parse_err("shareable type", s)),
}
}
pub fn target_type(s: &str) -> Result<TargetType, AppError> {
match s {
"user" => Ok(TargetType::User),
"group" => Ok(TargetType::Group),
_ => Err(parse_err("target type", s)),
}
}
pub fn access_level(s: &str) -> Result<LinkAccessLevel, AppError> {
match s {
"view_only" => Ok(LinkAccessLevel::ViewOnly),
"limited_search" => Ok(LinkAccessLevel::LimitedSearch),
_ => Err(parse_err("access level", s)),
}
}
pub fn job_type(s: &str) -> JobType {
match s {
"scan_directory" => JobType::ScanDirectory,
"extract_metadata" => JobType::ExtractMetadata,
"generate_derivative" => JobType::GenerateDerivative,
"sync_sidecar" => JobType::SyncSidecar,
"detect_duplicates" => JobType::DetectDuplicates,
other => JobType::Custom(other.to_string()),
}
}
pub fn plugin_type(s: &str) -> Result<PluginType, AppError> {
match s {
"media_processor" => Ok(PluginType::MediaProcessor),
"scheduled_task" => Ok(PluginType::ScheduledTask),
"sidecar_writer" => Ok(PluginType::SidecarWriter),
_ => Err(parse_err("plugin type", s)),
}
}
pub fn usage_type(s: &str) -> Result<UsageType, AppError> {
match s {
"storage_bytes" => Ok(UsageType::StorageBytes),
"process_jobs" => Ok(UsageType::ProcessJobs),
"api_calls" => Ok(UsageType::ApiCalls),
"indexing_size" => Ok(UsageType::IndexingSize),
_ => Err(parse_err("usage type", s)),
}
}
pub fn conflict_policy(s: &str) -> Result<ConflictPolicy, AppError> {
match s {
"db_wins" => Ok(ConflictPolicy::DbWins),
"file_wins" => Ok(ConflictPolicy::FileWins),
_ => Err(parse_err("conflict policy", s)),
}
}

View File

@@ -1,19 +1,30 @@
use crate::{
handlers::{albums, assets, auth, health, processing, sharing, sidecar, storage},
handlers::{
albums, assets, auth, duplicates, health, processing, sharing, sidecar, stacks, storage,
},
middleware::auth::require_auth,
openapi::openapi_router,
state::AppState,
};
use axum::{
Router,
middleware::from_fn_with_state,
routing::{delete, get, post, put},
};
pub fn api_v1_router() -> Router<AppState> {
fn public_routes() -> Router<AppState> {
Router::new()
// auth
.route("/auth/register", post(auth::register))
.route("/auth/login", post(auth::login))
.route("/auth/refresh", post(auth::refresh))
.route("/sharing/access/{token}", get(sharing::access_by_token))
}
fn protected_routes(state: &AppState) -> Router<AppState> {
Router::new()
// auth
.route("/auth/me", get(auth::me))
.route("/auth/logout", post(auth::logout))
// albums
.route("/albums", post(albums::create_album))
.route("/albums/{id}", get(albums::get_album))
@@ -23,10 +34,14 @@ pub fn api_v1_router() -> Router<AppState> {
delete(albums::remove_entry),
)
// assets
.route("/assets", get(assets::search_assets))
.route("/assets/ingest", post(assets::ingest))
.route("/assets/register", post(assets::register_asset))
.route("/assets/timeline", get(assets::timeline))
.route("/assets/{id}", get(assets::get_asset))
.route(
"/assets/{id}",
get(assets::get_asset).delete(assets::delete_asset),
)
.route("/assets/{id}/metadata", put(assets::update_metadata))
.route("/assets/{id}/file", get(assets::serve_file))
.route(
@@ -34,11 +49,26 @@ pub fn api_v1_router() -> Router<AppState> {
get(assets::serve_derivative),
)
.route("/assets/{id}/tags", post(assets::tag_asset))
// stacks
.route("/stacks", post(stacks::create_stack))
.route(
"/stacks/detect-live-photos",
post(stacks::detect_live_photos),
)
.route(
"/stacks/{id}",
get(stacks::get_stack).delete(stacks::delete_stack),
)
// duplicates
.route("/duplicates", get(duplicates::list_duplicates))
.route(
"/duplicates/{id}/resolve",
post(duplicates::resolve_duplicate),
)
// sharing
.route("/sharing", post(sharing::share_resource))
.route("/sharing/links", post(sharing::generate_link))
.route("/sharing/{id}", delete(sharing::revoke))
.route("/sharing/access/{token}", get(sharing::access_by_token))
// storage
.route("/storage/volumes", post(storage::register_volume))
.route(
@@ -57,18 +87,22 @@ pub fn api_v1_router() -> Router<AppState> {
.route("/sidecar/full-export", post(sidecar::full_export))
.route("/sidecar/full-import", post(sidecar::full_import))
// processing
.route("/jobs", post(processing::enqueue_job))
.route(
"/jobs",
get(processing::list_jobs).post(processing::enqueue_job),
)
.route("/jobs/{id}/start", post(processing::start_job))
.route("/jobs/{id}/complete", post(processing::complete_job))
.route("/jobs/{id}/fail", post(processing::fail_job))
.route("/jobs/batches/{id}", get(processing::batch_progress))
.route("/plugins", post(processing::manage_plugin))
.route("/pipelines", post(processing::configure_pipeline))
.route_layer(from_fn_with_state(state.clone(), require_auth))
}
pub fn app_router() -> Router<AppState> {
pub fn app_router(state: &AppState) -> Router<AppState> {
Router::new()
.route("/health", get(health::health))
.nest("/api/v1", api_v1_router())
.nest("/api/v1", public_routes().merge(protected_routes(state)))
.merge(openapi_router())
}

View File

@@ -2,16 +2,21 @@ use std::sync::Arc;
use application::{
catalog::{
GetAssetHandler, GetTimelineHandler, ReadAssetFileHandler, ReadDerivativeHandler,
RegisterAssetHandler, UpdateMetadataHandler,
CreateStackHandler, DeleteAssetHandler, DeleteStackHandler, DetectLivePhotosHandler,
GetAssetHandler, GetStackHandler, GetTimelineHandler, ListDuplicatesHandler,
ReadAssetFileHandler, ReadDerivativeHandler, RegisterAssetHandler, ResolveDuplicateHandler,
SearchAssetsHandler, UpdateMetadataHandler,
},
identity::{
GetProfileHandler, LoginUserHandler, LogoutHandler, RefreshTokenHandler,
RegisterUserHandler,
},
identity::{GetProfileHandler, LoginUserHandler, RegisterUserHandler},
organization::{
CreateAlbumHandler, GetAlbumHandler, ManageAlbumEntriesHandler, TagAssetHandler,
},
processing::{
CompleteJobHandler, ConfigurePipelineHandler, EnqueueJobHandler, FailJobHandler,
ManagePluginHandler, ReportBatchProgressHandler, StartJobHandler,
ListJobsHandler, ManagePluginHandler, ReportBatchProgressHandler, StartJobHandler,
},
sharing::{
AccessSharedResourceHandler, GenerateShareLinkHandler, RevokeShareHandler,
@@ -25,13 +30,16 @@ use application::{
CheckQuotaHandler, IngestAssetHandler, RegisterLibraryPathHandler, RegisterVolumeHandler,
},
};
use domain::ports::TokenIssuer;
use domain::ports::{RefreshTokenRepository, TokenIssuer};
#[derive(Clone)]
pub struct IdentityHandlers {
pub register: Arc<RegisterUserHandler>,
pub login: Arc<LoginUserHandler>,
pub get_profile: Arc<GetProfileHandler>,
pub refresh: Arc<RefreshTokenHandler>,
pub logout: Arc<LogoutHandler>,
pub refresh_token_repo: Arc<dyn RefreshTokenRepository>,
}
#[derive(Clone)]
@@ -43,6 +51,14 @@ pub struct CatalogHandlers {
pub read_asset_file: Arc<ReadAssetFileHandler>,
pub read_derivative: Arc<ReadDerivativeHandler>,
pub register_asset: Arc<RegisterAssetHandler>,
pub delete_asset: Arc<DeleteAssetHandler>,
pub search_assets: Arc<SearchAssetsHandler>,
pub list_duplicates: Arc<ListDuplicatesHandler>,
pub resolve_duplicate: Arc<ResolveDuplicateHandler>,
pub create_stack: Arc<CreateStackHandler>,
pub get_stack: Arc<GetStackHandler>,
pub delete_stack: Arc<DeleteStackHandler>,
pub detect_live_photos: Arc<DetectLivePhotosHandler>,
}
#[derive(Clone)]
@@ -84,6 +100,7 @@ pub struct ProcessingHandlers {
pub start_job: Arc<StartJobHandler>,
pub complete_job: Arc<CompleteJobHandler>,
pub fail_job: Arc<FailJobHandler>,
pub list_jobs: Arc<ListJobsHandler>,
pub batch_progress: Arc<ReportBatchProgressHandler>,
pub manage_plugin: Arc<ManagePluginHandler>,
pub configure_pipeline: Arc<ConfigurePipelineHandler>,