From 201eff717d1d04e2686cc0a8b530f050e08cbbf7 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 31 May 2026 05:51:09 +0200 Subject: [PATCH] feat: add presentation layer + bootstrap wiring for vertical slice --- .env.example | 45 ++--- Cargo.lock | 31 ++++ compose.yml | 14 ++ crates/adapters/storage/Cargo.toml | 1 + crates/adapters/storage/src/lib.rs | 2 + .../storage/src/local_file_storage.rs | 101 ++++++++++ crates/api-types/Cargo.toml | 11 +- crates/api-types/src/requests.rs | 25 +++ crates/api-types/src/responses.rs | 103 +++++++++++ crates/bootstrap/Cargo.toml | 1 + crates/bootstrap/src/factory.rs | 80 +++++++- crates/bootstrap/src/lib.rs | 4 +- crates/bootstrap/src/log_event_publisher.rs | 12 ++ crates/bootstrap/src/main.rs | 5 +- crates/presentation/Cargo.toml | 4 +- crates/presentation/src/handlers/albums.rs | 72 ++++++++ crates/presentation/src/handlers/assets.rs | 173 ++++++++++++++++++ crates/presentation/src/handlers/mod.rs | 3 + crates/presentation/src/handlers/storage.rs | 37 ++++ crates/presentation/src/routes.rs | 18 +- crates/presentation/src/state.rs | 35 +++- 21 files changed, 726 insertions(+), 51 deletions(-) create mode 100644 compose.yml create mode 100644 crates/adapters/storage/src/local_file_storage.rs create mode 100644 crates/bootstrap/src/log_event_publisher.rs create mode 100644 crates/presentation/src/handlers/albums.rs create mode 100644 crates/presentation/src/handlers/assets.rs create mode 100644 crates/presentation/src/handlers/storage.rs diff --git a/.env.example b/.env.example index a01c603..ae331a8 100644 --- a/.env.example +++ b/.env.example @@ -1,65 +1,42 @@ # ============================================================================ -# K-Template Configuration +# K-Photos Configuration # ============================================================================ # Copy this file to .env and adjust values for your environment. # ============================================================================ # Server # ============================================================================ -HOST=127.0.0.1 +HOST=0.0.0.0 PORT=3000 # ============================================================================ # Database # ============================================================================ -# SQLite (default) -DATABASE_URL=sqlite:data.db?mode=rwc - -# PostgreSQL (requires postgres feature flag) -# DATABASE_URL=postgres://user:password@localhost:5432/mydb +DATABASE_URL=postgres://kphotos:kphotos@localhost:5432/kphotos DB_MAX_CONNECTIONS=5 DB_MIN_CONNECTIONS=1 -# ============================================================================ -# Cookie Secret -# ============================================================================ -# Used to encrypt the OIDC state cookie (CSRF token, PKCE verifier, nonce). -# Must be at least 64 characters in production. -COOKIE_SECRET=your-cookie-secret-key-must-be-at-least-64-characters-long-for-security!! - -# Set to true when serving over HTTPS -SECURE_COOKIE=false - # ============================================================================ # JWT # ============================================================================ -# Must be at least 32 characters in production. -JWT_SECRET=your-jwt-secret-key-at-least-32-chars - -# Optional: embed issuer/audience claims in tokens -# JWT_ISSUER=your-app-name -# JWT_AUDIENCE=your-app-audience +JWT_SECRET=change-me-in-production-at-least-32-characters # Token lifetime in hours (default: 24) JWT_EXPIRY_HOURS=24 -# ============================================================================ -# OIDC (optional — requires auth-oidc feature flag) -# ============================================================================ -# OIDC_ISSUER=https://your-oidc-provider.com -# OIDC_CLIENT_ID=your-client-id -# OIDC_CLIENT_SECRET=your-client-secret -# OIDC_REDIRECT_URL=http://localhost:3000/api/v1/auth/callback -# OIDC_RESOURCE_ID=your-resource-id # optional audience claim to verify - # ============================================================================ # CORS # ============================================================================ -CORS_ALLOWED_ORIGINS=http://localhost:5173,http://localhost:3000 +CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:5173 + +# ============================================================================ +# Storage +# ============================================================================ +STORAGE_BACKEND=local +STORAGE_PATH=./data/media # ============================================================================ # Production Mode # ============================================================================ -# Set to true/production/1 to enforce minimum secret lengths and other checks. PRODUCTION=false diff --git a/Cargo.lock b/Cargo.lock index fae8b80..24cc04e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -81,6 +81,7 @@ dependencies = [ "chrono", "domain", "serde", + "serde_json", "utoipa", "uuid", ] @@ -154,6 +155,7 @@ dependencies = [ "matchit", "memchr", "mime", + "multer", "percent-encoding", "pin-project-lite", "serde_core", @@ -260,6 +262,7 @@ dependencies = [ "adapters-storage", "anyhow", "application", + "async-trait", "axum", "domain", "dotenvy", @@ -486,6 +489,15 @@ dependencies = [ "serde", ] +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -1195,6 +1207,23 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "multer" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http", + "httparse", + "memchr", + "mime", + "spin", + "version_check", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -1434,10 +1463,12 @@ dependencies = [ "application", "async-trait", "axum", + "bytes", "chrono", "domain", "serde", "serde_json", + "sha2", "tower-http", "tracing", "utoipa", diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..3a372dc --- /dev/null +++ b/compose.yml @@ -0,0 +1,14 @@ +services: + postgres: + image: postgres:17-alpine + environment: + POSTGRES_USER: kphotos + POSTGRES_PASSWORD: kphotos + POSTGRES_DB: kphotos + ports: + - "5432:5432" + volumes: + - pgdata:/var/lib/postgresql/data + +volumes: + pgdata: diff --git a/crates/adapters/storage/Cargo.toml b/crates/adapters/storage/Cargo.toml index f33b3f1..7c51262 100644 --- a/crates/adapters/storage/Cargo.toml +++ b/crates/adapters/storage/Cargo.toml @@ -15,6 +15,7 @@ anyhow = { workspace = true } tracing = { workspace = true } bytes = { workspace = true } futures = { workspace = true } +tokio = { workspace = true, features = ["fs"] } object_store = { version = "0.11" } [dev-dependencies] diff --git a/crates/adapters/storage/src/lib.rs b/crates/adapters/storage/src/lib.rs index 4cb3160..47ddeaf 100644 --- a/crates/adapters/storage/src/lib.rs +++ b/crates/adapters/storage/src/lib.rs @@ -1,5 +1,7 @@ pub mod adapter; pub mod config; +pub mod local_file_storage; pub use adapter::ObjectStorageAdapter; pub use config::{StorageConfig, build_store}; +pub use local_file_storage::LocalFileStorage; diff --git a/crates/adapters/storage/src/local_file_storage.rs b/crates/adapters/storage/src/local_file_storage.rs new file mode 100644 index 0000000..660e9fd --- /dev/null +++ b/crates/adapters/storage/src/local_file_storage.rs @@ -0,0 +1,101 @@ +use async_trait::async_trait; +use bytes::Bytes; +use domain::errors::DomainError; +use domain::ports::{FileEntry, FileStoragePort}; +use std::path::PathBuf; + +pub struct LocalFileStorage { + base_path: PathBuf, +} + +impl LocalFileStorage { + pub fn new(base_path: impl Into) -> Self { + Self { + base_path: base_path.into(), + } + } + + fn resolve(&self, path: &str) -> Result { + let full = self.base_path.join(path); + // Prevent path traversal + if !full.starts_with(&self.base_path) { + return Err(DomainError::Validation( + "Path traversal not allowed".to_string(), + )); + } + Ok(full) + } +} + +#[async_trait] +impl FileStoragePort for LocalFileStorage { + async fn store_file(&self, path: &str, data: Bytes) -> Result<(), DomainError> { + let full = self.resolve(path)?; + if let Some(parent) = full.parent() { + tokio::fs::create_dir_all(parent) + .await + .map_err(|e| DomainError::Internal(format!("Failed to create dirs: {e}")))?; + } + tokio::fs::write(&full, &data) + .await + .map_err(|e| DomainError::Internal(format!("Failed to write file: {e}")))?; + Ok(()) + } + + async fn read_file(&self, path: &str) -> Result { + let full = self.resolve(path)?; + let data = tokio::fs::read(&full) + .await + .map_err(|e| match e.kind() { + std::io::ErrorKind::NotFound => DomainError::NotFound(path.to_string()), + _ => DomainError::Internal(format!("Failed to read file: {e}")), + })?; + Ok(Bytes::from(data)) + } + + async fn delete_file(&self, path: &str) -> Result<(), DomainError> { + let full = self.resolve(path)?; + match tokio::fs::remove_file(&full).await { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(DomainError::Internal(format!("Failed to delete file: {e}"))), + } + } + + async fn list_directory(&self, path: &str) -> Result, DomainError> { + let full = self.resolve(path)?; + let mut entries = Vec::new(); + let mut read_dir = tokio::fs::read_dir(&full) + .await + .map_err(|e| match e.kind() { + std::io::ErrorKind::NotFound => DomainError::NotFound(path.to_string()), + _ => DomainError::Internal(format!("Failed to read dir: {e}")), + })?; + while let Some(entry) = read_dir + .next_entry() + .await + .map_err(|e| DomainError::Internal(e.to_string()))? + { + let meta = entry + .metadata() + .await + .map_err(|e| DomainError::Internal(e.to_string()))?; + entries.push(FileEntry { + path: entry.file_name().to_string_lossy().to_string(), + size_bytes: meta.len(), + is_directory: meta.is_dir(), + }); + } + Ok(entries) + } + + async fn file_exists(&self, path: &str) -> Result { + let full = self.resolve(path)?; + Ok(full.exists()) + } + + async fn available_space(&self) -> Result { + // Simple stub: return a large number + Ok(u64::MAX) + } +} diff --git a/crates/api-types/Cargo.toml b/crates/api-types/Cargo.toml index a88a94c..6942318 100644 --- a/crates/api-types/Cargo.toml +++ b/crates/api-types/Cargo.toml @@ -4,8 +4,9 @@ version = "0.1.0" edition = "2024" [dependencies] -domain = { workspace = true } -serde = { workspace = true } -uuid = { workspace = true } -chrono = { workspace = true } -utoipa = { workspace = true } +domain = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +uuid = { workspace = true } +chrono = { workspace = true } +utoipa = { workspace = true } diff --git a/crates/api-types/src/requests.rs b/crates/api-types/src/requests.rs index cc45379..b52cea6 100644 --- a/crates/api-types/src/requests.rs +++ b/crates/api-types/src/requests.rs @@ -15,3 +15,28 @@ pub struct LoginRequest { pub struct CreateAlbumRequest { pub title: String, } + +#[derive(Debug, serde::Deserialize, utoipa::ToSchema)] +pub struct AlbumEntryRequest { + pub asset_id: uuid::Uuid, +} + +#[derive(Debug, serde::Deserialize, utoipa::ToSchema)] +pub struct RegisterVolumeRequest { + pub volume_name: String, + pub uri_prefix: String, + pub is_writable: bool, +} + +#[derive(Debug, serde::Deserialize, utoipa::ToSchema)] +pub struct RegisterLibraryPathRequest { + pub volume_id: uuid::Uuid, + pub relative_path: String, + pub owner_id: uuid::Uuid, + pub is_ingest_destination: bool, +} + +#[derive(Debug, serde::Deserialize, utoipa::ToSchema)] +pub struct UpdateMetadataRequest { + pub data: std::collections::HashMap, +} diff --git a/crates/api-types/src/responses.rs b/crates/api-types/src/responses.rs index cb26e9a..2cee155 100644 --- a/crates/api-types/src/responses.rs +++ b/crates/api-types/src/responses.rs @@ -32,6 +32,7 @@ pub struct AlbumResponse { pub title: String, pub description: String, pub creator_id: Uuid, + pub asset_count: usize, pub created_at: DateTime, } @@ -42,7 +43,109 @@ impl AlbumResponse { title: album.title.clone(), description: album.description.clone(), creator_id: *album.creator_user_id.as_uuid(), + asset_count: album.asset_count(), created_at: *album.created_at.as_datetime(), } } } + +#[derive(Debug, serde::Serialize, utoipa::ToSchema)] +pub struct AssetResponse { + pub id: Uuid, + pub asset_type: String, + pub mime_type: String, + pub file_size: u64, + pub is_processed: bool, + pub created_at: DateTime, + pub metadata: std::collections::HashMap, +} + +impl AssetResponse { + pub fn from_domain( + asset: &domain::entities::Asset, + metadata: &domain::value_objects::StructuredData, + ) -> Self { + let meta_map = metadata + .inner() + .iter() + .map(|(k, v)| { + let json_val = match v { + domain::value_objects::MetadataValue::String(s) => { + serde_json::Value::String(s.clone()) + } + domain::value_objects::MetadataValue::Integer(i) => { + serde_json::json!(*i) + } + domain::value_objects::MetadataValue::Float(f) => { + serde_json::json!(*f) + } + domain::value_objects::MetadataValue::Boolean(b) => { + serde_json::Value::Bool(*b) + } + domain::value_objects::MetadataValue::Null => serde_json::Value::Null, + }; + (k.clone(), json_val) + }) + .collect(); + + Self { + id: *asset.asset_id.as_uuid(), + asset_type: format!("{:?}", asset.asset_type), + mime_type: asset.mime_type.clone(), + file_size: asset.file_size, + is_processed: asset.is_processed, + created_at: *asset.created_at.as_datetime(), + metadata: meta_map, + } + } +} + +#[derive(Debug, serde::Serialize, utoipa::ToSchema)] +pub struct TimelineResponse { + pub assets: Vec, + pub total: usize, +} + +#[derive(Debug, serde::Serialize, utoipa::ToSchema)] +pub struct VolumeResponse { + pub id: Uuid, + pub volume_name: String, + pub uri_prefix: String, + pub is_writable: bool, +} + +impl VolumeResponse { + pub fn from_domain(volume: &domain::entities::StorageVolume) -> Self { + Self { + id: *volume.volume_id.as_uuid(), + volume_name: volume.volume_name.clone(), + uri_prefix: volume.uri_prefix.clone(), + is_writable: volume.is_writable, + } + } +} + +#[derive(Debug, serde::Serialize, utoipa::ToSchema)] +pub struct LibraryPathResponse { + pub id: Uuid, + pub volume_id: Uuid, + pub relative_path: String, + pub is_ingest_destination: bool, +} + +impl LibraryPathResponse { + pub fn from_domain(path: &domain::entities::LibraryPath) -> Self { + Self { + id: *path.path_id.as_uuid(), + volume_id: *path.volume_id.as_uuid(), + relative_path: path.relative_path.clone(), + is_ingest_destination: path.is_ingest_destination, + } + } +} + +#[derive(Debug, serde::Serialize, utoipa::ToSchema)] +pub struct IngestResponse { + pub asset: AssetResponse, + pub session_id: Uuid, +} diff --git a/crates/bootstrap/Cargo.toml b/crates/bootstrap/Cargo.toml index 9ef10b8..e540252 100644 --- a/crates/bootstrap/Cargo.toml +++ b/crates/bootstrap/Cargo.toml @@ -21,6 +21,7 @@ adapters-postgres = { path = "../adapters/postgres" } tokio = { workspace = true } anyhow = { workspace = true } +async-trait = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } dotenvy = { workspace = true } diff --git a/crates/bootstrap/src/factory.rs b/crates/bootstrap/src/factory.rs index 231ee71..68f19d7 100644 --- a/crates/bootstrap/src/factory.rs +++ b/crates/bootstrap/src/factory.rs @@ -9,21 +9,32 @@ use tower_http::{ use adapters_auth::{BcryptPasswordHasher, JwtTokenIssuer}; -use adapters_postgres::{PostgresUserRepository, connect, run_migrations}; +use adapters_postgres::{ + PostgresAlbumRepository, PostgresAssetMetadataRepository, PostgresAssetRepository, + PostgresIngestSessionRepository, PostgresLibraryPathRepository, PostgresQuotaRepository, + PostgresStorageVolumeRepository, PostgresUsageLedgerRepository, PostgresUserRepository, + connect, run_migrations, +}; -use adapters_storage::{ObjectStorageAdapter, StorageConfig, build_store}; +use adapters_storage::{LocalFileStorage, ObjectStorageAdapter, StorageConfig, build_store}; -use application::identity::{GetProfileHandler, LoginUserHandler, RegisterUserHandler}; +use application::{ + catalog::{GetAssetHandler, GetTimelineHandler, UpdateMetadataHandler}, + identity::{GetProfileHandler, LoginUserHandler, RegisterUserHandler}, + organization::{CreateAlbumHandler, GetAlbumHandler, ManageAlbumEntriesHandler}, + storage::{IngestAssetHandler, RegisterLibraryPathHandler, RegisterVolumeHandler}, +}; use presentation::{routes::app_router, state::AppState}; use crate::config::Config; +use crate::log_event_publisher::LogEventPublisher; pub async fn build_app(config: &Config) -> Result { let pool = connect(&config.database_url).await?; run_migrations(&pool).await?; - let user_repo = Arc::new(PostgresUserRepository::new(pool)); - + // Identity + let user_repo = Arc::new(PostgresUserRepository::new(pool.clone())); let hasher = Arc::new(BcryptPasswordHasher); let issuer = Arc::new(JwtTokenIssuer::new(&config.jwt_secret)); @@ -35,16 +46,75 @@ pub async fn build_app(config: &Config) -> Result { )); let get_profile_handler = Arc::new(GetProfileHandler::new(user_repo)); + // Object storage let storage_cfg = StorageConfig::from_env()?; let store = build_store(&storage_cfg)?; let storage = Arc::new(ObjectStorageAdapter::new(store, &storage_cfg.prefix)?); + // Repos + let album_repo = Arc::new(PostgresAlbumRepository::new(pool.clone())); + let asset_repo = Arc::new(PostgresAssetRepository::new(pool.clone())); + let metadata_repo = Arc::new(PostgresAssetMetadataRepository::new(pool.clone())); + let volume_repo = Arc::new(PostgresStorageVolumeRepository::new(pool.clone())); + let path_repo = Arc::new(PostgresLibraryPathRepository::new(pool.clone())); + let session_repo = Arc::new(PostgresIngestSessionRepository::new(pool.clone())); + let quota_repo = Arc::new(PostgresQuotaRepository::new(pool.clone())); + let ledger_repo = Arc::new(PostgresUsageLedgerRepository::new(pool.clone())); + let event_publisher: Arc = Arc::new(LogEventPublisher); + + // File storage for ingest + let storage_path = std::env::var("STORAGE_PATH").unwrap_or_else(|_| "./data/media".to_string()); + let file_storage = Arc::new(LocalFileStorage::new(&storage_path)); + + // Album handlers + let create_album_handler = Arc::new(CreateAlbumHandler::new(album_repo.clone())); + let get_album_handler = Arc::new(GetAlbumHandler::new(album_repo.clone())); + let manage_album_entries_handler = Arc::new(ManageAlbumEntriesHandler::new(album_repo)); + + // Asset handlers + let ingest_asset_handler = Arc::new(IngestAssetHandler::new( + session_repo, + path_repo.clone(), + quota_repo, + ledger_repo, + asset_repo.clone(), + file_storage, + event_publisher.clone(), + )); + let get_asset_handler = Arc::new(GetAssetHandler::new( + asset_repo.clone(), + metadata_repo.clone(), + )); + let get_timeline_handler = Arc::new(GetTimelineHandler::new( + asset_repo.clone(), + metadata_repo.clone(), + )); + let update_metadata_handler = Arc::new(UpdateMetadataHandler::new( + asset_repo, + metadata_repo, + event_publisher, + )); + + // Storage handlers + let register_volume_handler = Arc::new(RegisterVolumeHandler::new(volume_repo.clone())); + let register_library_path_handler = + Arc::new(RegisterLibraryPathHandler::new(volume_repo, path_repo)); + let state = AppState::new( register_handler, login_handler, get_profile_handler, issuer, storage, + create_album_handler, + get_album_handler, + manage_album_entries_handler, + ingest_asset_handler, + get_asset_handler, + get_timeline_handler, + update_metadata_handler, + register_volume_handler, + register_library_path_handler, ); let cors = CorsLayer::new() diff --git a/crates/bootstrap/src/lib.rs b/crates/bootstrap/src/lib.rs index 8b13789..b8ba85b 100644 --- a/crates/bootstrap/src/lib.rs +++ b/crates/bootstrap/src/lib.rs @@ -1 +1,3 @@ - +pub mod config; +pub mod factory; +pub mod log_event_publisher; diff --git a/crates/bootstrap/src/log_event_publisher.rs b/crates/bootstrap/src/log_event_publisher.rs new file mode 100644 index 0000000..188842e --- /dev/null +++ b/crates/bootstrap/src/log_event_publisher.rs @@ -0,0 +1,12 @@ +use async_trait::async_trait; +use domain::{errors::DomainError, events::DomainEvent, ports::EventPublisher}; + +pub struct LogEventPublisher; + +#[async_trait] +impl EventPublisher for LogEventPublisher { + async fn publish(&self, event: DomainEvent) -> Result<(), DomainError> { + tracing::info!(?event, "domain event published"); + Ok(()) + } +} diff --git a/crates/bootstrap/src/main.rs b/crates/bootstrap/src/main.rs index 85117f1..a65ff78 100644 --- a/crates/bootstrap/src/main.rs +++ b/crates/bootstrap/src/main.rs @@ -3,6 +3,7 @@ use tracing::info; mod config; mod factory; +mod log_event_publisher; #[tokio::main] async fn main() -> anyhow::Result<()> { @@ -20,8 +21,8 @@ async fn main() -> anyhow::Result<()> { let addr: SocketAddr = format!("{}:{}", config.host, config.port).parse()?; let listener = tokio::net::TcpListener::bind(addr).await?; - info!("🚀 Server running at http://{addr}"); - info!("📖 Scalar docs at http://{addr}/scalar"); + info!("Server running at http://{addr}"); + info!("Scalar docs at http://{addr}/scalar"); axum::serve(listener, app).await?; Ok(()) diff --git a/crates/presentation/Cargo.toml b/crates/presentation/Cargo.toml index 50583da..e04f084 100644 --- a/crates/presentation/Cargo.toml +++ b/crates/presentation/Cargo.toml @@ -7,13 +7,15 @@ edition = "2024" domain = { workspace = true } application = { workspace = true } api-types = { path = "../api-types" } -axum = { workspace = true } +axum = { workspace = true, features = ["multipart"] } tower-http = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } uuid = { workspace = true } chrono = { workspace = true } +bytes = { workspace = true } tracing = { workspace = true } async-trait = { workspace = true } +sha2 = { workspace = true } utoipa = { workspace = true } utoipa-scalar = { workspace = true } diff --git a/crates/presentation/src/handlers/albums.rs b/crates/presentation/src/handlers/albums.rs new file mode 100644 index 0000000..7c986e4 --- /dev/null +++ b/crates/presentation/src/handlers/albums.rs @@ -0,0 +1,72 @@ +use crate::{errors::AppError, extractors::JwtClaims, state::AppState}; +use api_types::{ + requests::{AlbumEntryRequest, CreateAlbumRequest}, + responses::AlbumResponse, +}; +use application::organization::{ + AlbumAction, CreateAlbumCommand, GetAlbumQuery, ManageAlbumEntriesCommand, +}; +use axum::{ + Json, + extract::{Path, State}, + http::StatusCode, +}; +use domain::value_objects::SystemId; + +pub async fn create_album( + State(state): State, + claims: JwtClaims, + Json(req): Json, +) -> Result<(StatusCode, Json), AppError> { + let cmd = CreateAlbumCommand { + title: req.title, + creator_id: claims.user_id, + }; + let album = state.create_album_handler.execute(cmd).await?; + Ok((StatusCode::CREATED, Json(AlbumResponse::from_domain(&album)))) +} + +pub async fn get_album( + State(state): State, + _claims: JwtClaims, + Path((album_id,)): Path<(uuid::Uuid,)>, +) -> Result, AppError> { + let query = GetAlbumQuery { + album_id: SystemId::from_uuid(album_id), + }; + let album = state.get_album_handler.execute(query).await?; + Ok(Json(AlbumResponse::from_domain(&album))) +} + +pub async fn add_entry( + State(state): State, + claims: JwtClaims, + Path((album_id,)): Path<(uuid::Uuid,)>, + Json(req): Json, +) -> Result<(StatusCode, Json), AppError> { + let cmd = ManageAlbumEntriesCommand { + album_id: SystemId::from_uuid(album_id), + action: AlbumAction::Add { + asset_id: SystemId::from_uuid(req.asset_id), + }, + user_id: claims.user_id, + }; + let album = state.manage_album_entries_handler.execute(cmd).await?; + Ok((StatusCode::OK, Json(AlbumResponse::from_domain(&album)))) +} + +pub async fn remove_entry( + State(state): State, + claims: JwtClaims, + Path((album_id, asset_id)): Path<(uuid::Uuid, uuid::Uuid)>, +) -> Result, AppError> { + let cmd = ManageAlbumEntriesCommand { + album_id: SystemId::from_uuid(album_id), + action: AlbumAction::Remove { + asset_id: SystemId::from_uuid(asset_id), + }, + user_id: claims.user_id, + }; + let album = state.manage_album_entries_handler.execute(cmd).await?; + Ok(Json(AlbumResponse::from_domain(&album))) +} diff --git a/crates/presentation/src/handlers/assets.rs b/crates/presentation/src/handlers/assets.rs new file mode 100644 index 0000000..cd88f39 --- /dev/null +++ b/crates/presentation/src/handlers/assets.rs @@ -0,0 +1,173 @@ +use crate::{errors::AppError, extractors::JwtClaims, state::AppState}; +use api_types::{ + requests::UpdateMetadataRequest, + responses::{AssetResponse, IngestResponse, TimelineResponse}, +}; +use application::{ + catalog::{GetAssetQuery, GetTimelineQuery, UpdateMetadataCommand}, + storage::IngestAssetCommand, +}; +use axum::{ + Json, + extract::{Multipart, Path, Query, State}, + http::StatusCode, +}; +use domain::value_objects::{MetadataValue, StructuredData, SystemId}; +use sha2::{Digest, Sha256}; + +#[derive(Debug, serde::Deserialize)] +pub struct TimelineParams { + pub limit: Option, + pub offset: Option, +} + +pub async fn ingest( + State(state): State, + claims: JwtClaims, + mut multipart: Multipart, +) -> Result<(StatusCode, Json), AppError> { + let mut file_data: Option = None; + let mut filename: Option = None; + let mut target_path_id: Option = None; + let mut client_device_id = "web".to_string(); + + while let Some(field) = multipart + .next_field() + .await + .map_err(|e| AppError::from(domain::errors::DomainError::Validation(e.to_string())))? + { + let name = field.name().unwrap_or("").to_string(); + match name.as_str() { + "file" => { + filename = field.file_name().map(|s| s.to_string()); + let data = field + .bytes() + .await + .map_err(|e| { + AppError::from(domain::errors::DomainError::Internal(e.to_string())) + })?; + file_data = Some(data); + } + "target_path_id" => { + let text = field + .text() + .await + .map_err(|e| { + AppError::from(domain::errors::DomainError::Validation(e.to_string())) + })?; + target_path_id = Some(text.parse::().map_err(|e| { + AppError::from(domain::errors::DomainError::Validation(e.to_string())) + })?); + } + "client_device_id" => { + client_device_id = field + .text() + .await + .map_err(|e| { + AppError::from(domain::errors::DomainError::Validation(e.to_string())) + })?; + } + _ => {} + } + } + + let data = file_data + .ok_or_else(|| AppError::from(domain::errors::DomainError::Validation("Missing file field".to_string())))?; + let fname = filename + .ok_or_else(|| AppError::from(domain::errors::DomainError::Validation("Missing filename".to_string())))?; + let path_id = target_path_id + .ok_or_else(|| AppError::from(domain::errors::DomainError::Validation("Missing target_path_id".to_string())))?; + + let mut hasher = Sha256::new(); + hasher.update(&data); + let checksum = format!("{:x}", hasher.finalize()); + + let file_size = data.len() as u64; + + let cmd = IngestAssetCommand { + uploader_id: claims.user_id, + client_device_id, + filename: fname, + checksum, + target_path_id: SystemId::from_uuid(path_id), + file_size, + data, + }; + + let (asset, session) = state.ingest_asset_handler.execute(cmd).await?; + let empty_meta = StructuredData::new(); + + Ok(( + StatusCode::CREATED, + Json(IngestResponse { + asset: AssetResponse::from_domain(&asset, &empty_meta), + session_id: *session.session_id.as_uuid(), + }), + )) +} + +pub async fn get_asset( + State(state): State, + _claims: JwtClaims, + Path((asset_id,)): Path<(uuid::Uuid,)>, +) -> Result, AppError> { + let query = GetAssetQuery { + asset_id: SystemId::from_uuid(asset_id), + }; + let (asset, metadata) = state.get_asset_handler.execute(query).await?; + Ok(Json(AssetResponse::from_domain(&asset, &metadata))) +} + +pub async fn timeline( + State(state): State, + claims: JwtClaims, + Query(params): Query, +) -> Result, AppError> { + let query = GetTimelineQuery { + owner_id: claims.user_id, + limit: params.limit.unwrap_or(50), + offset: params.offset.unwrap_or(0), + }; + let results = state.get_timeline_handler.execute(query).await?; + let total = results.len(); + let assets = results + .iter() + .map(|(asset, meta)| AssetResponse::from_domain(asset, meta)) + .collect(); + Ok(Json(TimelineResponse { assets, total })) +} + +pub async fn update_metadata( + State(state): State, + claims: JwtClaims, + Path((asset_id,)): Path<(uuid::Uuid,)>, + Json(req): Json, +) -> Result, AppError> { + let mut data = StructuredData::new(); + for (k, v) in req.data { + let mv = match v { + serde_json::Value::String(s) => MetadataValue::String(s), + serde_json::Value::Number(n) => { + if let Some(i) = n.as_i64() { + MetadataValue::Integer(i) + } else if let Some(f) = n.as_f64() { + MetadataValue::Float(f) + } else { + MetadataValue::Null + } + } + serde_json::Value::Bool(b) => MetadataValue::Boolean(b), + serde_json::Value::Null => MetadataValue::Null, + _ => MetadataValue::String(v.to_string()), + }; + data.insert(k, mv); + } + + let cmd = UpdateMetadataCommand { + asset_id: SystemId::from_uuid(asset_id), + user_id: claims.user_id, + data, + }; + state.update_metadata_handler.execute(cmd).await?; + Ok(Json(serde_json::json!({ "status": "updated" }))) +} diff --git a/crates/presentation/src/handlers/mod.rs b/crates/presentation/src/handlers/mod.rs index a923656..857bfa8 100644 --- a/crates/presentation/src/handlers/mod.rs +++ b/crates/presentation/src/handlers/mod.rs @@ -1,2 +1,5 @@ +pub mod albums; +pub mod assets; pub mod auth; pub mod health; +pub mod storage; diff --git a/crates/presentation/src/handlers/storage.rs b/crates/presentation/src/handlers/storage.rs new file mode 100644 index 0000000..0720e27 --- /dev/null +++ b/crates/presentation/src/handlers/storage.rs @@ -0,0 +1,37 @@ +use crate::{errors::AppError, extractors::JwtClaims, state::AppState}; +use api_types::{ + requests::{RegisterLibraryPathRequest, RegisterVolumeRequest}, + responses::{LibraryPathResponse, VolumeResponse}, +}; +use application::storage::{RegisterLibraryPathCommand, RegisterVolumeCommand}; +use axum::{Json, extract::State, http::StatusCode}; +use domain::value_objects::SystemId; + +pub async fn register_volume( + State(state): State, + _claims: JwtClaims, + Json(req): Json, +) -> Result<(StatusCode, Json), AppError> { + let cmd = RegisterVolumeCommand { + volume_name: req.volume_name, + uri_prefix: req.uri_prefix, + is_writable: req.is_writable, + }; + let volume = state.register_volume_handler.execute(cmd).await?; + Ok((StatusCode::CREATED, Json(VolumeResponse::from_domain(&volume)))) +} + +pub async fn register_library_path( + State(state): State, + _claims: JwtClaims, + Json(req): Json, +) -> Result<(StatusCode, Json), AppError> { + let cmd = RegisterLibraryPathCommand { + volume_id: SystemId::from_uuid(req.volume_id), + relative_path: req.relative_path, + owner_id: SystemId::from_uuid(req.owner_id), + is_ingest_destination: req.is_ingest_destination, + }; + let path = state.register_library_path_handler.execute(cmd).await?; + Ok((StatusCode::CREATED, Json(LibraryPathResponse::from_domain(&path)))) +} diff --git a/crates/presentation/src/routes.rs b/crates/presentation/src/routes.rs index 1b50871..c6fc81b 100644 --- a/crates/presentation/src/routes.rs +++ b/crates/presentation/src/routes.rs @@ -1,18 +1,32 @@ use crate::{ - handlers::{auth, health}, + handlers::{albums, assets, auth, health, storage}, openapi::openapi_router, state::AppState, }; use axum::{ Router, - routing::{get, post}, + routing::{delete, get, post, put}, }; pub fn api_v1_router() -> Router { Router::new() + // auth .route("/auth/register", post(auth::register)) .route("/auth/login", post(auth::login)) .route("/auth/me", get(auth::me)) + // albums + .route("/albums", post(albums::create_album)) + .route("/albums/:id", get(albums::get_album)) + .route("/albums/:id/entries", post(albums::add_entry)) + .route("/albums/:id/entries/:asset_id", delete(albums::remove_entry)) + // assets + .route("/assets/ingest", post(assets::ingest)) + .route("/assets/timeline", get(assets::timeline)) + .route("/assets/:id", get(assets::get_asset)) + .route("/assets/:id/metadata", put(assets::update_metadata)) + // storage + .route("/storage/volumes", post(storage::register_volume)) + .route("/storage/library-paths", post(storage::register_library_path)) } pub fn app_router() -> Router { diff --git a/crates/presentation/src/state.rs b/crates/presentation/src/state.rs index 2b730f8..90bb2ac 100644 --- a/crates/presentation/src/state.rs +++ b/crates/presentation/src/state.rs @@ -1,4 +1,9 @@ -use application::identity::{GetProfileHandler, LoginUserHandler, RegisterUserHandler}; +use application::{ + catalog::{GetAssetHandler, GetTimelineHandler, UpdateMetadataHandler}, + identity::{GetProfileHandler, LoginUserHandler, RegisterUserHandler}, + organization::{CreateAlbumHandler, GetAlbumHandler, ManageAlbumEntriesHandler}, + storage::{IngestAssetHandler, RegisterLibraryPathHandler, RegisterVolumeHandler}, +}; use std::sync::Arc; use domain::ports::{StoragePort, TokenIssuer}; @@ -10,15 +15,34 @@ pub struct AppState { pub get_profile_handler: Arc, pub token_issuer: Arc, pub storage: Arc, + pub create_album_handler: Arc, + pub get_album_handler: Arc, + pub manage_album_entries_handler: Arc, + pub ingest_asset_handler: Arc, + pub get_asset_handler: Arc, + pub get_timeline_handler: Arc, + pub update_metadata_handler: Arc, + pub register_volume_handler: Arc, + pub register_library_path_handler: Arc, } impl AppState { + #[allow(clippy::too_many_arguments)] pub fn new( register_handler: Arc, login_handler: Arc, get_profile_handler: Arc, token_issuer: Arc, storage: Arc, + create_album_handler: Arc, + get_album_handler: Arc, + manage_album_entries_handler: Arc, + ingest_asset_handler: Arc, + get_asset_handler: Arc, + get_timeline_handler: Arc, + update_metadata_handler: Arc, + register_volume_handler: Arc, + register_library_path_handler: Arc, ) -> Self { Self { register_handler, @@ -26,6 +50,15 @@ impl AppState { get_profile_handler, token_issuer, storage, + create_album_handler, + get_album_handler, + manage_album_entries_handler, + ingest_asset_handler, + get_asset_handler, + get_timeline_handler, + update_metadata_handler, + register_volume_handler, + register_library_path_handler, } } }