feat: Add public album routes and enhance authorization checks for media and albums

This commit is contained in:
2025-11-15 17:18:14 +01:00
parent 199544d1c3
commit a9805b5eb1
16 changed files with 323 additions and 92 deletions

View File

@@ -13,7 +13,13 @@ use tower::ServiceExt;
use tower_http::services::ServeFile;
use uuid::Uuid;
use crate::{error::ApiError, extractors::query_options::ApiListMediaOptions, middleware::auth::UserId, schema::{MediaDetailsResponse, MediaMetadataResponse, MediaResponse}, state::AppState};
use crate::{
error::ApiError,
extractors::query_options::ApiListMediaOptions,
middleware::auth::{OptionalUserId, UserId},
schema::{MediaDetailsResponse, MediaMetadataResponse, MediaResponse},
state::AppState,
};
pub fn media_routes(max_upload_size: usize) -> Router<AppState> {
Router::new()
@@ -62,7 +68,7 @@ async fn upload_media(
async fn get_media_file(
State(state): State<AppState>,
UserId(user_id): UserId,
OptionalUserId(user_id): OptionalUserId,
Path(media_id): Path<Uuid>,
request: Request,
) -> Result<impl IntoResponse, ApiError> {
@@ -86,7 +92,7 @@ async fn get_media_file(
async fn get_media_details(
State(state): State<AppState>,
UserId(user_id): UserId,
OptionalUserId(user_id): OptionalUserId,
Path(id): Path<Uuid>,
) -> Result<Json<MediaDetailsResponse>, ApiError> {
let bundle = state.media_service.get_media_details(id, user_id).await?;
@@ -97,13 +103,13 @@ async fn get_media_details(
mime_type: bundle.media.mime_type,
hash: bundle.media.hash,
thumbnail_path: bundle.media.thumbnail_path,
metadata: bundle.metadata
metadata: bundle
.metadata
.into_iter()
.map(MediaMetadataResponse::from)
.collect(),
};
Ok(Json(response))
}
@@ -120,7 +126,7 @@ async fn list_user_media(
State(state): State<AppState>,
UserId(user_id): UserId,
ApiListMediaOptions(options): ApiListMediaOptions,
) -> Result<Json<Vec<MediaResponse>>, ApiError> {
) -> Result<Json<Vec<MediaResponse>>, ApiError> {
let media_list = state
.media_service
.list_user_media(user_id, options)
@@ -128,4 +134,4 @@ async fn list_user_media(
let response = media_list.into_iter().map(MediaResponse::from).collect();
Ok(Json(response))
}
}

View File

@@ -1,6 +1,7 @@
pub mod album_handlers;
pub mod auth_handlers;
pub mod media_handlers;
pub mod user_handlers;
pub mod person_handlers;
pub mod public_handlers;
pub mod tag_handlers;
pub mod person_handlers;
pub mod user_handlers;

View File

@@ -0,0 +1,33 @@
use axum::{
Json, Router,
extract::{Path, State},
routing::get,
};
use uuid::Uuid;
use crate::{
error::ApiError,
schema::{AlbumResponse, MediaResponse, PublicAlbumBundleResponse},
state::AppState,
};
pub fn public_routes() -> Router<AppState> {
Router::new().route("/public/albums/{id}", get(get_public_album))
}
async fn get_public_album(
State(state): State<AppState>,
Path(album_id): Path<Uuid>,
) -> Result<Json<PublicAlbumBundleResponse>, ApiError> {
let bundle = state
.album_service
.get_public_album_bundle(album_id)
.await?;
let response = PublicAlbumBundleResponse {
album: AlbumResponse::from(bundle.album),
media: bundle.media.into_iter().map(MediaResponse::from).collect(),
};
Ok(Json(response))
}

View File

@@ -14,6 +14,8 @@ use std::sync::Arc;
pub struct UserId(pub Uuid);
pub struct OptionalUserId(pub Option<Uuid>);
impl FromRequestParts<AppState> for UserId {
type Rejection = Response;
@@ -47,3 +49,30 @@ impl FromRequestParts<AppState> for UserId {
}
}
}
impl FromRequestParts<AppState> for OptionalUserId {
type Rejection = Response;
async fn from_request_parts(
parts: &mut Parts,
state: &AppState,
) -> Result<Self, Self::Rejection> {
let tokenizer: Arc<dyn TokenGenerator> = state.token_generator.clone();
let result = (async || -> CoreResult<Uuid> {
let TypedHeader(Authorization(bearer)) = parts
.extract::<TypedHeader<Authorization<Bearer>>>()
.await
.map_err(|_| CoreError::Auth("Missing Authorization header".to_string()))?;
let user_id = tokenizer.verify_token(bearer.token())?;
Ok(user_id)
})()
.await;
match result {
Ok(user_id) => Ok(Self(Some(user_id))),
Err(_) => Ok(Self(None)),
}
}
}

View File

@@ -1,7 +1,10 @@
use axum::{Router, routing::get};
use crate::{
handlers::{album_handlers, auth_handlers, media_handlers, person_handlers, tag_handlers, user_handlers},
handlers::{
album_handlers, auth_handlers, media_handlers, person_handlers, public_handlers,
tag_handlers, user_handlers,
},
state::AppState,
};
@@ -13,9 +16,11 @@ pub fn api_routes(max_upload_size: usize) -> Router<AppState> {
let media_tag_routes = tag_handlers::tag_routes();
let people_routes = person_handlers::people_routes();
let face_routes = person_handlers::face_routes();
let public_routes = public_handlers::public_routes();
Router::new()
.route("/api/v1/health", get(|| async { "OK" }))
.nest("/api/v1", public_routes)
.nest("/api/v1/auth", auth_routes)
.nest("/api/v1/users", user_routes)
.nest("/api/v1/media", media_routes)

View File

@@ -1,4 +1,6 @@
use libertas_core::models::{Album, AlbumPermission, FaceRegion, Media, MediaMetadata, Person, PersonPermission, Tag};
use libertas_core::models::{
Album, AlbumPermission, FaceRegion, Media, MediaMetadata, Person, PersonPermission, Tag,
};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
@@ -20,7 +22,7 @@ impl From<Media> for MediaResponse {
original_filename: media.original_filename,
mime_type: media.mime_type,
hash: media.hash,
thumbnail_path: media.thumbnail_path,
thumbnail_path: media.thumbnail_path,
}
}
}
@@ -31,7 +33,8 @@ pub struct ListMediaParams {
pub order: Option<String>,
pub mime_type: Option<String>,
#[serde(default)]
pub metadata: Vec<String>,}
pub metadata: Vec<String>,
}
#[derive(Deserialize)]
pub struct CreateAlbumRequest {
@@ -144,7 +147,10 @@ pub struct TagResponse {
impl From<Tag> for TagResponse {
fn from(tag: Tag) -> Self {
Self { id: tag.id, name: tag.name }
Self {
id: tag.id,
name: tag.name,
}
}
}
@@ -162,7 +168,11 @@ pub struct PersonResponse {
impl From<Person> for PersonResponse {
fn from(person: Person) -> Self {
Self { id: person.id, owner_id: person.owner_id, name: person.name }
Self {
id: person.id,
owner_id: person.owner_id,
name: person.name,
}
}
}
@@ -210,4 +220,10 @@ pub struct AssignFaceRequest {
pub struct SharePersonRequest {
pub target_user_id: Uuid,
pub permission: PersonPermission,
}
}
#[derive(Serialize)]
pub struct PublicAlbumBundleResponse {
pub album: AlbumResponse,
pub media: Vec<MediaResponse>,
}

View File

@@ -5,7 +5,7 @@ use chrono::Utc;
use libertas_core::{
authz::{self, Permission},
error::{CoreError, CoreResult},
models::Album,
models::{Album, PublicAlbumBundle},
repositories::{AlbumRepository, AlbumShareRepository},
schema::{AddMediaToAlbumData, CreateAlbumData, ShareAlbumData, UpdateAlbumData},
services::{AlbumService, AuthorizationService},
@@ -57,7 +57,7 @@ impl AlbumService for AlbumServiceImpl {
async fn get_album_details(&self, album_id: Uuid, user_id: Uuid) -> CoreResult<Album> {
self.auth_service
.check_permission(user_id, Permission::ViewAlbum(album_id))
.check_permission(Some(user_id), Permission::ViewAlbum(album_id))
.await?;
let album = self
@@ -71,12 +71,12 @@ impl AlbumService for AlbumServiceImpl {
async fn add_media_to_album(&self, data: AddMediaToAlbumData, user_id: Uuid) -> CoreResult<()> {
self.auth_service
.check_permission(user_id, Permission::AddToAlbum(data.album_id))
.check_permission(Some(user_id), Permission::AddToAlbum(data.album_id))
.await?;
for media_id in &data.media_ids {
self.auth_service
.check_permission(*media_id, Permission::ViewMedia(*media_id))
.check_permission(Some(user_id), Permission::ViewMedia(*media_id))
.await?;
}
@@ -91,7 +91,7 @@ impl AlbumService for AlbumServiceImpl {
async fn share_album(&self, data: ShareAlbumData, owner_id: Uuid) -> CoreResult<()> {
self.auth_service
.check_permission(owner_id, Permission::ShareAlbum(data.album_id))
.check_permission(Some(owner_id), Permission::ShareAlbum(data.album_id))
.await?;
if data.target_user_id == owner_id {
@@ -112,7 +112,7 @@ impl AlbumService for AlbumServiceImpl {
data: UpdateAlbumData<'_>,
) -> CoreResult<Album> {
self.auth_service
.check_permission(user_id, Permission::EditAlbum(album_id))
.check_permission(Some(user_id), Permission::EditAlbum(album_id))
.await?;
let mut album = self
@@ -150,9 +150,24 @@ impl AlbumService for AlbumServiceImpl {
async fn delete_album(&self, album_id: Uuid, user_id: Uuid) -> CoreResult<()> {
self.auth_service
.check_permission(user_id, Permission::DeleteAlbum(album_id))
.check_permission(Some(user_id), Permission::DeleteAlbum(album_id))
.await?;
self.album_repo.delete(album_id).await
}
async fn get_public_album_bundle(&self, album_id: Uuid) -> CoreResult<PublicAlbumBundle> {
let album = self
.album_repo
.find_by_id(album_id)
.await?
.ok_or(CoreError::NotFound("Album".to_string(), album_id))?;
if !album.is_public {
return Err(CoreError::Auth("Album is not public".to_string()));
}
let media = self.album_repo.list_media_by_album_id(album_id).await?;
Ok(PublicAlbumBundle { album, media })
}
}

View File

@@ -107,27 +107,46 @@ impl AuthorizationServiceImpl {
#[async_trait]
impl AuthorizationService for AuthorizationServiceImpl {
async fn check_permission(&self, user_id: Uuid, permission: Permission) -> CoreResult<()> {
let user = self.get_user(user_id).await?;
async fn check_permission(
&self,
user_id: Option<Uuid>,
permission: Permission,
) -> CoreResult<()> {
let user = if let Some(id) = user_id {
Some(self.get_user(id).await?)
} else {
None
};
if authz::is_admin(&user) {
return Ok(());
if let Some(ref user) = user {
if authz::is_admin(user) {
// [cite: 115]
return Ok(());
}
}
match permission {
Permission::ViewMedia(media_id) => {
let media = self.get_media(media_id).await?;
if authz::is_owner(user_id, &media) {
if self.album_repo.is_media_in_public_album(media_id).await? {
return Ok(());
}
let is_shared = self
.album_share_repo
.is_media_in_shared_album(media_id, user_id)
.await?;
if let Some(id) = user_id {
if authz::is_owner(id, &media) {
// [cite: 117]
return Ok(());
}
if is_shared {
return Ok(());
if self
.album_share_repo
.is_media_in_shared_album(media_id, id)
.await?
{
// [cite: 118-119]
return Ok(());
}
}
Err(CoreError::Auth(
@@ -136,6 +155,9 @@ impl AuthorizationService for AuthorizationServiceImpl {
}
Permission::DeleteMedia(media_id) | Permission::EditMedia(media_id) => {
let user_id = user_id.ok_or(CoreError::Auth(
"Authentication required for this action".into(),
))?;
let media = self.get_media(media_id).await?;
if authz::is_owner(user_id, &media) {
return Ok(());
@@ -149,6 +171,9 @@ impl AuthorizationService for AuthorizationServiceImpl {
Permission::AddTags(media_id)
| Permission::RemoveTags(media_id)
| Permission::EditTags(media_id) => {
let user_id = user_id.ok_or(CoreError::Auth(
"Authentication required for this action".into(),
))?;
let media = self.get_media(media_id).await?;
if authz::is_owner(user_id, &media) {
@@ -170,6 +195,9 @@ impl AuthorizationService for AuthorizationServiceImpl {
}
Permission::ViewAlbum(album_id) => {
let user_id = user_id.ok_or(CoreError::Auth(
"Authentication required for this action".into(),
))?;
let album = self.get_album(album_id).await?;
let share_permission = self.get_album_share_permission(album_id, user_id).await?;
@@ -184,6 +212,9 @@ impl AuthorizationService for AuthorizationServiceImpl {
}
Permission::AddToAlbum(album_id) | Permission::EditAlbum(album_id) => {
let user_id = user_id.ok_or(CoreError::Auth(
"Authentication required for this action".into(),
))?;
let album = self.get_album(album_id).await?;
let share_permission = self.get_album_share_permission(album_id, user_id).await?;
@@ -197,6 +228,9 @@ impl AuthorizationService for AuthorizationServiceImpl {
}
Permission::ShareAlbum(album_id) | Permission::DeleteAlbum(album_id) => {
let user_id = user_id.ok_or(CoreError::Auth(
"Authentication required for this action".into(),
))?;
let album = self.get_album(album_id).await?;
if authz::is_owner(user_id, &album) {
@@ -209,6 +243,9 @@ impl AuthorizationService for AuthorizationServiceImpl {
}
Permission::ViewPerson(person_id) => {
let user_id = user_id.ok_or(CoreError::Auth(
"Authentication required for this action".into(),
))?;
let person = self.get_person(person_id).await?;
let share_permission = self.get_person_share_permission(person_id, user_id).await?;
@@ -224,6 +261,9 @@ impl AuthorizationService for AuthorizationServiceImpl {
Permission::EditPerson(person_id)
| Permission::SharePerson(person_id)
| Permission::DeletePerson(person_id) => {
let user_id = user_id.ok_or(CoreError::Auth(
"Authentication required for this action".into(),
))?;
let person = self.get_person(person_id).await?;
if authz::is_owner(user_id, &person) {
@@ -236,6 +276,9 @@ impl AuthorizationService for AuthorizationServiceImpl {
}
Permission::UsePerson(person_id) => {
let user_id = user_id.ok_or(CoreError::Auth(
"Authentication required for this action".into(),
))?;
let person = self.get_person(person_id).await?;
let share_permission = self.get_person_share_permission(person_id, user_id).await?;

View File

@@ -89,7 +89,7 @@ impl MediaService for MediaServiceImpl {
Ok(media)
}
async fn get_media_details(&self, id: Uuid, user_id: Uuid) -> CoreResult<MediaBundle> {
async fn get_media_details(&self, id: Uuid, user_id: Option<Uuid>) -> CoreResult<MediaBundle> {
self.auth_service
.check_permission(user_id, authz::Permission::ViewMedia(id))
.await?;
@@ -113,7 +113,7 @@ impl MediaService for MediaServiceImpl {
self.repo.list_by_user(user_id, &options).await
}
async fn get_media_filepath(&self, id: Uuid, user_id: Uuid) -> CoreResult<String> {
async fn get_media_filepath(&self, id: Uuid, user_id: Option<Uuid>) -> CoreResult<String> {
self.auth_service
.check_permission(user_id, authz::Permission::ViewMedia(id))
.await?;
@@ -129,7 +129,7 @@ impl MediaService for MediaServiceImpl {
async fn delete_media(&self, id: Uuid, user_id: Uuid) -> CoreResult<()> {
self.auth_service
.check_permission(user_id, authz::Permission::DeleteMedia(id))
.check_permission(Some(user_id), authz::Permission::DeleteMedia(id))
.await?;
let media = self

View File

@@ -59,7 +59,7 @@ impl PersonService for PersonServiceImpl {
async fn get_person(&self, person_id: Uuid, user_id: Uuid) -> CoreResult<Person> {
self.auth_service
.check_permission(user_id, authz::Permission::ViewPerson(person_id))
.check_permission(Some(user_id), authz::Permission::ViewPerson(person_id))
.await?;
self.person_repo
@@ -93,7 +93,7 @@ impl PersonService for PersonServiceImpl {
user_id: Uuid,
) -> CoreResult<Person> {
self.auth_service
.check_permission(user_id, authz::Permission::EditPerson(person_id))
.check_permission(Some(user_id), authz::Permission::EditPerson(person_id))
.await?;
let mut person = self.get_person(person_id).await?;
@@ -105,7 +105,7 @@ impl PersonService for PersonServiceImpl {
async fn delete_person(&self, person_id: Uuid, user_id: Uuid) -> CoreResult<()> {
self.auth_service
.check_permission(user_id, authz::Permission::DeletePerson(person_id))
.check_permission(Some(user_id), authz::Permission::DeletePerson(person_id))
.await?;
self.person_repo.delete(person_id).await
@@ -118,10 +118,10 @@ impl PersonService for PersonServiceImpl {
user_id: Uuid,
) -> CoreResult<FaceRegion> {
self.auth_service
.check_permission(user_id, authz::Permission::UsePerson(person_id))
.check_permission(Some(user_id), authz::Permission::UsePerson(person_id))
.await?;
self.auth_service
.check_permission(user_id, authz::Permission::AssignFace(face_region_id))
.check_permission(Some(user_id), authz::Permission::AssignFace(face_region_id))
.await?;
let mut face =
@@ -147,7 +147,7 @@ impl PersonService for PersonServiceImpl {
user_id: Uuid,
) -> CoreResult<Vec<FaceRegion>> {
self.auth_service
.check_permission(user_id, authz::Permission::ViewFaces(media_id))
.check_permission(Some(user_id), authz::Permission::ViewFaces(media_id))
.await?;
self.face_repo.find_by_media_id(media_id).await
@@ -161,7 +161,7 @@ impl PersonService for PersonServiceImpl {
owner_id: Uuid,
) -> CoreResult<()> {
self.auth_service
.check_permission(owner_id, authz::Permission::SharePerson(person_id))
.check_permission(Some(owner_id), authz::Permission::SharePerson(person_id))
.await?;
self.person_share_repo
@@ -176,7 +176,7 @@ impl PersonService for PersonServiceImpl {
owner_id: Uuid,
) -> CoreResult<()> {
self.auth_service
.check_permission(owner_id, authz::Permission::SharePerson(person_id))
.check_permission(Some(owner_id), authz::Permission::SharePerson(person_id))
.await?;
self.person_share_repo

View File

@@ -1,7 +1,13 @@
use std::sync::Arc;
use async_trait::async_trait;
use libertas_core::{authz::Permission, error::CoreResult, models::Tag, repositories::TagRepository, services::{AuthorizationService, TagService}};
use libertas_core::{
authz::Permission,
error::CoreResult,
models::Tag,
repositories::TagRepository,
services::{AuthorizationService, TagService},
};
use uuid::Uuid;
pub struct TagServiceImpl {
@@ -28,9 +34,10 @@ impl TagService for TagServiceImpl {
media_id: Uuid,
tag_names: &[String],
user_id: Uuid,
) -> CoreResult<Vec<Tag>> {
self.auth_service.check_permission(user_id, Permission::AddTags(media_id)).await?;
self.auth_service
.check_permission(Some(user_id), Permission::AddTags(media_id))
.await?;
let mut tag_ids = Vec::new();
let tags = self.tag_repo.find_or_create_tags(tag_names).await?;
@@ -49,7 +56,9 @@ impl TagService for TagServiceImpl {
tag_names: &[String],
user_id: Uuid,
) -> CoreResult<()> {
self.auth_service.check_permission(user_id, Permission::RemoveTags(media_id)).await?;
self.auth_service
.check_permission(Some(user_id), Permission::RemoveTags(media_id))
.await?;
let tags = self.tag_repo.find_or_create_tags(tag_names).await?;
let mut tag_ids = Vec::new();
@@ -57,20 +66,20 @@ impl TagService for TagServiceImpl {
tag_ids.push(tag.id);
}
self.tag_repo.remove_tags_from_media(media_id, &tag_ids).await?;
self.tag_repo
.remove_tags_from_media(media_id, &tag_ids)
.await?;
Ok(())
}
async fn list_tags_for_media(
&self,
media_id: Uuid,
user_id: Uuid,
) -> CoreResult<Vec<Tag>> {
self.auth_service.check_permission(user_id, Permission::ViewMedia(media_id)).await?;
async fn list_tags_for_media(&self, media_id: Uuid, user_id: Uuid) -> CoreResult<Vec<Tag>> {
self.auth_service
.check_permission(Some(user_id), Permission::ViewMedia(media_id))
.await?;
let tags = self.tag_repo.list_tags_for_media(media_id).await?;
Ok(tags)
}
}
}