use axum::{ Json, extract::{Extension, Path, Query, State}, }; use pinakes_core::model::{MediaId, Pagination}; use serde::Deserialize; use uuid::Uuid; use crate::{ auth::resolve_user_id, dto::{ CommentResponse, CreateCommentRequest, CreateRatingRequest, CreateShareLinkRequest, FavoriteRequest, MediaResponse, RatingResponse, ShareLinkResponse, }, error::ApiError, state::AppState, }; #[derive(Deserialize)] pub struct ShareLinkQuery { pub password: Option, } #[utoipa::path( post, path = "/api/v1/media/{id}/rate", tag = "social", params(("id" = Uuid, Path, description = "Media item ID")), request_body = CreateRatingRequest, responses( (status = 200, description = "Rating saved", body = RatingResponse), (status = 400, description = "Bad request"), (status = 401, description = "Unauthorized"), (status = 500, description = "Internal server error"), ), security(("bearer_auth" = [])) )] pub async fn rate_media( State(state): State, Extension(username): Extension, Path(id): Path, Json(req): Json, ) -> Result, ApiError> { if req.stars < 1 || req.stars > 5 { return Err(ApiError( pinakes_core::error::PinakesError::InvalidOperation( "stars must be between 1 and 5".into(), ), )); } if req .review_text .as_ref() .is_some_and(|t| t.chars().count() > 10_000) { return Err(ApiError( pinakes_core::error::PinakesError::InvalidOperation( "review_text must not exceed 10000 characters".into(), ), )); } let user_id = resolve_user_id(&state.storage, &username).await?; let rating = state .storage .rate_media(user_id, MediaId(id), req.stars, req.review_text.as_deref()) .await?; Ok(Json(RatingResponse::from(rating))) } #[utoipa::path( get, path = "/api/v1/media/{id}/ratings", tag = "social", params(("id" = Uuid, Path, description = "Media item ID")), responses( (status = 200, description = "Media ratings", body = Vec), (status = 401, description = "Unauthorized"), (status = 500, description = "Internal server error"), ), security(("bearer_auth" = [])) )] pub async fn get_media_ratings( State(state): State, Path(id): Path, ) -> Result>, ApiError> { let ratings = state.storage.get_media_ratings(MediaId(id)).await?; Ok(Json( ratings.into_iter().map(RatingResponse::from).collect(), )) } #[utoipa::path( post, path = "/api/v1/media/{id}/comments", tag = "social", params(("id" = Uuid, Path, description = "Media item ID")), request_body = CreateCommentRequest, responses( (status = 200, description = "Comment added", body = CommentResponse), (status = 400, description = "Bad request"), (status = 401, description = "Unauthorized"), (status = 500, description = "Internal server error"), ), security(("bearer_auth" = [])) )] pub async fn add_comment( State(state): State, Extension(username): Extension, Path(id): Path, Json(req): Json, ) -> Result, ApiError> { let char_count = req.text.chars().count(); if char_count == 0 || char_count > 10_000 { return Err(ApiError( pinakes_core::error::PinakesError::InvalidOperation( "comment text must be 1-10000 characters".into(), ), )); } let user_id = resolve_user_id(&state.storage, &username).await?; let comment = state .storage .add_comment(user_id, MediaId(id), &req.text, req.parent_id) .await?; Ok(Json(CommentResponse::from(comment))) } #[utoipa::path( get, path = "/api/v1/media/{id}/comments", tag = "social", params(("id" = Uuid, Path, description = "Media item ID")), responses( (status = 200, description = "Media comments", body = Vec), (status = 401, description = "Unauthorized"), (status = 500, description = "Internal server error"), ), security(("bearer_auth" = [])) )] pub async fn get_media_comments( State(state): State, Path(id): Path, ) -> Result>, ApiError> { let comments = state.storage.get_media_comments(MediaId(id)).await?; Ok(Json( comments.into_iter().map(CommentResponse::from).collect(), )) } #[utoipa::path( post, path = "/api/v1/favorites", tag = "social", request_body = FavoriteRequest, responses( (status = 200, description = "Added to favorites"), (status = 401, description = "Unauthorized"), (status = 500, description = "Internal server error"), ), security(("bearer_auth" = [])) )] pub async fn add_favorite( State(state): State, Extension(username): Extension, Json(req): Json, ) -> Result, ApiError> { let user_id = resolve_user_id(&state.storage, &username).await?; state .storage .add_favorite(user_id, MediaId(req.media_id)) .await?; Ok(Json(serde_json::json!({"added": true}))) } #[utoipa::path( delete, path = "/api/v1/favorites/{media_id}", tag = "social", params(("media_id" = Uuid, Path, description = "Media item ID")), responses( (status = 200, description = "Removed from favorites"), (status = 401, description = "Unauthorized"), (status = 500, description = "Internal server error"), ), security(("bearer_auth" = [])) )] pub async fn remove_favorite( State(state): State, Extension(username): Extension, Path(media_id): Path, ) -> Result, ApiError> { let user_id = resolve_user_id(&state.storage, &username).await?; state .storage .remove_favorite(user_id, MediaId(media_id)) .await?; Ok(Json(serde_json::json!({"removed": true}))) } #[utoipa::path( get, path = "/api/v1/favorites", tag = "social", responses( (status = 200, description = "User favorites", body = Vec), (status = 401, description = "Unauthorized"), (status = 500, description = "Internal server error"), ), security(("bearer_auth" = [])) )] pub async fn list_favorites( State(state): State, Extension(username): Extension, ) -> Result>, ApiError> { let user_id = resolve_user_id(&state.storage, &username).await?; let items = state .storage .get_user_favorites(user_id, &Pagination::default()) .await?; let roots = state.config.read().await.directories.roots.clone(); Ok(Json( items .into_iter() .map(|item| MediaResponse::new(item, &roots)) .collect(), )) } #[utoipa::path( post, path = "/api/v1/media/share", tag = "social", request_body = CreateShareLinkRequest, responses( (status = 200, description = "Share link created", body = ShareLinkResponse), (status = 400, description = "Bad request"), (status = 401, description = "Unauthorized"), (status = 500, description = "Internal server error"), ), security(("bearer_auth" = [])) )] pub async fn create_share_link( State(state): State, Extension(username): Extension, Json(req): Json, ) -> Result, ApiError> { if req.password.as_ref().is_some_and(|p| p.len() > 1024) { return Err(ApiError( pinakes_core::error::PinakesError::InvalidOperation( "password must not exceed 1024 bytes".into(), ), )); } let user_id = resolve_user_id(&state.storage, &username).await?; let token = uuid::Uuid::now_v7().to_string().replace('-', ""); let password_hash = match req.password.as_ref() { Some(p) => { Some(pinakes_core::users::auth::hash_password(p).map_err(ApiError)?) }, None => None, }; const MAX_EXPIRY_HOURS: u64 = 8760; // 1 year if let Some(h) = req.expires_in_hours && h > MAX_EXPIRY_HOURS { return Err(ApiError( pinakes_core::error::PinakesError::InvalidOperation(format!( "expires_in_hours cannot exceed {MAX_EXPIRY_HOURS}" )), )); } let expires_at = req .expires_in_hours .map(|h| chrono::Utc::now() + chrono::Duration::hours(h as i64)); let link = state .storage .create_share_link( MediaId(req.media_id), user_id, &token, password_hash.as_deref(), expires_at, ) .await?; Ok(Json(ShareLinkResponse::from(link))) } #[utoipa::path( get, path = "/api/v1/shared/media/{token}", tag = "social", params( ("token" = String, Path, description = "Share token"), ("password" = Option, Query, description = "Share password"), ), responses( (status = 200, description = "Shared media", body = MediaResponse), (status = 401, description = "Unauthorized"), (status = 404, description = "Not found"), ) )] pub async fn access_shared_media( State(state): State, Path(token): Path, Query(query): Query, ) -> Result, ApiError> { if query.password.as_ref().is_some_and(|p| p.len() > 1024) { return Err(ApiError( pinakes_core::error::PinakesError::InvalidOperation( "password must not exceed 1024 bytes".into(), ), )); } let link = state.storage.get_share_link(&token).await?; // Check expiration if let Some(expires) = link.expires_at && chrono::Utc::now() > expires { return Err(ApiError( pinakes_core::error::PinakesError::InvalidOperation( "share link has expired".into(), ), )); } // Verify password if set if let Some(ref hash) = link.password_hash { let password = match query.password.as_deref() { Some(p) => p, None => { return Err(ApiError( pinakes_core::error::PinakesError::Authentication( "password required for this share link".into(), ), )); }, }; let valid = pinakes_core::users::auth::verify_password(password, hash) .unwrap_or(false); if !valid { return Err(ApiError(pinakes_core::error::PinakesError::Authentication( "invalid share link password".into(), ))); } } state.storage.increment_share_views(&token).await?; let item = state.storage.get_media(link.media_id).await?; let roots = state.config.read().await.directories.roots.clone(); Ok(Json(MediaResponse::new(item, &roots))) }