diff --git a/Cargo.lock b/Cargo.lock index 24cc04e..9ed2fb4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1468,7 +1468,6 @@ dependencies = [ "domain", "serde", "serde_json", - "sha2", "tower-http", "tracing", "utoipa", diff --git a/crates/application/src/catalog/mod.rs b/crates/application/src/catalog/mod.rs index ce7ab77..d1b1df0 100644 --- a/crates/application/src/catalog/mod.rs +++ b/crates/application/src/catalog/mod.rs @@ -5,3 +5,4 @@ pub use commands::register_asset::{RegisterAssetCommand, RegisterAssetHandler}; pub use commands::update_metadata::{UpdateMetadataCommand, UpdateMetadataHandler}; pub use queries::get_asset::{GetAssetHandler, GetAssetQuery}; pub use queries::get_timeline::{GetTimelineHandler, GetTimelineQuery}; +pub use queries::read_asset_file::{AssetFileResult, ReadAssetFileHandler, ReadAssetFileQuery}; diff --git a/crates/application/src/catalog/queries/get_asset.rs b/crates/application/src/catalog/queries/get_asset.rs index 09fbdd2..e2b187b 100644 --- a/crates/application/src/catalog/queries/get_asset.rs +++ b/crates/application/src/catalog/queries/get_asset.rs @@ -10,6 +10,7 @@ use std::sync::Arc; #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct GetAssetQuery { pub asset_id: SystemId, + pub user_id: SystemId, } pub struct GetAssetHandler { @@ -38,6 +39,10 @@ impl GetAssetHandler { .await? .ok_or_else(|| DomainError::NotFound(format!("Asset {} not found", query.asset_id)))?; + if asset.owner_user_id != query.user_id { + return Err(DomainError::Forbidden("Access denied".to_string())); + } + let layers = self.metadata_repo.find_by_asset(&asset.asset_id).await?; let resolved = resolve_metadata(&layers); diff --git a/crates/application/src/catalog/queries/mod.rs b/crates/application/src/catalog/queries/mod.rs index 917e934..3ca1429 100644 --- a/crates/application/src/catalog/queries/mod.rs +++ b/crates/application/src/catalog/queries/mod.rs @@ -1,2 +1,3 @@ pub mod get_asset; pub mod get_timeline; +pub mod read_asset_file; diff --git a/crates/application/src/catalog/queries/read_asset_file.rs b/crates/application/src/catalog/queries/read_asset_file.rs new file mode 100644 index 0000000..dc14404 --- /dev/null +++ b/crates/application/src/catalog/queries/read_asset_file.rs @@ -0,0 +1,62 @@ +use bytes::Bytes; +use domain::{ + errors::DomainError, + ports::{AssetRepository, FileStoragePort}, + value_objects::SystemId, +}; +use std::sync::Arc; + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct ReadAssetFileQuery { + pub asset_id: SystemId, +} + +pub struct AssetFileResult { + pub data: Bytes, + pub mime_type: String, + pub filename: String, +} + +pub struct ReadAssetFileHandler { + asset_repo: Arc, + file_storage: Arc, +} + +impl ReadAssetFileHandler { + pub fn new( + asset_repo: Arc, + file_storage: Arc, + ) -> Self { + Self { + asset_repo, + file_storage, + } + } + + pub async fn execute(&self, query: ReadAssetFileQuery) -> Result { + let asset = self + .asset_repo + .find_by_id(&query.asset_id) + .await? + .ok_or_else(|| DomainError::NotFound(format!("Asset {} not found", query.asset_id)))?; + + let data = self + .file_storage + .read_file(&asset.source_reference.relative_path) + .await?; + + let filename = asset + .source_reference + .relative_path + .rsplit('/') + .next() + .unwrap_or(&asset.source_reference.relative_path) + .to_string(); + + Ok(AssetFileResult { + data, + mime_type: asset.mime_type, + filename, + }) + } +} diff --git a/crates/application/src/organization/commands/manage_album_entries.rs b/crates/application/src/organization/commands/manage_album_entries.rs index 75fa96b..85aedc1 100644 --- a/crates/application/src/organization/commands/manage_album_entries.rs +++ b/crates/application/src/organization/commands/manage_album_entries.rs @@ -32,6 +32,10 @@ impl ManageAlbumEntriesHandler { .await? .ok_or_else(|| DomainError::NotFound(format!("Album {} not found", cmd.album_id)))?; + if album.creator_user_id != cmd.user_id { + return Err(DomainError::Forbidden("Access denied".to_string())); + } + match cmd.action { AlbumAction::Add { asset_id } => album.add_asset(asset_id, cmd.user_id)?, AlbumAction::Remove { asset_id } => album.remove_asset(&asset_id)?, diff --git a/crates/application/src/organization/queries/get_album.rs b/crates/application/src/organization/queries/get_album.rs index 6c1e32c..f159b68 100644 --- a/crates/application/src/organization/queries/get_album.rs +++ b/crates/application/src/organization/queries/get_album.rs @@ -6,6 +6,7 @@ use std::sync::Arc; #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct GetAlbumQuery { pub album_id: SystemId, + pub user_id: SystemId, } pub struct GetAlbumHandler { @@ -18,9 +19,16 @@ impl GetAlbumHandler { } pub async fn execute(&self, query: GetAlbumQuery) -> Result { - self.album_repo + let album = self + .album_repo .find_by_id(&query.album_id) .await? - .ok_or_else(|| DomainError::NotFound(format!("Album {} not found", query.album_id))) + .ok_or_else(|| DomainError::NotFound(format!("Album {} not found", query.album_id)))?; + + if album.creator_user_id != query.user_id { + return Err(DomainError::Forbidden("Access denied".to_string())); + } + + Ok(album) } } diff --git a/crates/application/src/storage/commands/ingest_asset.rs b/crates/application/src/storage/commands/ingest_asset.rs index 8993db3..9f5599a 100644 --- a/crates/application/src/storage/commands/ingest_asset.rs +++ b/crates/application/src/storage/commands/ingest_asset.rs @@ -11,6 +11,7 @@ use domain::{ }, value_objects::{Checksum, DateTimeStamp, SystemId}, }; +use sha2::{Digest, Sha256}; use std::sync::Arc; #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] @@ -18,7 +19,6 @@ pub struct IngestAssetCommand { pub uploader_id: SystemId, pub client_device_id: String, pub filename: String, - pub checksum: String, pub target_path_id: SystemId, pub file_size: u64, #[serde(skip)] @@ -60,7 +60,10 @@ impl IngestAssetHandler { &self, cmd: IngestAssetCommand, ) -> Result<(Asset, IngestSession), DomainError> { - let checksum = Checksum::new(&cmd.checksum)?; + let mut hasher = Sha256::new(); + hasher.update(&cmd.data); + let checksum_hex = format!("{:x}", hasher.finalize()); + let checksum = Checksum::new(checksum_hex)?; let path = self .path_repo diff --git a/crates/application/tests/catalog/queries/get_asset.rs b/crates/application/tests/catalog/queries/get_asset.rs index c815e5d..c4f197e 100644 --- a/crates/application/tests/catalog/queries/get_asset.rs +++ b/crates/application/tests/catalog/queries/get_asset.rs @@ -16,12 +16,13 @@ async fn returns_asset_with_resolved_metadata() { relative_path: "photos/img.jpg".into(), checksum: Checksum::new("a".repeat(64)).unwrap(), }; + let owner = SystemId::new(); let asset = Asset::new( source, AssetType::Image, "image/jpeg", 1024, - SystemId::new(), + owner, ); asset_repo.save(&asset).await.unwrap(); @@ -42,6 +43,7 @@ async fn returns_asset_with_resolved_metadata() { let (returned, resolved) = handler .execute(GetAssetQuery { asset_id: asset.asset_id, + user_id: owner, }) .await .unwrap(); @@ -62,8 +64,35 @@ async fn rejects_nonexistent() { let result = handler .execute(GetAssetQuery { asset_id: SystemId::new(), + user_id: SystemId::new(), }) .await; assert!(matches!(result, Err(DomainError::NotFound(_)))); } + +#[tokio::test] +async fn rejects_forbidden_access() { + let asset_repo = Arc::new(InMemoryAssetRepository::new()); + let meta_repo = Arc::new(InMemoryAssetMetadataRepository::new()); + + let source = SourceReference { + volume_id: SystemId::new(), + relative_path: "photos/img.jpg".into(), + checksum: Checksum::new("a".repeat(64)).unwrap(), + }; + let owner = SystemId::new(); + let asset = Asset::new(source, AssetType::Image, "image/jpeg", 1024, owner); + asset_repo.save(&asset).await.unwrap(); + + let handler = GetAssetHandler::new(asset_repo, meta_repo); + let other_user = SystemId::new(); + let result = handler + .execute(GetAssetQuery { + asset_id: asset.asset_id, + user_id: other_user, + }) + .await; + + assert!(matches!(result, Err(DomainError::Forbidden(_)))); +} diff --git a/crates/application/tests/catalog/queries/mod.rs b/crates/application/tests/catalog/queries/mod.rs index 4955b19..d26d657 100644 --- a/crates/application/tests/catalog/queries/mod.rs +++ b/crates/application/tests/catalog/queries/mod.rs @@ -1,2 +1,3 @@ mod get_asset; mod get_timeline; +mod read_asset_file; diff --git a/crates/application/tests/catalog/queries/read_asset_file.rs b/crates/application/tests/catalog/queries/read_asset_file.rs new file mode 100644 index 0000000..31c1669 --- /dev/null +++ b/crates/application/tests/catalog/queries/read_asset_file.rs @@ -0,0 +1,55 @@ +use application::catalog::{ReadAssetFileHandler, ReadAssetFileQuery}; +use application::testing::{InMemoryAssetRepository, InMemoryFileStorage}; +use bytes::Bytes; +use domain::catalog::entities::{Asset, AssetType, SourceReference}; +use domain::errors::DomainError; +use domain::ports::{AssetRepository, FileStoragePort}; +use domain::value_objects::{Checksum, SystemId}; +use std::sync::Arc; + +#[tokio::test] +async fn reads_file_successfully() { + let asset_repo = Arc::new(InMemoryAssetRepository::new()); + let file_storage = Arc::new(InMemoryFileStorage::new()); + + let source = SourceReference { + volume_id: SystemId::new(), + relative_path: "photos/inbox/cat.jpg".into(), + checksum: Checksum::new("a".repeat(64)).unwrap(), + }; + let asset = Asset::new(source, AssetType::Image, "image/jpeg", 512, SystemId::new()); + asset_repo.save(&asset).await.unwrap(); + + let file_data = Bytes::from(vec![0xFFu8; 512]); + file_storage + .store_file("photos/inbox/cat.jpg", file_data.clone()) + .await + .unwrap(); + + let handler = ReadAssetFileHandler::new(asset_repo, file_storage); + let result = handler + .execute(ReadAssetFileQuery { + asset_id: asset.asset_id, + }) + .await + .unwrap(); + + assert_eq!(result.data, file_data); + assert_eq!(result.mime_type, "image/jpeg"); + assert_eq!(result.filename, "cat.jpg"); +} + +#[tokio::test] +async fn rejects_nonexistent_asset() { + let asset_repo = Arc::new(InMemoryAssetRepository::new()); + let file_storage = Arc::new(InMemoryFileStorage::new()); + + let handler = ReadAssetFileHandler::new(asset_repo, file_storage); + let result = handler + .execute(ReadAssetFileQuery { + asset_id: SystemId::new(), + }) + .await; + + assert!(matches!(result, Err(DomainError::NotFound(_)))); +} diff --git a/crates/application/tests/organization/commands/manage_album_entries.rs b/crates/application/tests/organization/commands/manage_album_entries.rs index d2563f9..5e0d8e2 100644 --- a/crates/application/tests/organization/commands/manage_album_entries.rs +++ b/crates/application/tests/organization/commands/manage_album_entries.rs @@ -111,3 +111,23 @@ async fn rejects_duplicate_add() { .await; assert!(matches!(result, Err(DomainError::Conflict(_)))); } + +#[tokio::test] +async fn rejects_forbidden_access() { + let repo = Arc::new(InMemoryAlbumRepository::new()); + let creator = SystemId::new(); + let album_id = setup_album(&repo, creator).await; + + let handler = ManageAlbumEntriesHandler::new(repo); + let other_user = SystemId::new(); + let result = handler + .execute(ManageAlbumEntriesCommand { + album_id, + action: AlbumAction::Add { + asset_id: SystemId::new(), + }, + user_id: other_user, + }) + .await; + assert!(matches!(result, Err(DomainError::Forbidden(_)))); +} diff --git a/crates/application/tests/organization/queries/get_album.rs b/crates/application/tests/organization/queries/get_album.rs index 191cd51..eaa5603 100644 --- a/crates/application/tests/organization/queries/get_album.rs +++ b/crates/application/tests/organization/queries/get_album.rs @@ -24,6 +24,7 @@ async fn returns_album() { let found = query_handler .execute(GetAlbumQuery { album_id: album.album_id, + user_id: creator, }) .await .unwrap(); @@ -39,7 +40,33 @@ async fn rejects_nonexistent() { let result = handler .execute(GetAlbumQuery { album_id: SystemId::new(), + user_id: SystemId::new(), }) .await; assert!(matches!(result, Err(DomainError::NotFound(_)))); } + +#[tokio::test] +async fn rejects_forbidden_access() { + let repo = Arc::new(InMemoryAlbumRepository::new()); + let creator = SystemId::new(); + + let create_handler = CreateAlbumHandler::new(repo.clone()); + let album = create_handler + .execute(CreateAlbumCommand { + title: "Private Album".into(), + creator_id: creator, + }) + .await + .unwrap(); + + let query_handler = GetAlbumHandler::new(repo); + let other_user = SystemId::new(); + let result = query_handler + .execute(GetAlbumQuery { + album_id: album.album_id, + user_id: other_user, + }) + .await; + assert!(matches!(result, Err(DomainError::Forbidden(_)))); +} diff --git a/crates/application/tests/storage/commands/ingest_asset.rs b/crates/application/tests/storage/commands/ingest_asset.rs index 1122fc9..2b5cd49 100644 --- a/crates/application/tests/storage/commands/ingest_asset.rs +++ b/crates/application/tests/storage/commands/ingest_asset.rs @@ -77,9 +77,6 @@ impl Harness { } } -fn valid_checksum() -> String { - "a".repeat(64) -} #[tokio::test] async fn ingests_successfully() { @@ -93,7 +90,6 @@ async fn ingests_successfully() { uploader_id: user, client_device_id: "iphone-1".into(), filename: "photo.jpg".into(), - checksum: valid_checksum(), target_path_id: path_id, file_size: 1024, data: Bytes::from(vec![0u8; 1024]), @@ -124,7 +120,6 @@ async fn rejects_quota_exceeded() { uploader_id: user, client_device_id: "iphone-1".into(), filename: "big.jpg".into(), - checksum: valid_checksum(), target_path_id: path_id, file_size: 1024, data: Bytes::from(vec![0u8; 1024]), @@ -134,28 +129,6 @@ async fn rejects_quota_exceeded() { assert!(matches!(result, Err(DomainError::QuotaExceeded(_)))); } -#[tokio::test] -async fn rejects_invalid_checksum() { - let h = Harness::new(); - let user = SystemId::new(); - let path_id = h.setup_volume_and_path(user).await; - - let handler = h.ingest_handler(); - let result = handler - .execute(IngestAssetCommand { - uploader_id: user, - client_device_id: "iphone-1".into(), - filename: "photo.jpg".into(), - checksum: "tooshort".into(), - target_path_id: path_id, - file_size: 1024, - data: Bytes::from(vec![0u8; 1024]), - }) - .await; - - assert!(matches!(result, Err(DomainError::Validation(_)))); -} - #[tokio::test] async fn rejects_non_ingest_path() { let h = Harness::new(); @@ -187,7 +160,6 @@ async fn rejects_non_ingest_path() { uploader_id: user, client_device_id: "iphone-1".into(), filename: "photo.jpg".into(), - checksum: valid_checksum(), target_path_id: path.path_id, file_size: 1024, data: Bytes::from(vec![0u8; 1024]), diff --git a/crates/domain/src/common/value_objects/structured_data.rs b/crates/domain/src/common/value_objects/structured_data.rs index 9eb276f..029e29d 100644 --- a/crates/domain/src/common/value_objects/structured_data.rs +++ b/crates/domain/src/common/value_objects/structured_data.rs @@ -62,3 +62,34 @@ impl Default for StructuredData { Self::new() } } + +impl From<&MetadataValue> for serde_json::Value { + fn from(val: &MetadataValue) -> Self { + match val { + MetadataValue::String(s) => serde_json::Value::String(s.clone()), + MetadataValue::Integer(i) => serde_json::json!(*i), + MetadataValue::Float(f) => serde_json::json!(*f), + MetadataValue::Boolean(b) => serde_json::Value::Bool(*b), + MetadataValue::Null => serde_json::Value::Null, + } + } +} + +impl From for MetadataValue { + fn from(val: serde_json::Value) -> Self { + match val { + 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), + _ => MetadataValue::Null, + } + } +} diff --git a/crates/presentation/Cargo.toml b/crates/presentation/Cargo.toml index e04f084..9330938 100644 --- a/crates/presentation/Cargo.toml +++ b/crates/presentation/Cargo.toml @@ -16,6 +16,5 @@ 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 index 46fede3..d80e20a 100644 --- a/crates/presentation/src/handlers/albums.rs +++ b/crates/presentation/src/handlers/albums.rs @@ -31,11 +31,12 @@ pub async fn create_album( pub async fn get_album( State(state): State, - _claims: JwtClaims, + claims: JwtClaims, Path((album_id,)): Path<(uuid::Uuid,)>, ) -> Result, AppError> { let query = GetAlbumQuery { album_id: SystemId::from_uuid(album_id), + user_id: claims.user_id, }; let album = state.get_album_handler.execute(query).await?; Ok(Json(AlbumResponse::from_domain(&album))) diff --git a/crates/presentation/src/handlers/assets.rs b/crates/presentation/src/handlers/assets.rs index cd3b8b1..2b75157 100644 --- a/crates/presentation/src/handlers/assets.rs +++ b/crates/presentation/src/handlers/assets.rs @@ -15,7 +15,6 @@ use axum::{ response::Response, }; use domain::value_objects::{MetadataValue, StructuredData, SystemId}; -use sha2::{Digest, Sha256}; #[derive(Debug, serde::Deserialize)] pub struct TimelineParams { @@ -80,17 +79,12 @@ pub async fn ingest( )) })?; - 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, @@ -110,11 +104,12 @@ pub async fn ingest( pub async fn get_asset( State(state): State, - _claims: JwtClaims, + claims: JwtClaims, Path((asset_id,)): Path<(uuid::Uuid,)>, ) -> Result, AppError> { let query = GetAssetQuery { asset_id: SystemId::from_uuid(asset_id), + user_id: claims.user_id, }; let (asset, metadata) = state.get_asset_handler.execute(query).await?; Ok(Json(AssetResponse::from_domain(&asset, &metadata)))