Files
Mangalord/backend/src/repo/user.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

66 lines
1.7 KiB
Rust

//! User persistence.
use sqlx::PgPool;
use uuid::Uuid;
use crate::domain::User;
use crate::error::{AppError, AppResult};
pub async fn create(pool: &PgPool, username: &str, password_hash: &str) -> AppResult<User> {
let result = sqlx::query_as::<_, User>(
r#"
INSERT INTO users (username, password_hash)
VALUES ($1, $2)
RETURNING id, username, password_hash, created_at
"#,
)
.bind(username)
.bind(password_hash)
.fetch_one(pool)
.await;
match result {
Ok(user) => Ok(user),
Err(e) if is_unique_violation(&e) => {
Err(AppError::Conflict("username is already taken".into()))
}
Err(e) => Err(AppError::Database(e)),
}
}
/// 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 lower(username) = lower($1)
"#,
)
.bind(username)
.fetch_optional(pool)
.await?;
Ok(row)
}
pub async fn find_by_id(pool: &PgPool, id: Uuid) -> AppResult<Option<User>> {
let row = sqlx::query_as::<_, User>(
r#"SELECT id, username, password_hash, created_at FROM users WHERE id = $1"#,
)
.bind(id)
.fetch_optional(pool)
.await?;
Ok(row)
}
fn is_unique_violation(err: &sqlx::Error) -> bool {
if let sqlx::Error::Database(db_err) = err {
db_err.code().as_deref() == Some("23505")
} else {
false
}
}