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>
This commit is contained in:
MechaCat02
2026-05-16 23:27:19 +02:00
parent 80ab119750
commit 785b9755cf
8 changed files with 100 additions and 4 deletions

View File

@@ -7,6 +7,7 @@ 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;
@@ -49,6 +50,18 @@ async fn create(
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?;