treewide: fix various UI bugs; optimize crypto dependencies & format

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: If8fe8b38c1d9c4fecd40ff71f88d2ae06a6a6964
This commit is contained in:
raf 2026-02-10 12:56:05 +03:00
commit 3ccddce7fd
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF
178 changed files with 58285 additions and 54241 deletions

View file

@ -1,199 +1,204 @@
use axum::Json;
use axum::extract::{Extension, Path, Query, State};
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;
use crate::dto::*;
use crate::error::ApiError;
use crate::state::AppState;
use pinakes_core::model::{MediaId, Pagination};
use crate::{auth::resolve_user_id, dto::*, error::ApiError, state::AppState};
#[derive(Deserialize)]
pub struct ShareLinkQuery {
pub password: Option<String>,
pub password: Option<String>,
}
// ===== Ratings =====
pub async fn rate_media(
State(state): State<AppState>,
Extension(username): Extension<String>,
Path(id): Path<Uuid>,
Json(req): Json<CreateRatingRequest>,
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(),
),
));
}
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)))
if req.stars < 1 || req.stars > 5 {
return Err(ApiError(
pinakes_core::error::PinakesError::InvalidOperation(
"stars must be between 1 and 5".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)))
}
pub async fn get_media_ratings(
State(state): State<AppState>,
Path(id): Path<Uuid>,
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(),
))
let ratings = state.storage.get_media_ratings(MediaId(id)).await?;
Ok(Json(
ratings.into_iter().map(RatingResponse::from).collect(),
))
}
// ===== Comments =====
pub async fn add_comment(
State(state): State<AppState>,
Extension(username): Extension<String>,
Path(id): Path<Uuid>,
Json(req): Json<CreateCommentRequest>,
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)))
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)))
}
pub async fn get_media_comments(
State(state): State<AppState>,
Path(id): Path<Uuid>,
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(),
))
let comments = state.storage.get_media_comments(MediaId(id)).await?;
Ok(Json(
comments.into_iter().map(CommentResponse::from).collect(),
))
}
// ===== Favorites =====
pub async fn add_favorite(
State(state): State<AppState>,
Extension(username): Extension<String>,
Json(req): Json<FavoriteRequest>,
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})))
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})))
}
pub async fn remove_favorite(
State(state): State<AppState>,
Extension(username): Extension<String>,
Path(media_id): Path<Uuid>,
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})))
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})))
}
pub async fn list_favorites(
State(state): State<AppState>,
Extension(username): Extension<String>,
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?;
Ok(Json(items.into_iter().map(MediaResponse::from).collect()))
let user_id = resolve_user_id(&state.storage, &username).await?;
let items = state
.storage
.get_user_favorites(user_id, &Pagination::default())
.await?;
Ok(Json(items.into_iter().map(MediaResponse::from).collect()))
}
// ===== Share Links =====
pub async fn create_share_link(
State(state): State<AppState>,
Extension(username): Extension<String>,
Json(req): Json<CreateShareLinkRequest>,
State(state): State<AppState>,
Extension(username): Extension<String>,
Json(req): Json<CreateShareLinkRequest>,
) -> Result<Json<ShareLinkResponse>, ApiError> {
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)))
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)))
}
pub async fn access_shared_media(
State(state): State<AppState>,
Path(token): Path<String>,
Query(query): Query<ShareLinkQuery>,
State(state): State<AppState>,
Path(token): Path<String>,
Query(query): Query<ShareLinkQuery>,
) -> Result<Json<MediaResponse>, ApiError> {
let link = state.storage.get_share_link(&token).await?;
// Check expiration
if let Some(expires) = link.expires_at
&& chrono::Utc::now() > expires
{
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::InvalidOperation("share link has expired".into()),
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(),
)));
}
// 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?;
Ok(Json(MediaResponse::from(item)))
}
state.storage.increment_share_views(&token).await?;
let item = state.storage.get_media(link.media_id).await?;
Ok(Json(MediaResponse::from(item)))
}