Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: I28cf5b7b7cff8e90e123d624d97cf9656a6a6964
355 lines
10 KiB
Rust
355 lines
10 KiB
Rust
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<String>,
|
|
}
|
|
|
|
#[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<AppState>,
|
|
Extension(username): Extension<String>,
|
|
Path(id): Path<Uuid>,
|
|
Json(req): Json<CreateRatingRequest>,
|
|
) -> Result<Json<RatingResponse>, 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<RatingResponse>),
|
|
(status = 401, description = "Unauthorized"),
|
|
(status = 500, description = "Internal server error"),
|
|
),
|
|
security(("bearer_auth" = []))
|
|
)]
|
|
pub async fn get_media_ratings(
|
|
State(state): State<AppState>,
|
|
Path(id): Path<Uuid>,
|
|
) -> Result<Json<Vec<RatingResponse>>, 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<AppState>,
|
|
Extension(username): Extension<String>,
|
|
Path(id): Path<Uuid>,
|
|
Json(req): Json<CreateCommentRequest>,
|
|
) -> Result<Json<CommentResponse>, 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<CommentResponse>),
|
|
(status = 401, description = "Unauthorized"),
|
|
(status = 500, description = "Internal server error"),
|
|
),
|
|
security(("bearer_auth" = []))
|
|
)]
|
|
pub async fn get_media_comments(
|
|
State(state): State<AppState>,
|
|
Path(id): Path<Uuid>,
|
|
) -> Result<Json<Vec<CommentResponse>>, 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<AppState>,
|
|
Extension(username): Extension<String>,
|
|
Json(req): Json<FavoriteRequest>,
|
|
) -> Result<Json<serde_json::Value>, 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<AppState>,
|
|
Extension(username): Extension<String>,
|
|
Path(media_id): Path<Uuid>,
|
|
) -> Result<Json<serde_json::Value>, 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<MediaResponse>),
|
|
(status = 401, description = "Unauthorized"),
|
|
(status = 500, description = "Internal server error"),
|
|
),
|
|
security(("bearer_auth" = []))
|
|
)]
|
|
pub async fn list_favorites(
|
|
State(state): State<AppState>,
|
|
Extension(username): Extension<String>,
|
|
) -> Result<Json<Vec<MediaResponse>>, 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<AppState>,
|
|
Extension(username): Extension<String>,
|
|
Json(req): Json<CreateShareLinkRequest>,
|
|
) -> Result<Json<ShareLinkResponse>, 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<String>, 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<AppState>,
|
|
Path(token): Path<String>,
|
|
Query(query): Query<ShareLinkQuery>,
|
|
) -> Result<Json<MediaResponse>, 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)))
|
|
}
|