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:
@@ -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?;
|
||||
|
||||
@@ -28,9 +28,17 @@ pub async fn create(pool: &PgPool, username: &str, password_hash: &str) -> AppRe
|
||||
}
|
||||
}
|
||||
|
||||
/// Case-insensitive lookup so login with "Alice" matches a user
|
||||
/// registered as "alice" (the unique index on `lower(username)` keeps
|
||||
/// the comparison cheap). Equivalent in spirit to ILIKE but uses the
|
||||
/// functional index directly.
|
||||
pub async fn find_by_username(pool: &PgPool, username: &str) -> AppResult<Option<User>> {
|
||||
let row = sqlx::query_as::<_, User>(
|
||||
r#"SELECT id, username, password_hash, created_at FROM users WHERE username = $1"#,
|
||||
r#"
|
||||
SELECT id, username, password_hash, created_at
|
||||
FROM users
|
||||
WHERE lower(username) = lower($1)
|
||||
"#,
|
||||
)
|
||||
.bind(username)
|
||||
.fetch_optional(pool)
|
||||
|
||||
Reference in New Issue
Block a user