Files
Mangalord/backend/src/api/bookmarks.rs
MechaCat02 785b9755cf bugfix: case-insensitive usernames, reject non-positive bookmark page
Two related correctness fixes from the audit:

- Username uniqueness was case-sensitive (`username text UNIQUE`), so
  "Alice" and "alice" could both register and then race on login.
  Migration 0006 adds a unique index on `lower(username)`; the
  existing constraint is kept (overlapping but cheap) to avoid a
  destructive migration on any deployments that may already exist.
  `repo::user::find_by_username` now matches on `lower(username) =
  lower($1)` so login is case-insensitive against the same index.
  Test: registering "alice" then "Alice" returns 409 conflict; login
  with "ALICE" succeeds against the existing user.

- `POST /api/v1/bookmarks` silently accepted `page: 0` and `page: -1`
  even though both are nonsense for a 1-indexed page number. Reject
  with 422 `validation_failed` and `details.page` populated, matching
  the pattern used for missing-metadata / empty-title elsewhere. Test
  covers both 0 and -1.

Lockstep version bump to 0.9.4.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 23:27:19 +02:00

117 lines
3.4 KiB
Rust

//! 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<AppState> {
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<Uuid>,
#[serde(default)]
pub page: Option<i32>,
}
#[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<AppState>,
CurrentUser(user): CurrentUser,
Json(input): Json<NewBookmark>,
) -> AppResult<(StatusCode, Json<Bookmark>)> {
// 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<AppState>,
CurrentUser(user): CurrentUser,
Path(id): Path<Uuid>,
) -> AppResult<StatusCode> {
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<AppState>,
CurrentUser(user): CurrentUser,
Query(params): Query<ListParams>,
) -> AppResult<Json<PagedResponse<BookmarkSummary>>> {
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)))
}