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>
66 lines
1.7 KiB
Rust
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
|
|
}
|
|
}
|