diff --git a/libertas_api/migrations/20251102161826_create_album_shares_table.sql b/libertas_api/migrations/20251102161826_create_album_shares_table.sql new file mode 100644 index 0000000..73159a6 --- /dev/null +++ b/libertas_api/migrations/20251102161826_create_album_shares_table.sql @@ -0,0 +1,10 @@ +CREATE TYPE album_permission AS ENUM ('view', 'contribute'); + +CREATE TABLE album_shares ( + album_id UUID NOT NULL REFERENCES albums (id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users (id) ON DELETE CASCADE, + permission album_permission NOT NULL, + PRIMARY KEY (album_id, user_id) +); + +CREATE INDEX idx_album_shares_user_id ON album_shares (user_id); \ No newline at end of file diff --git a/libertas_api/src/factory.rs b/libertas_api/src/factory.rs index a5b61f7..d735322 100644 --- a/libertas_api/src/factory.rs +++ b/libertas_api/src/factory.rs @@ -5,7 +5,8 @@ use libertas_core::{ error::{CoreError, CoreResult}, }; use libertas_infra::factory::{ - build_album_repository, build_database_pool, build_media_repository, build_user_repository, + build_album_repository, build_album_share_repository, build_database_pool, + build_media_repository, build_user_repository, }; use crate::{ @@ -28,6 +29,7 @@ pub async fn build_app_state(config: Config) -> CoreResult { let user_repo = build_user_repository(&config.database, db_pool.clone()).await?; let media_repo = build_media_repository(&config.database, db_pool.clone()).await?; let album_repo = build_album_repository(&config.database, db_pool.clone()).await?; + let album_share_repo = build_album_share_repository(&config.database, db_pool.clone()).await?; let hasher = Arc::new(Argon2Hasher::default()); let tokenizer = Arc::new(JwtGenerator::new(config.jwt_secret.clone())); @@ -40,10 +42,15 @@ pub async fn build_app_state(config: Config) -> CoreResult { let media_service = Arc::new(MediaServiceImpl::new( media_repo.clone(), user_repo.clone(), + album_share_repo.clone(), config.clone(), nats_client.clone(), )); - let album_service = Arc::new(AlbumServiceImpl::new(album_repo, media_repo)); + let album_service = Arc::new(AlbumServiceImpl::new( + album_repo, + media_repo, + album_share_repo, + )); Ok(AppState { user_service, diff --git a/libertas_api/src/handlers/album_handlers.rs b/libertas_api/src/handlers/album_handlers.rs index 02d28dd..5ca1c05 100644 --- a/libertas_api/src/handlers/album_handlers.rs +++ b/libertas_api/src/handlers/album_handlers.rs @@ -3,7 +3,10 @@ use axum::{ extract::{Path, State}, http::StatusCode, }; -use libertas_core::schema::{AddMediaToAlbumData, CreateAlbumData}; +use libertas_core::{ + models::AlbumPermission, + schema::{AddMediaToAlbumData, CreateAlbumData, ShareAlbumData}, +}; use serde::Deserialize; use uuid::Uuid; @@ -57,8 +60,32 @@ async fn add_media_to_album( Ok(StatusCode::OK) } +#[derive(Deserialize)] +pub struct ShareAlbumRequest { + target_user_id: Uuid, + permission: AlbumPermission, +} + +async fn share_album( + State(state): State, + UserId(owner_id): UserId, // The person sharing must be authenticated + Path(album_id): Path, + Json(payload): Json, +) -> Result { + let data = ShareAlbumData { + album_id, + target_user_id: payload.target_user_id, + permission: payload.permission, + }; + + state.album_service.share_album(data, owner_id).await?; + + Ok(StatusCode::OK) +} + pub fn album_routes() -> Router { Router::new() .route("/", axum::routing::post(create_album)) .route("/{album_id}/media", axum::routing::post(add_media_to_album)) + .route("/{album_id}/share", axum::routing::post(share_album)) } diff --git a/libertas_api/src/services/album_service.rs b/libertas_api/src/services/album_service.rs index 32932a9..9ebe9d3 100644 --- a/libertas_api/src/services/album_service.rs +++ b/libertas_api/src/services/album_service.rs @@ -6,7 +6,7 @@ use libertas_core::{ authz, error::{CoreError, CoreResult}, models::Album, - repositories::{AlbumRepository, MediaRepository}, + repositories::{AlbumRepository, AlbumShareRepository, MediaRepository}, schema::{AddMediaToAlbumData, CreateAlbumData, ShareAlbumData}, services::AlbumService, }; @@ -15,25 +15,21 @@ use uuid::Uuid; pub struct AlbumServiceImpl { album_repo: Arc, media_repo: Arc, + album_share_repo: Arc, } impl AlbumServiceImpl { - pub fn new(album_repo: Arc, media_repo: Arc) -> Self { + pub fn new( + album_repo: Arc, + media_repo: Arc, + album_share_repo: Arc, + ) -> Self { Self { album_repo, media_repo, + album_share_repo, } } - - async fn is_album_owner(&self, user_id: Uuid, album_id: Uuid) -> CoreResult { - let album = self - .album_repo - .find_by_id(album_id) - .await? - .ok_or(CoreError::NotFound("Album".to_string(), album_id))?; - - Ok(album.owner_id == user_id) - } } #[async_trait] @@ -66,7 +62,12 @@ impl AlbumService for AlbumServiceImpl { .await? .ok_or(CoreError::NotFound("Album".to_string(), album_id))?; - if !authz::is_owner(user_id, &album) { + let share_permission = self + .album_share_repo + .get_user_permission(album_id, user_id) + .await?; + + if !authz::can_view_album(user_id, &album, share_permission) { return Err(CoreError::Auth("Access denied to album".to_string())); } @@ -80,8 +81,15 @@ impl AlbumService for AlbumServiceImpl { .await? .ok_or(CoreError::NotFound("Album".to_string(), data.album_id))?; - if !authz::is_owner(user_id, &album) { - return Err(CoreError::Auth("User does not own this album".to_string())); + let share_permission = self + .album_share_repo + .get_user_permission(data.album_id, user_id) + .await?; + + if !authz::can_contribute_to_album(user_id, &album, share_permission) { + return Err(CoreError::Auth( + "User does not have permission to add media to this album".to_string(), + )); } for media_id in &data.media_ids { @@ -108,8 +116,27 @@ impl AlbumService for AlbumServiceImpl { self.album_repo.list_by_user(user_id).await } - async fn share_album(&self, _data: ShareAlbumData, _owner_id: Uuid) -> CoreResult<()> { - // This is not part of the MVP, but part of the trait - unimplemented!("Sharing will be implemented in a future phase") + async fn share_album(&self, data: ShareAlbumData, owner_id: Uuid) -> CoreResult<()> { + let album = self + .album_repo + .find_by_id(data.album_id) + .await? + .ok_or(CoreError::NotFound("Album".to_string(), data.album_id))?; + + if !authz::is_owner(owner_id, &album) { + return Err(CoreError::Auth( + "Only the album owner can share the album".to_string(), + )); + } + + if data.target_user_id == owner_id { + return Err(CoreError::Validation( + "Cannot share album with oneself".to_string(), + )); + } + + self.album_share_repo + .create_or_update_share(data.album_id, data.target_user_id, data.permission) + .await } } diff --git a/libertas_api/src/services/media_service.rs b/libertas_api/src/services/media_service.rs index a45e982..7395886 100644 --- a/libertas_api/src/services/media_service.rs +++ b/libertas_api/src/services/media_service.rs @@ -8,7 +8,7 @@ use libertas_core::{ config::Config, error::{CoreError, CoreResult}, models::Media, - repositories::{MediaRepository, UserRepository}, + repositories::{AlbumShareRepository, MediaRepository, UserRepository}, schema::UploadMediaData, services::MediaService, }; @@ -20,6 +20,7 @@ use uuid::Uuid; pub struct MediaServiceImpl { repo: Arc, user_repo: Arc, + album_share_repo: Arc, config: Config, nats_client: async_nats::Client, } @@ -28,12 +29,14 @@ impl MediaServiceImpl { pub fn new( repo: Arc, user_repo: Arc, + album_share_repo: Arc, config: Config, nats_client: async_nats::Client, ) -> Self { Self { repo, user_repo, + album_share_repo, config, nats_client, } @@ -141,11 +144,20 @@ impl MediaService for MediaServiceImpl { .await? .ok_or(CoreError::NotFound("User".to_string(), user_id))?; - if !authz::is_owner(user_id, &media) && !authz::is_admin(&user) { - return Err(CoreError::Auth("Access denied".to_string())); + if authz::is_owner(user_id, &media) || authz::is_admin(&user) { + return Ok(media); } - Ok(media) + let is_shared = self + .album_share_repo + .is_media_in_shared_album(id, user_id) + .await?; + + if is_shared { + return Ok(media); + } + + Err(CoreError::Auth("Access denied".to_string())) } async fn list_user_media(&self, user_id: Uuid) -> CoreResult> { @@ -165,10 +177,19 @@ impl MediaService for MediaServiceImpl { .await? .ok_or(CoreError::NotFound("User".to_string(), user_id))?; - if !authz::is_owner(user_id, &media) && !authz::is_admin(&user) { - return Err(CoreError::Auth("Access denied".to_string())); + if authz::is_owner(user_id, &media) || authz::is_admin(&user) { + return Ok(media.storage_path); } - Ok(media.storage_path) + let is_shared = self + .album_share_repo + .is_media_in_shared_album(id, user_id) + .await?; + + if is_shared { + return Ok(media.storage_path); + } + + Err(CoreError::Auth("Access denied".to_string())) } } diff --git a/libertas_core/src/authz.rs b/libertas_core/src/authz.rs index c2ee716..9044515 100644 --- a/libertas_core/src/authz.rs +++ b/libertas_core/src/authz.rs @@ -1,6 +1,6 @@ use uuid::Uuid; -use crate::models::{Album, Media, Role, User}; +use crate::models::{Album, AlbumPermission, Media, Role, User}; pub trait Ownable { fn owner_id(&self) -> Uuid; @@ -23,3 +23,19 @@ pub fn is_admin(user: &User) -> bool { pub fn is_owner(user_id: Uuid, entity: &impl Ownable) -> bool { user_id == entity.owner_id() } + +pub fn can_view_album( + user_id: Uuid, + album: &Album, + share_permission: Option, +) -> bool { + is_owner(user_id, album) || share_permission.is_some() +} + +pub fn can_contribute_to_album( + user_id: Uuid, + album: &Album, + share_permission: Option, +) -> bool { + is_owner(user_id, album) || share_permission == Some(AlbumPermission::Contribute) +} diff --git a/libertas_core/src/models.rs b/libertas_core/src/models.rs index d56a229..efb8fe7 100644 --- a/libertas_core/src/models.rs +++ b/libertas_core/src/models.rs @@ -1,3 +1,5 @@ +use serde::Deserialize; + #[derive(Debug, Clone, PartialEq, Eq, sqlx::Type)] #[sqlx(rename_all = "lowercase")] #[sqlx(type_name = "TEXT")] @@ -75,7 +77,9 @@ pub struct AlbumMedia { pub media_id: uuid::Uuid, } -#[derive(Clone, Copy)] +#[derive(Debug, Clone, Copy, sqlx::Type, PartialEq, Eq, Deserialize)] +#[sqlx(rename_all = "lowercase")] +#[sqlx(type_name = "album_permission")] pub enum AlbumPermission { View, Contribute, diff --git a/libertas_core/src/repositories.rs b/libertas_core/src/repositories.rs index 3d89c32..6d7bbc7 100644 --- a/libertas_core/src/repositories.rs +++ b/libertas_core/src/repositories.rs @@ -3,7 +3,7 @@ use uuid::Uuid; use crate::{ error::CoreResult, - models::{Album, Media, User}, + models::{Album, AlbumPermission, Media, User}, }; #[async_trait] @@ -37,3 +37,21 @@ pub trait AlbumRepository: Send + Sync { async fn list_by_user(&self, user_id: Uuid) -> CoreResult>; async fn add_media_to_album(&self, album_id: Uuid, media_ids: &[Uuid]) -> CoreResult<()>; } + +#[async_trait] +pub trait AlbumShareRepository: Send + Sync { + async fn create_or_update_share( + &self, + album_id: Uuid, + user_id: Uuid, + permission: AlbumPermission, + ) -> CoreResult<()>; + + async fn get_user_permission( + &self, + album_id: Uuid, + user_id: Uuid, + ) -> CoreResult>; + + async fn is_media_in_shared_album(&self, media_id: Uuid, user_id: Uuid) -> CoreResult; +} diff --git a/libertas_infra/src/factory.rs b/libertas_infra/src/factory.rs index 0139588..a45d007 100644 --- a/libertas_infra/src/factory.rs +++ b/libertas_infra/src/factory.rs @@ -73,3 +73,17 @@ pub async fn build_album_repository( )), } } + +pub async fn build_album_share_repository( + _db_config: &DatabaseConfig, + pool: DatabasePool, +) -> CoreResult> { + match pool { + DatabasePool::Postgres(pg_pool) => Ok(Arc::new( + crate::repositories::album_share_repository::PostgresAlbumShareRepository::new(pg_pool), + )), + DatabasePool::Sqlite(_sqlite_pool) => Err(CoreError::Database( + "Sqlite album share repository not implemented".to_string(), + )), + } +} diff --git a/libertas_infra/src/repositories/album_share_repository.rs b/libertas_infra/src/repositories/album_share_repository.rs new file mode 100644 index 0000000..165307c --- /dev/null +++ b/libertas_infra/src/repositories/album_share_repository.rs @@ -0,0 +1,87 @@ +use async_trait::async_trait; +use libertas_core::{ + error::{CoreError, CoreResult}, + models::AlbumPermission, + repositories::AlbumShareRepository, +}; +use sqlx::PgPool; +use uuid::Uuid; + +#[derive(Clone)] +pub struct PostgresAlbumShareRepository { + pool: PgPool, +} + +impl PostgresAlbumShareRepository { + pub fn new(pool: PgPool) -> Self { + Self { pool } + } +} + +#[async_trait] +impl AlbumShareRepository for PostgresAlbumShareRepository { + async fn create_or_update_share( + &self, + album_id: Uuid, + user_id: Uuid, + permission: AlbumPermission, + ) -> CoreResult<()> { + sqlx::query!( + r#" + INSERT INTO album_shares (album_id, user_id, permission) + VALUES ($1, $2, $3) + ON CONFLICT (album_id, user_id) + DO UPDATE SET permission = $3 + "#, + album_id, + user_id, + permission as AlbumPermission, + ) + .execute(&self.pool) + .await + .map_err(|e| CoreError::Database(e.to_string()))?; + + Ok(()) + } + + async fn get_user_permission( + &self, + album_id: Uuid, + user_id: Uuid, + ) -> CoreResult> { + let result = sqlx::query!( + r#" + SELECT permission as "permission: AlbumPermission" + FROM album_shares + WHERE album_id = $1 AND user_id = $2 + "#, + album_id, + user_id + ) + .fetch_optional(&self.pool) + .await + .map_err(|e| CoreError::Database(e.to_string()))?; + + Ok(result.map(|row| row.permission)) + } + + async fn is_media_in_shared_album(&self, media_id: Uuid, user_id: Uuid) -> CoreResult { + let result = sqlx::query!( + r#" + SELECT EXISTS ( + SELECT 1 + FROM album_media am + JOIN album_shares ash ON am.album_id = ash.album_id + WHERE am.media_id = $1 AND ash.user_id = $2 + ) + "#, + media_id, + user_id + ) + .fetch_one(&self.pool) + .await + .map_err(|e| CoreError::Database(e.to_string()))?; + + Ok(result.exists.unwrap_or(false)) + } +} diff --git a/libertas_infra/src/repositories/mod.rs b/libertas_infra/src/repositories/mod.rs index f1f652d..e754b62 100644 --- a/libertas_infra/src/repositories/mod.rs +++ b/libertas_infra/src/repositories/mod.rs @@ -1,3 +1,4 @@ pub mod album_repository; +pub mod album_share_repository; pub mod media_repository; pub mod user_repository;