From 3399e2544190a17bbdad4273074804686ae6c878 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 31 May 2026 10:50:28 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20add=20sharing=20endpoints=20=E2=80=94?= =?UTF-8?q?=20share,=20link,=20revoke,=20public=20access?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../postgres/migrations/006_sharing.sql | 49 ++ crates/adapters/postgres/src/lib.rs | 4 + .../adapters/postgres/src/share_repository.rs | 417 ++++++++++++++++++ .../src/visibility_filter_repository.rs | 83 ++++ crates/api-types/src/requests.rs | 18 + crates/api-types/src/responses.rs | 64 +++ crates/bootstrap/src/factory.rs | 35 +- crates/presentation/src/handlers/mod.rs | 1 + crates/presentation/src/handlers/sharing.rs | 128 ++++++ crates/presentation/src/routes.rs | 7 +- crates/presentation/src/state.rs | 13 + 11 files changed, 814 insertions(+), 5 deletions(-) create mode 100644 crates/adapters/postgres/migrations/006_sharing.sql create mode 100644 crates/adapters/postgres/src/share_repository.rs create mode 100644 crates/adapters/postgres/src/visibility_filter_repository.rs create mode 100644 crates/presentation/src/handlers/sharing.rs diff --git a/crates/adapters/postgres/migrations/006_sharing.sql b/crates/adapters/postgres/migrations/006_sharing.sql new file mode 100644 index 0000000..8e53684 --- /dev/null +++ b/crates/adapters/postgres/migrations/006_sharing.sql @@ -0,0 +1,49 @@ +CREATE TABLE share_scopes ( + scope_id UUID PRIMARY KEY, + scope_type TEXT NOT NULL, + shareable_type TEXT NOT NULL, + shareable_id UUID NOT NULL, + created_by_user_id UUID NOT NULL REFERENCES users(id), + expires_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_share_scopes_shareable ON share_scopes(shareable_id); + +CREATE TABLE share_targets ( + scope_id UUID NOT NULL REFERENCES share_scopes(scope_id) ON DELETE CASCADE, + target_type TEXT NOT NULL, + target_id UUID NOT NULL, + role_id UUID NOT NULL, + PRIMARY KEY (scope_id, target_id) +); + +CREATE TABLE share_links ( + scope_id UUID NOT NULL REFERENCES share_scopes(scope_id) ON DELETE CASCADE, + token TEXT UNIQUE NOT NULL, + expires_at TIMESTAMPTZ, + access_level TEXT NOT NULL DEFAULT 'view_only', + is_active BOOLEAN NOT NULL DEFAULT true, + max_uses INTEGER, + use_count INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (scope_id) +); + +CREATE INDEX idx_share_links_token ON share_links(token); + +CREATE TABLE invite_codes ( + code_id UUID PRIMARY KEY, + scope_id UUID NOT NULL REFERENCES share_scopes(scope_id) ON DELETE CASCADE, + created_by_user_id UUID NOT NULL REFERENCES users(id), + expires_at TIMESTAMPTZ, + max_uses INTEGER, + use_count INTEGER NOT NULL DEFAULT 0, + assigned_role_id UUID NOT NULL +); + +CREATE TABLE visibility_filters ( + filter_id UUID PRIMARY KEY, + scope_id UUID NOT NULL REFERENCES share_scopes(scope_id) ON DELETE CASCADE, + role_id UUID NOT NULL, + hidden_fields TEXT[] NOT NULL DEFAULT '{}' +); diff --git a/crates/adapters/postgres/src/lib.rs b/crates/adapters/postgres/src/lib.rs index 2d663bc..610b55b 100644 --- a/crates/adapters/postgres/src/lib.rs +++ b/crates/adapters/postgres/src/lib.rs @@ -6,8 +6,10 @@ pub mod asset_repository; pub mod ingest_session_repository; pub mod library_path_repository; pub mod quota_repository; +pub mod share_repository; pub mod storage_volume_repository; pub mod user_repository; +pub mod visibility_filter_repository; pub use db::{PgPool, connect, run_migrations}; @@ -17,5 +19,7 @@ pub use asset_repository::PostgresAssetRepository; pub use ingest_session_repository::PostgresIngestSessionRepository; pub use library_path_repository::PostgresLibraryPathRepository; pub use quota_repository::{PostgresQuotaRepository, PostgresUsageLedgerRepository}; +pub use share_repository::PostgresShareRepository; pub use storage_volume_repository::PostgresStorageVolumeRepository; pub use user_repository::PostgresUserRepository; +pub use visibility_filter_repository::PostgresVisibilityFilterRepository; diff --git a/crates/adapters/postgres/src/share_repository.rs b/crates/adapters/postgres/src/share_repository.rs new file mode 100644 index 0000000..cae6cfc --- /dev/null +++ b/crates/adapters/postgres/src/share_repository.rs @@ -0,0 +1,417 @@ +use crate::db::PgPool; +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use domain::{ + entities::{ + InviteCode, LinkAccessLevel, ScopeType, ShareLink, ShareScope, ShareTarget, ShareableType, + TargetType, + }, + errors::DomainError, + ports::ShareRepository, + value_objects::{DateTimeStamp, SystemId}, +}; +use uuid::Uuid; + +// --- String constants for DB enum mapping --- + +const SCOPE_PRIVATE: &str = "private"; +const SCOPE_USER: &str = "user"; +const SCOPE_GROUP: &str = "group"; +const SCOPE_LINK: &str = "link"; +const SCOPE_PUBLIC: &str = "public"; + +const SHAREABLE_ASSET: &str = "asset"; +const SHAREABLE_ALBUM: &str = "album"; +const SHAREABLE_COLLECTION: &str = "collection"; +const SHAREABLE_DIRECTORY: &str = "directory"; + +const TARGET_USER: &str = "user"; +const TARGET_GROUP: &str = "group"; + +const ACCESS_VIEW_ONLY: &str = "view_only"; +const ACCESS_LIMITED_SEARCH: &str = "limited_search"; + +// --- Row structs --- + +#[derive(sqlx::FromRow)] +struct ShareScopeRow { + scope_id: Uuid, + scope_type: String, + shareable_type: String, + shareable_id: Uuid, + created_by_user_id: Uuid, + expires_at: Option>, + created_at: DateTime, +} + +#[derive(sqlx::FromRow)] +struct ShareTargetRow { + scope_id: Uuid, + target_type: String, + target_id: Uuid, + role_id: Uuid, +} + +#[derive(sqlx::FromRow)] +struct ShareLinkRow { + scope_id: Uuid, + token: String, + expires_at: Option>, + access_level: String, + is_active: bool, + max_uses: Option, + use_count: i32, +} + +#[derive(sqlx::FromRow)] +struct InviteCodeRow { + code_id: Uuid, + scope_id: Uuid, + created_by_user_id: Uuid, + expires_at: Option>, + max_uses: Option, + use_count: i32, + assigned_role_id: Uuid, +} + +// --- Enum conversions --- + +fn scope_type_to_str(t: ScopeType) -> &'static str { + match t { + ScopeType::Private => SCOPE_PRIVATE, + ScopeType::User => SCOPE_USER, + ScopeType::Group => SCOPE_GROUP, + ScopeType::Link => SCOPE_LINK, + ScopeType::Public => SCOPE_PUBLIC, + } +} + +fn scope_type_from_str(s: &str) -> Result { + match s { + SCOPE_PRIVATE => Ok(ScopeType::Private), + SCOPE_USER => Ok(ScopeType::User), + SCOPE_GROUP => Ok(ScopeType::Group), + SCOPE_LINK => Ok(ScopeType::Link), + SCOPE_PUBLIC => Ok(ScopeType::Public), + _ => Err(DomainError::Internal(format!("Unknown scope_type: {s}"))), + } +} + +fn shareable_type_to_str(t: ShareableType) -> &'static str { + match t { + ShareableType::Asset => SHAREABLE_ASSET, + ShareableType::Album => SHAREABLE_ALBUM, + ShareableType::Collection => SHAREABLE_COLLECTION, + ShareableType::Directory => SHAREABLE_DIRECTORY, + } +} + +fn shareable_type_from_str(s: &str) -> Result { + match s { + SHAREABLE_ASSET => Ok(ShareableType::Asset), + SHAREABLE_ALBUM => Ok(ShareableType::Album), + SHAREABLE_COLLECTION => Ok(ShareableType::Collection), + SHAREABLE_DIRECTORY => Ok(ShareableType::Directory), + _ => Err(DomainError::Internal(format!( + "Unknown shareable_type: {s}" + ))), + } +} + +fn target_type_to_str(t: TargetType) -> &'static str { + match t { + TargetType::User => TARGET_USER, + TargetType::Group => TARGET_GROUP, + } +} + +fn target_type_from_str(s: &str) -> Result { + match s { + TARGET_USER => Ok(TargetType::User), + TARGET_GROUP => Ok(TargetType::Group), + _ => Err(DomainError::Internal(format!("Unknown target_type: {s}"))), + } +} + +fn access_level_to_str(t: LinkAccessLevel) -> &'static str { + match t { + LinkAccessLevel::ViewOnly => ACCESS_VIEW_ONLY, + LinkAccessLevel::LimitedSearch => ACCESS_LIMITED_SEARCH, + } +} + +fn access_level_from_str(s: &str) -> Result { + match s { + ACCESS_VIEW_ONLY => Ok(LinkAccessLevel::ViewOnly), + ACCESS_LIMITED_SEARCH => Ok(LinkAccessLevel::LimitedSearch), + _ => Err(DomainError::Internal(format!("Unknown access_level: {s}"))), + } +} + +// --- Row → Domain conversions --- + +impl TryFrom for ShareScope { + type Error = DomainError; + + fn try_from(r: ShareScopeRow) -> Result { + Ok(Self { + scope_id: SystemId::from_uuid(r.scope_id), + scope_type: scope_type_from_str(&r.scope_type)?, + shareable_type: shareable_type_from_str(&r.shareable_type)?, + shareable_id: SystemId::from_uuid(r.shareable_id), + created_by_user_id: SystemId::from_uuid(r.created_by_user_id), + expires_at: r.expires_at.map(DateTimeStamp::from_datetime), + created_at: DateTimeStamp::from_datetime(r.created_at), + }) + } +} + +impl TryFrom for ShareTarget { + type Error = DomainError; + + fn try_from(r: ShareTargetRow) -> Result { + Ok(Self { + scope_id: SystemId::from_uuid(r.scope_id), + target_type: target_type_from_str(&r.target_type)?, + target_id: SystemId::from_uuid(r.target_id), + role_id: SystemId::from_uuid(r.role_id), + }) + } +} + +impl TryFrom for ShareLink { + type Error = DomainError; + + fn try_from(r: ShareLinkRow) -> Result { + Ok(Self { + scope_id: SystemId::from_uuid(r.scope_id), + token: r.token, + expires_at: r.expires_at.map(DateTimeStamp::from_datetime), + access_level: access_level_from_str(&r.access_level)?, + is_active: r.is_active, + max_uses: r.max_uses.map(|v| v as u32), + use_count: r.use_count as u32, + }) + } +} + +impl TryFrom for InviteCode { + type Error = DomainError; + + fn try_from(r: InviteCodeRow) -> Result { + Ok(Self { + code_id: SystemId::from_uuid(r.code_id), + scope_id: SystemId::from_uuid(r.scope_id), + created_by_user_id: SystemId::from_uuid(r.created_by_user_id), + expires_at: r.expires_at.map(DateTimeStamp::from_datetime), + max_uses: r.max_uses.map(|v| v as u32), + use_count: r.use_count as u32, + assigned_role_id: SystemId::from_uuid(r.assigned_role_id), + }) + } +} + +// --- Repository --- + +pub struct PostgresShareRepository { + pool: PgPool, +} + +impl PostgresShareRepository { + pub fn new(pool: PgPool) -> Self { + Self { pool } + } +} + +#[async_trait] +impl ShareRepository for PostgresShareRepository { + // --- Scopes --- + + async fn save_scope(&self, scope: &ShareScope) -> Result<(), DomainError> { + sqlx::query( + "INSERT INTO share_scopes (scope_id, scope_type, shareable_type, shareable_id, created_by_user_id, expires_at, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7) + ON CONFLICT (scope_id) DO UPDATE SET + scope_type = EXCLUDED.scope_type, + expires_at = EXCLUDED.expires_at", + ) + .bind(*scope.scope_id.as_uuid()) + .bind(scope_type_to_str(scope.scope_type)) + .bind(shareable_type_to_str(scope.shareable_type)) + .bind(*scope.shareable_id.as_uuid()) + .bind(*scope.created_by_user_id.as_uuid()) + .bind(scope.expires_at.as_ref().map(|d| d.as_datetime()).copied()) + .bind(scope.created_at.as_datetime()) + .execute(&self.pool) + .await + .map_err(|e| DomainError::Internal(e.to_string()))?; + Ok(()) + } + + async fn find_scope_by_id(&self, id: &SystemId) -> Result, DomainError> { + let row = sqlx::query_as::<_, ShareScopeRow>( + "SELECT scope_id, scope_type, shareable_type, shareable_id, created_by_user_id, expires_at, created_at + FROM share_scopes WHERE scope_id = $1", + ) + .bind(*id.as_uuid()) + .fetch_optional(&self.pool) + .await + .map_err(|e| DomainError::Internal(e.to_string()))?; + + row.map(TryInto::try_into).transpose() + } + + async fn find_scopes_for_resource( + &self, + resource_id: &SystemId, + ) -> Result, DomainError> { + let rows = sqlx::query_as::<_, ShareScopeRow>( + "SELECT scope_id, scope_type, shareable_type, shareable_id, created_by_user_id, expires_at, created_at + FROM share_scopes WHERE shareable_id = $1", + ) + .bind(*resource_id.as_uuid()) + .fetch_all(&self.pool) + .await + .map_err(|e| DomainError::Internal(e.to_string()))?; + + rows.into_iter().map(TryInto::try_into).collect() + } + + async fn delete_scope(&self, id: &SystemId) -> Result<(), DomainError> { + sqlx::query("DELETE FROM share_scopes WHERE scope_id = $1") + .bind(*id.as_uuid()) + .execute(&self.pool) + .await + .map_err(|e| DomainError::Internal(e.to_string()))?; + Ok(()) + } + + // --- Targets --- + + async fn save_target(&self, target: &ShareTarget) -> Result<(), DomainError> { + sqlx::query( + "INSERT INTO share_targets (scope_id, target_type, target_id, role_id) + VALUES ($1, $2, $3, $4) + ON CONFLICT (scope_id, target_id) DO UPDATE SET + target_type = EXCLUDED.target_type, + role_id = EXCLUDED.role_id", + ) + .bind(*target.scope_id.as_uuid()) + .bind(target_type_to_str(target.target_type)) + .bind(*target.target_id.as_uuid()) + .bind(*target.role_id.as_uuid()) + .execute(&self.pool) + .await + .map_err(|e| DomainError::Internal(e.to_string()))?; + Ok(()) + } + + async fn find_targets_for_scope( + &self, + scope_id: &SystemId, + ) -> Result, DomainError> { + let rows = sqlx::query_as::<_, ShareTargetRow>( + "SELECT scope_id, target_type, target_id, role_id + FROM share_targets WHERE scope_id = $1", + ) + .bind(*scope_id.as_uuid()) + .fetch_all(&self.pool) + .await + .map_err(|e| DomainError::Internal(e.to_string()))?; + + rows.into_iter().map(TryInto::try_into).collect() + } + + async fn find_targets_for_user( + &self, + user_id: &SystemId, + ) -> Result, DomainError> { + let rows = sqlx::query_as::<_, ShareTargetRow>( + "SELECT scope_id, target_type, target_id, role_id + FROM share_targets WHERE target_id = $1", + ) + .bind(*user_id.as_uuid()) + .fetch_all(&self.pool) + .await + .map_err(|e| DomainError::Internal(e.to_string()))?; + + rows.into_iter().map(TryInto::try_into).collect() + } + + // --- Links --- + + async fn save_link(&self, link: &ShareLink) -> Result<(), DomainError> { + sqlx::query( + "INSERT INTO share_links (scope_id, token, expires_at, access_level, is_active, max_uses, use_count) + VALUES ($1, $2, $3, $4, $5, $6, $7) + ON CONFLICT (scope_id) DO UPDATE SET + token = EXCLUDED.token, + expires_at = EXCLUDED.expires_at, + access_level = EXCLUDED.access_level, + is_active = EXCLUDED.is_active, + max_uses = EXCLUDED.max_uses, + use_count = EXCLUDED.use_count", + ) + .bind(*link.scope_id.as_uuid()) + .bind(&link.token) + .bind(link.expires_at.as_ref().map(|d| d.as_datetime()).copied()) + .bind(access_level_to_str(link.access_level)) + .bind(link.is_active) + .bind(link.max_uses.map(|v| v as i32)) + .bind(link.use_count as i32) + .execute(&self.pool) + .await + .map_err(|e| DomainError::Internal(e.to_string()))?; + Ok(()) + } + + async fn find_link_by_token(&self, token: &str) -> Result, DomainError> { + let row = sqlx::query_as::<_, ShareLinkRow>( + "SELECT scope_id, token, expires_at, access_level, is_active, max_uses, use_count + FROM share_links WHERE token = $1", + ) + .bind(token) + .fetch_optional(&self.pool) + .await + .map_err(|e| DomainError::Internal(e.to_string()))?; + + row.map(TryInto::try_into).transpose() + } + + // --- Invites --- + + async fn save_invite(&self, invite: &InviteCode) -> Result<(), DomainError> { + sqlx::query( + "INSERT INTO invite_codes (code_id, scope_id, created_by_user_id, expires_at, max_uses, use_count, assigned_role_id) + VALUES ($1, $2, $3, $4, $5, $6, $7) + ON CONFLICT (code_id) DO UPDATE SET + expires_at = EXCLUDED.expires_at, + max_uses = EXCLUDED.max_uses, + use_count = EXCLUDED.use_count", + ) + .bind(*invite.code_id.as_uuid()) + .bind(*invite.scope_id.as_uuid()) + .bind(*invite.created_by_user_id.as_uuid()) + .bind(invite.expires_at.as_ref().map(|d| d.as_datetime()).copied()) + .bind(invite.max_uses.map(|v| v as i32)) + .bind(invite.use_count as i32) + .bind(*invite.assigned_role_id.as_uuid()) + .execute(&self.pool) + .await + .map_err(|e| DomainError::Internal(e.to_string()))?; + Ok(()) + } + + async fn find_invite_by_id(&self, id: &SystemId) -> Result, DomainError> { + let row = sqlx::query_as::<_, InviteCodeRow>( + "SELECT code_id, scope_id, created_by_user_id, expires_at, max_uses, use_count, assigned_role_id + FROM invite_codes WHERE code_id = $1", + ) + .bind(*id.as_uuid()) + .fetch_optional(&self.pool) + .await + .map_err(|e| DomainError::Internal(e.to_string()))?; + + row.map(TryInto::try_into).transpose() + } +} diff --git a/crates/adapters/postgres/src/visibility_filter_repository.rs b/crates/adapters/postgres/src/visibility_filter_repository.rs new file mode 100644 index 0000000..eceb1eb --- /dev/null +++ b/crates/adapters/postgres/src/visibility_filter_repository.rs @@ -0,0 +1,83 @@ +use crate::db::PgPool; +use async_trait::async_trait; +use domain::{ + entities::VisibilityFilter, errors::DomainError, ports::VisibilityFilterRepository, + value_objects::SystemId, +}; +use uuid::Uuid; + +#[derive(sqlx::FromRow)] +struct VisibilityFilterRow { + filter_id: Uuid, + scope_id: Uuid, + role_id: Uuid, + hidden_fields: Vec, +} + +impl From for VisibilityFilter { + fn from(r: VisibilityFilterRow) -> Self { + Self { + filter_id: SystemId::from_uuid(r.filter_id), + scope_id: SystemId::from_uuid(r.scope_id), + role_id: SystemId::from_uuid(r.role_id), + hidden_fields: r.hidden_fields, + } + } +} + +pub struct PostgresVisibilityFilterRepository { + pool: PgPool, +} + +impl PostgresVisibilityFilterRepository { + pub fn new(pool: PgPool) -> Self { + Self { pool } + } +} + +#[async_trait] +impl VisibilityFilterRepository for PostgresVisibilityFilterRepository { + async fn find_by_scope_and_role( + &self, + scope_id: &SystemId, + role_id: &SystemId, + ) -> Result, DomainError> { + let row = sqlx::query_as::<_, VisibilityFilterRow>( + "SELECT filter_id, scope_id, role_id, hidden_fields + FROM visibility_filters WHERE scope_id = $1 AND role_id = $2", + ) + .bind(*scope_id.as_uuid()) + .bind(*role_id.as_uuid()) + .fetch_optional(&self.pool) + .await + .map_err(|e| DomainError::Internal(e.to_string()))?; + + Ok(row.map(Into::into)) + } + + async fn save(&self, filter: &VisibilityFilter) -> Result<(), DomainError> { + sqlx::query( + "INSERT INTO visibility_filters (filter_id, scope_id, role_id, hidden_fields) + VALUES ($1, $2, $3, $4) + ON CONFLICT (filter_id) DO UPDATE SET + hidden_fields = EXCLUDED.hidden_fields", + ) + .bind(*filter.filter_id.as_uuid()) + .bind(*filter.scope_id.as_uuid()) + .bind(*filter.role_id.as_uuid()) + .bind(&filter.hidden_fields) + .execute(&self.pool) + .await + .map_err(|e| DomainError::Internal(e.to_string()))?; + Ok(()) + } + + async fn delete(&self, id: &SystemId) -> Result<(), DomainError> { + sqlx::query("DELETE FROM visibility_filters WHERE filter_id = $1") + .bind(*id.as_uuid()) + .execute(&self.pool) + .await + .map_err(|e| DomainError::Internal(e.to_string()))?; + Ok(()) + } +} diff --git a/crates/api-types/src/requests.rs b/crates/api-types/src/requests.rs index b52cea6..157a047 100644 --- a/crates/api-types/src/requests.rs +++ b/crates/api-types/src/requests.rs @@ -40,3 +40,21 @@ pub struct RegisterLibraryPathRequest { pub struct UpdateMetadataRequest { pub data: std::collections::HashMap, } + +#[derive(Debug, serde::Deserialize, utoipa::ToSchema)] +pub struct ShareResourceRequest { + pub shareable_type: String, + pub shareable_id: uuid::Uuid, + pub target_type: String, + pub target_id: uuid::Uuid, + pub role_id: uuid::Uuid, +} + +#[derive(Debug, serde::Deserialize, utoipa::ToSchema)] +pub struct GenerateShareLinkRequest { + pub shareable_type: String, + pub shareable_id: uuid::Uuid, + pub access_level: Option, + pub expires_in_hours: Option, + pub max_uses: Option, +} diff --git a/crates/api-types/src/responses.rs b/crates/api-types/src/responses.rs index ff91aee..4833bbc 100644 --- a/crates/api-types/src/responses.rs +++ b/crates/api-types/src/responses.rs @@ -132,3 +132,67 @@ pub struct IngestResponse { pub asset: AssetResponse, pub session_id: Uuid, } + +#[derive(Debug, serde::Serialize, utoipa::ToSchema)] +pub struct ShareScopeResponse { + pub scope_id: Uuid, + pub scope_type: String, + pub shareable_type: String, + pub shareable_id: Uuid, + pub created_at: DateTime, +} + +impl ShareScopeResponse { + pub fn from_domain(scope: &domain::entities::ShareScope) -> Self { + Self { + scope_id: *scope.scope_id.as_uuid(), + scope_type: format!("{:?}", scope.scope_type), + shareable_type: format!("{:?}", scope.shareable_type), + shareable_id: *scope.shareable_id.as_uuid(), + created_at: *scope.created_at.as_datetime(), + } + } +} + +#[derive(Debug, serde::Serialize, utoipa::ToSchema)] +pub struct ShareLinkResponse { + pub scope_id: Uuid, + pub token: String, + pub access_level: String, + pub expires_at: Option>, + pub max_uses: Option, +} + +impl ShareLinkResponse { + pub fn from_domain(link: &domain::entities::ShareLink) -> Self { + Self { + scope_id: *link.scope_id.as_uuid(), + token: link.token.clone(), + access_level: format!("{:?}", link.access_level), + expires_at: link.expires_at.as_ref().map(|d| *d.as_datetime()), + max_uses: link.max_uses, + } + } +} + +#[derive(Debug, serde::Serialize, utoipa::ToSchema)] +pub struct SharedResourceResponse { + pub scope_id: Uuid, + pub shareable_type: String, + pub shareable_id: Uuid, + pub access_level: String, +} + +impl SharedResourceResponse { + pub fn from_domain( + scope: &domain::entities::ShareScope, + access_level: domain::entities::LinkAccessLevel, + ) -> Self { + Self { + scope_id: *scope.scope_id.as_uuid(), + shareable_type: format!("{:?}", scope.shareable_type), + shareable_id: *scope.shareable_id.as_uuid(), + access_level: format!("{:?}", access_level), + } + } +} diff --git a/crates/bootstrap/src/factory.rs b/crates/bootstrap/src/factory.rs index 121ead0..94e670d 100644 --- a/crates/bootstrap/src/factory.rs +++ b/crates/bootstrap/src/factory.rs @@ -12,8 +12,8 @@ use adapters_auth::{BcryptPasswordHasher, JwtTokenIssuer}; use adapters_postgres::{ PostgresAlbumRepository, PostgresAssetMetadataRepository, PostgresAssetRepository, PostgresIngestSessionRepository, PostgresLibraryPathRepository, PostgresQuotaRepository, - PostgresStorageVolumeRepository, PostgresUsageLedgerRepository, PostgresUserRepository, - connect, run_migrations, + PostgresShareRepository, PostgresStorageVolumeRepository, PostgresUsageLedgerRepository, + PostgresUserRepository, PostgresVisibilityFilterRepository, connect, run_migrations, }; use adapters_storage::LocalFileStorage; @@ -22,11 +22,18 @@ use application::{ catalog::{GetAssetHandler, GetTimelineHandler, ReadAssetFileHandler, UpdateMetadataHandler}, identity::{GetProfileHandler, LoginUserHandler, RegisterUserHandler}, organization::{CreateAlbumHandler, GetAlbumHandler, ManageAlbumEntriesHandler}, + sharing::{ + AccessSharedResourceHandler, GenerateShareLinkHandler, RevokeShareHandler, + ShareResourceHandler, + }, storage::{IngestAssetHandler, RegisterLibraryPathHandler, RegisterVolumeHandler}, }; use presentation::{ routes::app_router, - state::{AppState, CatalogHandlers, IdentityHandlers, OrganizationHandlers, StorageHandlers}, + state::{ + AppState, CatalogHandlers, IdentityHandlers, OrganizationHandlers, SharingHandlers, + StorageHandlers, + }, }; use crate::config::Config; @@ -90,10 +97,22 @@ pub async fn build_app(config: &Config) -> Result { let update_metadata_handler = Arc::new(UpdateMetadataHandler::new( asset_repo.clone(), metadata_repo, - event_publisher, + event_publisher.clone(), )); let read_asset_file_handler = Arc::new(ReadAssetFileHandler::new(asset_repo, file_storage)); + // Sharing repos & handlers + let share_repo = Arc::new(PostgresShareRepository::new(pool.clone())); + let _visibility_filter_repo = Arc::new(PostgresVisibilityFilterRepository::new(pool)); + + let share_resource_handler = Arc::new(ShareResourceHandler::new( + share_repo.clone(), + event_publisher.clone(), + )); + let generate_link_handler = Arc::new(GenerateShareLinkHandler::new(share_repo.clone())); + let revoke_handler = Arc::new(RevokeShareHandler::new(share_repo.clone(), event_publisher)); + let access_handler = Arc::new(AccessSharedResourceHandler::new(share_repo)); + // Storage handlers let register_volume_handler = Arc::new(RegisterVolumeHandler::new(volume_repo.clone())); let register_library_path_handler = @@ -124,11 +143,19 @@ pub async fn build_app(config: &Config) -> Result { register_library_path: register_library_path_handler, }; + let sharing = SharingHandlers { + share_resource: share_resource_handler, + generate_link: generate_link_handler, + revoke: revoke_handler, + access: access_handler, + }; + let state = AppState { identity, catalog, organization, storage: storage_handlers, + sharing, token_issuer: issuer, }; diff --git a/crates/presentation/src/handlers/mod.rs b/crates/presentation/src/handlers/mod.rs index 857bfa8..c04f858 100644 --- a/crates/presentation/src/handlers/mod.rs +++ b/crates/presentation/src/handlers/mod.rs @@ -2,4 +2,5 @@ pub mod albums; pub mod assets; pub mod auth; pub mod health; +pub mod sharing; pub mod storage; diff --git a/crates/presentation/src/handlers/sharing.rs b/crates/presentation/src/handlers/sharing.rs new file mode 100644 index 0000000..6886a4f --- /dev/null +++ b/crates/presentation/src/handlers/sharing.rs @@ -0,0 +1,128 @@ +use crate::{errors::AppError, extractors::JwtClaims, state::AppState}; +use api_types::{ + requests::{GenerateShareLinkRequest, ShareResourceRequest}, + responses::{ShareLinkResponse, ShareScopeResponse, SharedResourceResponse}, +}; +use application::sharing::{ + AccessSharedResourceQuery, GenerateShareLinkCommand, RevokeShareCommand, ShareResourceCommand, +}; +use axum::{ + Json, + extract::{Path, State}, + http::StatusCode, +}; +use domain::{ + entities::{LinkAccessLevel, ShareableType, TargetType}, + errors::DomainError, + value_objects::{DateTimeStamp, SystemId}, +}; + +const DEFAULT_ACCESS_LEVEL: &str = "view_only"; + +fn parse_shareable_type(s: &str) -> Result { + 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 { + 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 { + 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, + claims: JwtClaims, + Json(req): Json, +) -> Result<(StatusCode, Json), AppError> { + let shareable_type = parse_shareable_type(&req.shareable_type)?; + let target_type = parse_target_type(&req.target_type)?; + + let cmd = ShareResourceCommand { + shareable_type, + shareable_id: SystemId::from_uuid(req.shareable_id), + target_type, + target_id: SystemId::from_uuid(req.target_id), + role_id: SystemId::from_uuid(req.role_id), + created_by: claims.user_id, + }; + let (scope, _target) = state.sharing.share_resource.execute(cmd).await?; + Ok(( + StatusCode::CREATED, + Json(ShareScopeResponse::from_domain(&scope)), + )) +} + +pub async fn generate_link( + State(state): State, + claims: JwtClaims, + Json(req): Json, +) -> Result<(StatusCode, Json), 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 expires_at = req.expires_in_hours.map(|h| { + DateTimeStamp::from_datetime(chrono::Utc::now() + chrono::Duration::hours(h as i64)) + }); + + let cmd = GenerateShareLinkCommand { + shareable_type, + shareable_id: SystemId::from_uuid(req.shareable_id), + access_level, + created_by: claims.user_id, + expires_at, + max_uses: req.max_uses, + }; + let (_scope, link) = state.sharing.generate_link.execute(cmd).await?; + Ok(( + StatusCode::CREATED, + Json(ShareLinkResponse::from_domain(&link)), + )) +} + +pub async fn revoke( + State(state): State, + claims: JwtClaims, + Path((scope_id,)): Path<(uuid::Uuid,)>, +) -> Result { + let cmd = RevokeShareCommand { + scope_id: SystemId::from_uuid(scope_id), + revoked_by: claims.user_id, + }; + state.sharing.revoke.execute(cmd).await?; + Ok(StatusCode::NO_CONTENT) +} + +pub async fn access_by_token( + State(state): State, + Path((token,)): Path<(String,)>, +) -> Result, AppError> { + let query = AccessSharedResourceQuery { token }; + let (scope, access_level) = state.sharing.access.execute(query).await?; + Ok(Json(SharedResourceResponse::from_domain( + &scope, + access_level, + ))) +} diff --git a/crates/presentation/src/routes.rs b/crates/presentation/src/routes.rs index 03e4f7d..0e42da9 100644 --- a/crates/presentation/src/routes.rs +++ b/crates/presentation/src/routes.rs @@ -1,5 +1,5 @@ use crate::{ - handlers::{albums, assets, auth, health, storage}, + handlers::{albums, assets, auth, health, sharing, storage}, openapi::openapi_router, state::AppState, }; @@ -28,6 +28,11 @@ pub fn api_v1_router() -> Router { .route("/assets/{id}", get(assets::get_asset)) .route("/assets/{id}/metadata", put(assets::update_metadata)) .route("/assets/{id}/file", get(assets::serve_file)) + // 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( diff --git a/crates/presentation/src/state.rs b/crates/presentation/src/state.rs index f9d6bad..ebaed94 100644 --- a/crates/presentation/src/state.rs +++ b/crates/presentation/src/state.rs @@ -4,6 +4,10 @@ use application::{ catalog::{GetAssetHandler, GetTimelineHandler, ReadAssetFileHandler, UpdateMetadataHandler}, identity::{GetProfileHandler, LoginUserHandler, RegisterUserHandler}, organization::{CreateAlbumHandler, GetAlbumHandler, ManageAlbumEntriesHandler}, + sharing::{ + AccessSharedResourceHandler, GenerateShareLinkHandler, RevokeShareHandler, + ShareResourceHandler, + }, storage::{IngestAssetHandler, RegisterLibraryPathHandler, RegisterVolumeHandler}, }; use domain::ports::TokenIssuer; @@ -37,11 +41,20 @@ pub struct StorageHandlers { pub register_library_path: Arc, } +#[derive(Clone)] +pub struct SharingHandlers { + pub share_resource: Arc, + pub generate_link: Arc, + pub revoke: Arc, + pub access: Arc, +} + #[derive(Clone)] pub struct AppState { pub identity: IdentityHandlers, pub catalog: CatalogHandlers, pub organization: OrganizationHandlers, pub storage: StorageHandlers, + pub sharing: SharingHandlers, pub token_issuer: Arc, }