feat: add sharing endpoints — share, link, revoke, public access

This commit is contained in:
2026-05-31 10:50:28 +02:00
parent 2d9dd2c2d0
commit 3399e25441
11 changed files with 814 additions and 5 deletions

View File

@@ -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 '{}'
);

View File

@@ -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;

View File

@@ -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<DateTime<Utc>>,
created_at: DateTime<Utc>,
}
#[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<DateTime<Utc>>,
access_level: String,
is_active: bool,
max_uses: Option<i32>,
use_count: i32,
}
#[derive(sqlx::FromRow)]
struct InviteCodeRow {
code_id: Uuid,
scope_id: Uuid,
created_by_user_id: Uuid,
expires_at: Option<DateTime<Utc>>,
max_uses: Option<i32>,
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<ScopeType, DomainError> {
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<ShareableType, DomainError> {
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<TargetType, DomainError> {
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<LinkAccessLevel, DomainError> {
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<ShareScopeRow> for ShareScope {
type Error = DomainError;
fn try_from(r: ShareScopeRow) -> Result<Self, Self::Error> {
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<ShareTargetRow> for ShareTarget {
type Error = DomainError;
fn try_from(r: ShareTargetRow) -> Result<Self, Self::Error> {
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<ShareLinkRow> for ShareLink {
type Error = DomainError;
fn try_from(r: ShareLinkRow) -> Result<Self, Self::Error> {
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<InviteCodeRow> for InviteCode {
type Error = DomainError;
fn try_from(r: InviteCodeRow) -> Result<Self, Self::Error> {
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<Option<ShareScope>, 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<Vec<ShareScope>, 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<Vec<ShareTarget>, 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<Vec<ShareTarget>, 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<Option<ShareLink>, 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<Option<InviteCode>, 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()
}
}

View File

@@ -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<String>,
}
impl From<VisibilityFilterRow> 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<Option<VisibilityFilter>, 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(())
}
}

View File

@@ -40,3 +40,21 @@ pub struct RegisterLibraryPathRequest {
pub struct UpdateMetadataRequest {
pub data: std::collections::HashMap<String, serde_json::Value>,
}
#[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<String>,
pub expires_in_hours: Option<u64>,
pub max_uses: Option<u32>,
}

View File

@@ -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<Utc>,
}
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<DateTime<Utc>>,
pub max_uses: Option<u32>,
}
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),
}
}
}

View File

@@ -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<Router> {
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<Router> {
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,
};

View File

@@ -2,4 +2,5 @@ pub mod albums;
pub mod assets;
pub mod auth;
pub mod health;
pub mod sharing;
pub mod storage;

View File

@@ -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<ShareableType, AppError> {
match s {
"asset" => Ok(ShareableType::Asset),
"album" => Ok(ShareableType::Album),
"collection" => Ok(ShareableType::Collection),
"directory" => Ok(ShareableType::Directory),
_ => Err(AppError::from(DomainError::Validation(format!(
"Invalid shareable type: {s}"
)))),
}
}
fn parse_target_type(s: &str) -> Result<TargetType, AppError> {
match s {
"user" => Ok(TargetType::User),
"group" => Ok(TargetType::Group),
_ => Err(AppError::from(DomainError::Validation(format!(
"Invalid target type: {s}"
)))),
}
}
fn parse_access_level(s: &str) -> Result<LinkAccessLevel, AppError> {
match s {
"view_only" => Ok(LinkAccessLevel::ViewOnly),
"limited_search" => Ok(LinkAccessLevel::LimitedSearch),
_ => Err(AppError::from(DomainError::Validation(format!(
"Invalid access level: {s}"
)))),
}
}
pub async fn share_resource(
State(state): State<AppState>,
claims: JwtClaims,
Json(req): Json<ShareResourceRequest>,
) -> Result<(StatusCode, Json<ShareScopeResponse>), AppError> {
let shareable_type = parse_shareable_type(&req.shareable_type)?;
let target_type = parse_target_type(&req.target_type)?;
let 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<AppState>,
claims: JwtClaims,
Json(req): Json<GenerateShareLinkRequest>,
) -> Result<(StatusCode, Json<ShareLinkResponse>), AppError> {
let shareable_type = parse_shareable_type(&req.shareable_type)?;
let access_level =
parse_access_level(req.access_level.as_deref().unwrap_or(DEFAULT_ACCESS_LEVEL))?;
let 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<AppState>,
claims: JwtClaims,
Path((scope_id,)): Path<(uuid::Uuid,)>,
) -> Result<StatusCode, AppError> {
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<AppState>,
Path((token,)): Path<(String,)>,
) -> Result<Json<SharedResourceResponse>, AppError> {
let query = AccessSharedResourceQuery { token };
let (scope, access_level) = state.sharing.access.execute(query).await?;
Ok(Json(SharedResourceResponse::from_domain(
&scope,
access_level,
)))
}

View File

@@ -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<AppState> {
.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(

View File

@@ -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<RegisterLibraryPathHandler>,
}
#[derive(Clone)]
pub struct SharingHandlers {
pub share_resource: Arc<ShareResourceHandler>,
pub generate_link: Arc<GenerateShareLinkHandler>,
pub revoke: Arc<RevokeShareHandler>,
pub access: Arc<AccessSharedResourceHandler>,
}
#[derive(Clone)]
pub struct AppState {
pub identity: IdentityHandlers,
pub catalog: CatalogHandlers,
pub organization: OrganizationHandlers,
pub storage: StorageHandlers,
pub sharing: SharingHandlers,
pub token_issuer: Arc<dyn TokenIssuer>,
}