//! Bookmarks — owned by a `CurrentUser`. Reads + writes both require //! auth; the listing endpoint is scoped under `/me/bookmarks` so the //! URL itself can't be reused to peek at another user's bookmarks. use axum::extract::{Path, Query, State}; use axum::http::StatusCode; use axum::routing::{delete, get, post}; use axum::{Json, Router}; use serde::Deserialize; use serde_json::json; use uuid::Uuid; use crate::api::pagination::PagedResponse; use crate::app::AppState; use crate::auth::extractor::CurrentUser; use crate::domain::{Bookmark, BookmarkSummary}; use crate::error::{AppError, AppResult}; use crate::repo; pub fn routes() -> Router { Router::new() .route("/bookmarks", post(create)) .route("/bookmarks/:id", delete(delete_one)) .route("/me/bookmarks", get(list_me)) } #[derive(Debug, Deserialize)] pub struct NewBookmark { pub manga_id: Uuid, #[serde(default)] pub chapter_id: Option, #[serde(default)] pub page: Option, } #[derive(Debug, Deserialize)] pub struct ListParams { #[serde(default = "default_limit")] pub limit: i64, #[serde(default)] pub offset: i64, } fn default_limit() -> i64 { 50 } async fn create( State(state): State, CurrentUser(user): CurrentUser, Json(input): Json, ) -> AppResult<(StatusCode, Json)> { // Reject obviously-bad page numbers up front (0-based or negative // page indexes were silently accepted before; not exploitable but // not what callers mean). if let Some(p) = input.page { if p < 1 { return Err(AppError::ValidationFailed { message: "page must be 1 or greater".into(), details: json!({ "page": "must be >= 1" }), }); } } // Surface 404 on a non-existent manga / chapter rather than letting // the foreign-key violation collapse into a generic 500. repo::manga::get(&state.db, input.manga_id).await?; if let Some(chapter_id) = input.chapter_id { let exists: Option<(Uuid,)> = sqlx::query_as( "SELECT id FROM chapters WHERE id = $1 AND manga_id = $2", ) .bind(chapter_id) .bind(input.manga_id) .fetch_optional(&state.db) .await?; if exists.is_none() { return Err(AppError::NotFound); } } let bookmark = repo::bookmark::create( &state.db, user.id, input.manga_id, input.chapter_id, input.page, ) .await?; Ok((StatusCode::CREATED, Json(bookmark))) } async fn delete_one( State(state): State, CurrentUser(user): CurrentUser, Path(id): Path, ) -> AppResult { match repo::bookmark::find_owner(&state.db, id).await? { None => Err(AppError::NotFound), Some(owner) if owner != user.id => Err(AppError::Forbidden), Some(_) => { repo::bookmark::delete(&state.db, id).await?; Ok(StatusCode::NO_CONTENT) } } } async fn list_me( State(state): State, CurrentUser(user): CurrentUser, Query(params): Query, ) -> AppResult>> { let limit = params.limit.clamp(1, 200); let offset = params.offset.max(0); let items = repo::bookmark::list_for_user(&state.db, user.id, limit, offset).await?; Ok(Json(PagedResponse::new(items, limit, offset))) }