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:
@@ -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()
|
||||
})?;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
41
crates/presentation/src/handlers/duplicates.rs
Normal file
41
crates/presentation/src/handlers/duplicates.rs
Normal 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)
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
77
crates/presentation/src/handlers/stacks.rs
Normal file
77
crates/presentation/src/handlers/stacks.rs
Normal 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))
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
53
crates/presentation/src/middleware/auth.rs
Normal file
53
crates/presentation/src/middleware/auth.rs
Normal 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
|
||||
}
|
||||
1
crates/presentation/src/middleware/mod.rs
Normal file
1
crates/presentation/src/middleware/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod auth;
|
||||
116
crates/presentation/src/parsers.rs
Normal file
116
crates/presentation/src/parsers.rs
Normal 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)),
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
|
||||
Reference in New Issue
Block a user