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

@@ -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>,
}