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>
117 lines
3.4 KiB
Rust
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)))
|
|
}
|