various: simplify code; work on security and performance
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: I9a5114addcab5fbff430ab2b919b83466a6a6964
This commit is contained in:
parent
016841b200
commit
c4adc4e3e0
75 changed files with 12921 additions and 358 deletions
208
crates/pinakes-server/src/routes/playlists.rs
Normal file
208
crates/pinakes-server/src/routes/playlists.rs
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
use axum::Json;
|
||||
use axum::extract::{Extension, Path, State};
|
||||
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;
|
||||
use pinakes_core::playlists::Playlist;
|
||||
use pinakes_core::users::UserId;
|
||||
|
||||
/// Check whether a user has access to a playlist.
|
||||
///
|
||||
/// * `require_write` – when `true` only the playlist owner is allowed (for
|
||||
/// mutations such as update, delete, add/remove/reorder items). When `false`
|
||||
/// the playlist must either be public or owned by the requesting user.
|
||||
async fn check_playlist_access(
|
||||
storage: &pinakes_core::storage::DynStorageBackend,
|
||||
playlist_id: Uuid,
|
||||
user_id: UserId,
|
||||
require_write: bool,
|
||||
) -> Result<Playlist, ApiError> {
|
||||
let playlist = storage.get_playlist(playlist_id).await.map_err(ApiError)?;
|
||||
if require_write {
|
||||
// Write operations require ownership
|
||||
if playlist.owner_id != user_id {
|
||||
return Err(ApiError(pinakes_core::error::PinakesError::Authorization(
|
||||
"only the playlist owner can modify this playlist".into(),
|
||||
)));
|
||||
}
|
||||
} else {
|
||||
// Read operations: allow if public or owner
|
||||
if !playlist.is_public && playlist.owner_id != user_id {
|
||||
return Err(ApiError(pinakes_core::error::PinakesError::Authorization(
|
||||
"playlist is private".into(),
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(playlist)
|
||||
}
|
||||
|
||||
pub async fn create_playlist(
|
||||
State(state): State<AppState>,
|
||||
Extension(username): Extension<String>,
|
||||
Json(req): Json<CreatePlaylistRequest>,
|
||||
) -> Result<Json<PlaylistResponse>, ApiError> {
|
||||
if req.name.is_empty() || req.name.chars().count() > 255 {
|
||||
return Err(ApiError(
|
||||
pinakes_core::error::PinakesError::InvalidOperation(
|
||||
"playlist name must be 1-255 characters".into(),
|
||||
),
|
||||
));
|
||||
}
|
||||
let owner_id = resolve_user_id(&state.storage, &username).await?;
|
||||
let playlist = state
|
||||
.storage
|
||||
.create_playlist(
|
||||
owner_id,
|
||||
&req.name,
|
||||
req.description.as_deref(),
|
||||
req.is_public.unwrap_or(false),
|
||||
req.is_smart.unwrap_or(false),
|
||||
req.filter_query.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(PlaylistResponse::from(playlist)))
|
||||
}
|
||||
|
||||
pub async fn list_playlists(
|
||||
State(state): State<AppState>,
|
||||
Extension(username): Extension<String>,
|
||||
) -> Result<Json<Vec<PlaylistResponse>>, ApiError> {
|
||||
let user_id = resolve_user_id(&state.storage, &username).await?;
|
||||
// Fetch all playlists and filter to only public ones plus the user's own
|
||||
let playlists = state.storage.list_playlists(None).await?;
|
||||
let visible: Vec<PlaylistResponse> = playlists
|
||||
.into_iter()
|
||||
.filter(|p| p.is_public || p.owner_id == user_id)
|
||||
.map(PlaylistResponse::from)
|
||||
.collect();
|
||||
Ok(Json(visible))
|
||||
}
|
||||
|
||||
pub async fn get_playlist(
|
||||
State(state): State<AppState>,
|
||||
Extension(username): Extension<String>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<PlaylistResponse>, ApiError> {
|
||||
let user_id = resolve_user_id(&state.storage, &username).await?;
|
||||
let playlist = check_playlist_access(&state.storage, id, user_id, false).await?;
|
||||
Ok(Json(PlaylistResponse::from(playlist)))
|
||||
}
|
||||
|
||||
pub async fn update_playlist(
|
||||
State(state): State<AppState>,
|
||||
Extension(username): Extension<String>,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(req): Json<UpdatePlaylistRequest>,
|
||||
) -> Result<Json<PlaylistResponse>, ApiError> {
|
||||
if let Some(ref name) = req.name
|
||||
&& (name.is_empty() || name.chars().count() > 255) {
|
||||
return Err(ApiError(
|
||||
pinakes_core::error::PinakesError::InvalidOperation(
|
||||
"playlist name must be 1-255 characters".into(),
|
||||
),
|
||||
));
|
||||
}
|
||||
let user_id = resolve_user_id(&state.storage, &username).await?;
|
||||
check_playlist_access(&state.storage, id, user_id, true).await?;
|
||||
let playlist = state
|
||||
.storage
|
||||
.update_playlist(
|
||||
id,
|
||||
req.name.as_deref(),
|
||||
req.description.as_deref(),
|
||||
req.is_public,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(PlaylistResponse::from(playlist)))
|
||||
}
|
||||
|
||||
pub async fn delete_playlist(
|
||||
State(state): State<AppState>,
|
||||
Extension(username): Extension<String>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
let user_id = resolve_user_id(&state.storage, &username).await?;
|
||||
check_playlist_access(&state.storage, id, user_id, true).await?;
|
||||
state.storage.delete_playlist(id).await?;
|
||||
Ok(Json(serde_json::json!({"deleted": true})))
|
||||
}
|
||||
|
||||
pub async fn add_item(
|
||||
State(state): State<AppState>,
|
||||
Extension(username): Extension<String>,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(req): Json<PlaylistItemRequest>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
let user_id = resolve_user_id(&state.storage, &username).await?;
|
||||
check_playlist_access(&state.storage, id, user_id, true).await?;
|
||||
let position = match req.position {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
let items = state.storage.get_playlist_items(id).await?;
|
||||
items.len() as i32
|
||||
}
|
||||
};
|
||||
state
|
||||
.storage
|
||||
.add_to_playlist(id, MediaId(req.media_id), position)
|
||||
.await?;
|
||||
Ok(Json(serde_json::json!({"added": true})))
|
||||
}
|
||||
|
||||
pub async fn remove_item(
|
||||
State(state): State<AppState>,
|
||||
Extension(username): Extension<String>,
|
||||
Path((id, media_id)): Path<(Uuid, Uuid)>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
let user_id = resolve_user_id(&state.storage, &username).await?;
|
||||
check_playlist_access(&state.storage, id, user_id, true).await?;
|
||||
state
|
||||
.storage
|
||||
.remove_from_playlist(id, MediaId(media_id))
|
||||
.await?;
|
||||
Ok(Json(serde_json::json!({"removed": true})))
|
||||
}
|
||||
|
||||
pub async fn list_items(
|
||||
State(state): State<AppState>,
|
||||
Extension(username): Extension<String>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<Vec<MediaResponse>>, ApiError> {
|
||||
let user_id = resolve_user_id(&state.storage, &username).await?;
|
||||
check_playlist_access(&state.storage, id, user_id, false).await?;
|
||||
let items = state.storage.get_playlist_items(id).await?;
|
||||
Ok(Json(items.into_iter().map(MediaResponse::from).collect()))
|
||||
}
|
||||
|
||||
pub async fn reorder_item(
|
||||
State(state): State<AppState>,
|
||||
Extension(username): Extension<String>,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(req): Json<ReorderPlaylistRequest>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
let user_id = resolve_user_id(&state.storage, &username).await?;
|
||||
check_playlist_access(&state.storage, id, user_id, true).await?;
|
||||
state
|
||||
.storage
|
||||
.reorder_playlist(id, MediaId(req.media_id), req.new_position)
|
||||
.await?;
|
||||
Ok(Json(serde_json::json!({"reordered": true})))
|
||||
}
|
||||
|
||||
pub async fn shuffle_playlist(
|
||||
State(state): State<AppState>,
|
||||
Extension(username): Extension<String>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<Vec<MediaResponse>>, ApiError> {
|
||||
let user_id = resolve_user_id(&state.storage, &username).await?;
|
||||
check_playlist_access(&state.storage, id, user_id, false).await?;
|
||||
use rand::seq::SliceRandom;
|
||||
let mut items = state.storage.get_playlist_items(id).await?;
|
||||
items.shuffle(&mut rand::rng());
|
||||
Ok(Json(items.into_iter().map(MediaResponse::from).collect()))
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue