feat(search): tag-based page search surface + per-page tags & collections

Add the /search surface (Pages / Chapters / Mangas tabs) backed by
per-user page tags and per-page collections: schema (migration 0023),
backend endpoints for page tags/collections and tagged-page aggregations
(with the OCR text-search param reserved at 501), plus the frontend API
clients, library Page-tags tab, collection page sections, page context
menu / AddTagsSheet, and reader long-press wiring. Includes the
continuous-reader navigation fixes (?page=N handling, chapter-reset
timing, back-button pops history) and tag-normalization hardening
accumulated on the branch.

Bump version 0.60.2 -> 0.62.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-13 15:51:38 +02:00
parent 9910a0a995
commit 6c901e64c9
50 changed files with 6971 additions and 132 deletions

View File

@@ -10,7 +10,7 @@ use crate::api::pagination::PagedResponse;
use crate::app::AppState;
use crate::auth::extractor::CurrentUser;
use crate::domain::collection::{
Collection, CollectionPatch, CollectionSummary, NewCollection,
Collection, CollectionPageItem, CollectionPatch, CollectionSummary, NewCollection,
};
use crate::domain::manga::Manga;
use crate::domain::patch::Patch;
@@ -27,10 +27,16 @@ pub fn routes() -> Router<AppState> {
"/collections/:id/mangas/:manga_id",
delete(remove_manga),
)
.route("/collections/:id/pages", get(list_pages).post(add_page))
.route("/collections/:id/pages/:page_id", delete(remove_page))
.route(
"/mangas/:id/my-collections",
get(list_my_collections_containing),
)
.route(
"/pages/:id/my-collections",
get(list_my_collections_containing_page),
)
}
const MAX_NAME_LEN: usize = 64;
@@ -54,11 +60,21 @@ pub struct AddMangaBody {
pub manga_id: Uuid,
}
#[derive(Debug, Deserialize)]
pub struct AddPageBody {
pub page_id: Uuid,
}
#[derive(Debug, Serialize)]
pub struct MangaCollectionIds {
pub collection_ids: Vec<Uuid>,
}
#[derive(Debug, Serialize)]
pub struct PageCollectionIds {
pub collection_ids: Vec<Uuid>,
}
fn validate_name(name: &str) -> AppResult<()> {
let trimmed = name.trim();
if trimmed.is_empty() {
@@ -218,6 +234,57 @@ async fn list_my_collections_containing(
Ok(Json(MangaCollectionIds { collection_ids: ids }))
}
async fn list_pages(
State(state): State<AppState>,
CurrentUser(user): CurrentUser,
Path(id): Path<Uuid>,
Query(params): Query<ListParams>,
) -> AppResult<Json<PagedResponse<CollectionPageItem>>> {
require_owner_id(&state, user.id, id).await?;
let limit = params.limit.clamp(1, 200);
let offset = params.offset.max(0);
let (items, total) =
repo::collection::list_pages(&state.db, id, limit, offset).await?;
Ok(Json(PagedResponse::with_total(items, limit, offset, total)))
}
async fn add_page(
State(state): State<AppState>,
CurrentUser(user): CurrentUser,
Path(id): Path<Uuid>,
Json(body): Json<AddPageBody>,
) -> AppResult<StatusCode> {
require_owner_id(&state, user.id, id).await?;
// FK violation in `repo::collection::add_page` maps to NotFound, so
// no separate `repo::page::exists` check is needed — the insert is
// the existence check.
let created = repo::collection::add_page(&state.db, id, body.page_id).await?;
Ok(if created { StatusCode::CREATED } else { StatusCode::OK })
}
async fn remove_page(
State(state): State<AppState>,
CurrentUser(user): CurrentUser,
Path((collection_id, page_id)): Path<(Uuid, Uuid)>,
) -> AppResult<StatusCode> {
require_owner_id(&state, user.id, collection_id).await?;
repo::collection::remove_page(&state.db, collection_id, page_id).await?;
Ok(StatusCode::NO_CONTENT)
}
async fn list_my_collections_containing_page(
State(state): State<AppState>,
CurrentUser(user): CurrentUser,
Path(page_id): Path<Uuid>,
) -> AppResult<Json<PageCollectionIds>> {
// Mirrors `list_my_collections_containing` for pages: unknown page
// returns an empty list, not 404 — keeps the endpoint side-effect-
// free and skips a distinguishing-status oracle.
let ids = repo::collection::list_collections_containing_page(&state.db, user.id, page_id)
.await?;
Ok(Json(PageCollectionIds { collection_ids: ids }))
}
/// Returns the row iff the caller owns it. Both "doesn't exist" and
/// "exists but belongs to someone else" surface as `NotFound` so the
/// API doesn't disclose collection existence to non-owners — the

View File

@@ -9,6 +9,7 @@ pub mod genres;
pub mod health;
pub mod history;
pub mod mangas;
pub mod page_tags;
pub mod pagination;
pub mod tags;
@@ -28,6 +29,7 @@ pub fn routes() -> Router<AppState> {
.merge(tags::routes())
.merge(authors::routes())
.merge(collections::routes())
.merge(page_tags::routes())
.merge(history::routes())
.merge(admin::routes())
}

View File

@@ -0,0 +1,443 @@
//! Per-page tag endpoints. See `migration 0023` for the underlying
//! schema and `repo::page_tag` for the query layer. All endpoints
//! require `CurrentUser` — every byte of this data is owned by the
//! caller.
use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use axum::routing::{delete, get, post};
use axum::{Json, Router};
use serde::{Deserialize, Serialize};
use serde_json::json;
use uuid::Uuid;
use crate::api::pagination::PagedResponse;
use crate::app::AppState;
use crate::auth::extractor::CurrentUser;
use crate::domain::page_tag::{
NewPageTag, PageTagSummary, TaggedChapterAggregate, TaggedMangaAggregate,
TaggedPageItem,
};
use crate::error::{AppError, AppResult};
use crate::repo;
use crate::repo::page_tag::Order;
pub fn routes() -> Router<AppState> {
Router::new()
.route("/pages/:id/tags", post(add))
.route("/pages/:id/tags/:tag", delete(remove))
// GET uses `/my-tags` to mirror the `mangas/:id/my-collections`
// convention — the URL says whose tags we're reading even
// though the cookie already implies it.
.route("/pages/:id/my-tags", get(list_for_page))
.route("/me/page-tags", get(list_mine))
.route("/me/page-tags/distinct", get(list_distinct_mine))
.route("/me/page-tags/chapters", get(list_chapters_for_tag))
.route("/me/page-tags/mangas", get(list_mangas_for_tag))
}
const MAX_TAG_LEN: usize = 64;
/// Hard wire-level byte cap, applied before any allocation in
/// `normalize_tag`. A 10MB tag in the JSON body would otherwise
/// allocate twice in `to_lowercase()` + `split_whitespace().collect()`
/// before the 64-char post-normalize cap rejected it. 4 KiB is a
/// generous ~64x the post-normalize char cap and well below anything
/// a user could fairly call a "tag".
const MAX_TAG_BYTES: usize = 4096;
const DEFAULT_LIMIT: i64 = 50;
const DEFAULT_DISTINCT_LIMIT: i64 = 200;
#[derive(Debug, Deserialize)]
pub struct ListMineParams {
#[serde(default = "default_limit")]
pub limit: i64,
#[serde(default)]
pub offset: i64,
/// Restrict to this exact tag (chip filter in the library tab).
#[serde(default)]
pub tag: Option<String>,
/// Prefix filter (autocomplete-style).
#[serde(default)]
pub q: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct DistinctParams {
#[serde(default)]
pub q: Option<String>,
#[serde(default = "default_distinct_limit")]
pub limit: i64,
}
#[derive(Debug, Serialize)]
struct TagsResponse {
tags: Vec<String>,
}
fn default_limit() -> i64 {
DEFAULT_LIMIT
}
fn default_distinct_limit() -> i64 {
DEFAULT_DISTINCT_LIMIT
}
/// Normalize a user-supplied tag for storage. Lowercases, trims,
/// collapses internal whitespace, rejects control chars and edge
/// colons, caps at 64 chars. Single source of truth so the same input
/// produces the same row regardless of which client sent it.
/// True for Unicode format / invisible chars that would let two
/// visually-identical tags coexist as distinct rows — `"funny"` and
/// `"funny\u{200d}"` are different codepoints but render the same, and
/// the autocomplete + chip cloud would split them. We hardcode the
/// well-known offenders (a subset of the Unicode `Cf` general category
/// plus the bidi-override block) so the file stays free of new deps.
fn is_invisible_format(c: char) -> bool {
matches!(c,
// Soft hyphen, Arabic letter mark, Mongolian vowel separator.
'\u{00AD}' | '\u{061C}' | '\u{180E}'
// ZWSP, ZWNJ, ZWJ, LRM, RLM.
| '\u{200B}'..='\u{200F}'
// LRE / RLE / PDF / LRO / RLO.
| '\u{202A}'..='\u{202E}'
// Word joiner, function-application markers, deprecated
// formatting (U+206A..U+206F).
| '\u{2060}'..='\u{2064}'
| '\u{2066}'..='\u{206F}'
// BOM / ZWNBSP.
| '\u{FEFF}'
// Plane-14 LANGUAGE TAG + TAG characters. These can tunnel
// ASCII semantics invisibly (the same mechanism used in 2024's
// LLM prompt-injection smuggling work). Storing them would
// let two visually-identical tags coexist while carrying
// distinct hidden payloads.
| '\u{E0001}'
| '\u{E0020}'..='\u{E007F}'
)
}
fn normalize_tag(input: &str) -> AppResult<String> {
// Fast-fail on absurd input before allocating in lowercase /
// whitespace passes. Cheap and bounds worst-case work.
if input.len() > MAX_TAG_BYTES {
return Err(AppError::ValidationFailed {
message: "tag too long".into(),
details: json!({ "tag": format!("max {MAX_TAG_BYTES} bytes") }),
});
}
let trimmed = input.trim();
if trimmed.is_empty() {
return Err(AppError::ValidationFailed {
message: "tag is required".into(),
details: json!({ "tag": "required" }),
});
}
if trimmed.chars().any(|c| c.is_control()) {
return Err(AppError::ValidationFailed {
message: "tag contains control characters".into(),
details: json!({ "tag": "control_chars" }),
});
}
if trimmed.chars().any(is_invisible_format) {
return Err(AppError::ValidationFailed {
message: "tag contains invisible / format characters".into(),
details: json!({ "tag": "invisible_chars" }),
});
}
// Reject characters that would break the DELETE URL path
// (`/v1/pages/:id/tags/:tag`). axum decodes %2F back to `/` after
// routing, so a tag containing one of these would be silently
// unreachable to the delete handler — store-only. Reject up front
// so every stored tag is removable. `%` and `_` are also rejected
// since they are LIKE wildcards in the `list_for_user` prefix
// filter and would otherwise let a user accidentally search for
// anything.
if trimmed.chars().any(|c| matches!(c, '/' | '\\' | '?' | '#' | '%' | '_')) {
return Err(AppError::ValidationFailed {
message: "tag contains a forbidden character (/ \\ ? # % _)".into(),
details: json!({ "tag": "forbidden_chars" }),
});
}
// Lowercase + collapse internal whitespace runs (any kind: spaces,
// tabs, fullwidth space, etc.) into a single ASCII space. This
// keeps "Foo Bar" and "foo bar" identifying the same tag.
let normalized: String = trimmed
.to_lowercase()
.split_whitespace()
.collect::<Vec<_>>()
.join(" ");
// `namespace:value` is permitted but a bare leading/trailing colon
// is nonsense and would break any future split.
if normalized.starts_with(':') || normalized.ends_with(':') {
return Err(AppError::ValidationFailed {
message: "tag must not start or end with ':'".into(),
details: json!({ "tag": "edge_colon" }),
});
}
if normalized.chars().count() > MAX_TAG_LEN {
// "After normalization" because Unicode case-folding can
// expand chars (Turkish capital `İ` → `i\u{307}`, so 33 İ's
// pass the wire-level 33-char input but trip this 64 cap).
return Err(AppError::ValidationFailed {
message: "tag too long after normalization".into(),
details: json!({ "tag": format!("max {MAX_TAG_LEN} characters") }),
});
}
Ok(normalized)
}
async fn add(
State(state): State<AppState>,
CurrentUser(user): CurrentUser,
Path(page_id): Path<Uuid>,
Json(input): Json<NewPageTag>,
) -> AppResult<StatusCode> {
let tag = normalize_tag(&input.tag)?;
let created = repo::page_tag::upsert(&state.db, user.id, page_id, &tag).await?;
Ok(if created { StatusCode::CREATED } else { StatusCode::OK })
}
async fn remove(
State(state): State<AppState>,
CurrentUser(user): CurrentUser,
Path((page_id, tag)): Path<(Uuid, String)>,
) -> AppResult<StatusCode> {
// Normalize the URL-decoded tag the same way add() did, so
// DELETE /pages/.../tags/Funny removes the row stored as "funny".
let normalized = normalize_tag(&tag)?;
repo::page_tag::remove(&state.db, user.id, page_id, &normalized).await?;
Ok(StatusCode::NO_CONTENT)
}
async fn list_for_page(
State(state): State<AppState>,
CurrentUser(user): CurrentUser,
Path(page_id): Path<Uuid>,
) -> AppResult<Json<TagsResponse>> {
let tags = repo::page_tag::list_for_page(&state.db, user.id, page_id).await?;
Ok(Json(TagsResponse { tags }))
}
async fn list_mine(
State(state): State<AppState>,
CurrentUser(user): CurrentUser,
Query(params): Query<ListMineParams>,
) -> AppResult<Json<PagedResponse<TaggedPageItem>>> {
let limit = params.limit.clamp(1, 200);
let offset = params.offset.max(0);
// Filters from the wire arrive raw — normalize them so a filter on
// "Funny" matches rows stored as "funny".
let tag_filter = params
.tag
.as_deref()
.map(normalize_tag)
.transpose()?;
let prefix_filter = params
.q
.as_deref()
.map(normalize_tag)
.transpose()?;
let (items, total) = repo::page_tag::list_for_user(
&state.db,
user.id,
tag_filter.as_deref(),
prefix_filter.as_deref(),
limit,
offset,
)
.await?;
Ok(Json(PagedResponse::with_total(items, limit, offset, total)))
}
async fn list_distinct_mine(
State(state): State<AppState>,
CurrentUser(user): CurrentUser,
Query(params): Query<DistinctParams>,
) -> AppResult<Json<DistinctResponse>> {
let limit = params.limit.clamp(1, 500);
let prefix = params
.q
.as_deref()
.map(normalize_tag)
.transpose()?;
let items = repo::page_tag::distinct_tags_for_user(
&state.db,
user.id,
prefix.as_deref(),
limit,
)
.await?;
Ok(Json(DistinctResponse { items }))
}
#[derive(Debug, Serialize)]
struct DistinctResponse {
items: Vec<PageTagSummary>,
}
#[derive(Debug, Deserialize)]
pub struct AggregateParams {
/// Required. Exact tag to aggregate by. Empty / whitespace-only
/// inputs are rejected with 422 via `normalize_tag`.
pub tag: String,
/// `desc` (default) or `asc`. Anything else → 422.
#[serde(default)]
pub order: Option<String>,
#[serde(default = "default_limit")]
pub limit: i64,
#[serde(default)]
pub offset: i64,
/// Reserved for the planned OCR text-search input. Accepted on
/// the wire so adding OCR later won't break the API shape, but
/// rejected with 501 `text_search_not_yet_supported` if non-empty
/// until the backend supports it.
#[serde(default)]
pub text: Option<String>,
}
fn parse_order(raw: Option<&str>) -> AppResult<Order> {
match raw.map(str::trim) {
None | Some("") | Some("desc") => Ok(Order::Desc),
Some("asc") => Ok(Order::Asc),
Some(other) => Err(AppError::ValidationFailed {
message: format!("order must be 'desc' or 'asc' (got {other:?})"),
details: json!({ "order": "invalid" }),
}),
}
}
fn ensure_text_unsupported(text: Option<&str>) -> AppResult<()> {
// Future OCR search will plug in here. Until then, return a
// distinct code (`text_search_not_yet_supported`) so clients can
// detect "feature pending" vs. a generic 4xx — the code is the
// wire contract, not the message.
if text.is_some_and(|s| !s.trim().is_empty()) {
return Err(AppError::NotImplemented {
code: "text_search_not_yet_supported",
message: "text search is reserved for the planned OCR input but not yet supported",
});
}
Ok(())
}
async fn list_chapters_for_tag(
State(state): State<AppState>,
CurrentUser(user): CurrentUser,
Query(params): Query<AggregateParams>,
) -> AppResult<Json<PagedResponse<TaggedChapterAggregate>>> {
ensure_text_unsupported(params.text.as_deref())?;
let tag = normalize_tag(&params.tag)?;
let order = parse_order(params.order.as_deref())?;
let limit = params.limit.clamp(1, 200);
let offset = params.offset.max(0);
let (items, total) = repo::page_tag::aggregate_chapters_for_tag(
&state.db, user.id, &tag, order, limit, offset,
)
.await?;
Ok(Json(PagedResponse::with_total(items, limit, offset, total)))
}
async fn list_mangas_for_tag(
State(state): State<AppState>,
CurrentUser(user): CurrentUser,
Query(params): Query<AggregateParams>,
) -> AppResult<Json<PagedResponse<TaggedMangaAggregate>>> {
ensure_text_unsupported(params.text.as_deref())?;
let tag = normalize_tag(&params.tag)?;
let order = parse_order(params.order.as_deref())?;
let limit = params.limit.clamp(1, 200);
let offset = params.offset.max(0);
let (items, total) = repo::page_tag::aggregate_mangas_for_tag(
&state.db, user.id, &tag, order, limit, offset,
)
.await?;
Ok(Json(PagedResponse::with_total(items, limit, offset, total)))
}
#[cfg(test)]
mod tests {
use super::normalize_tag;
#[test]
fn lowercases_and_collapses_whitespace() {
assert_eq!(normalize_tag("Funny").unwrap(), "funny");
assert_eq!(normalize_tag(" Foo Bar ").unwrap(), "foo bar");
assert_eq!(normalize_tag("character:Askeladd").unwrap(), "character:askeladd");
}
#[test]
fn rejects_blank_input() {
assert!(normalize_tag("").is_err());
assert!(normalize_tag(" ").is_err());
}
#[test]
fn rejects_control_chars() {
assert!(normalize_tag("bad\ntag").is_err());
assert!(normalize_tag("a\tb").is_err());
// NEL (U+0085, Cc) — rejected via `char::is_control()`. Locked
// here so a future stdlib drift surfaces as a test fail rather
// than a silent acceptance.
assert!(normalize_tag("a\u{0085}b").is_err());
// NULL byte.
assert!(normalize_tag("a\u{0000}b").is_err());
}
#[test]
fn rejects_edge_colon() {
assert!(normalize_tag(":foo").is_err());
assert!(normalize_tag("foo:").is_err());
}
#[test]
fn rejects_invisible_format_chars() {
// ZWJ — visually indistinguishable from no-zwj, would split
// "funny" and "funny\u{200d}" into distinct rows.
assert!(normalize_tag("funny\u{200d}").is_err());
// RLO — bidi override could let a tag display backwards.
assert!(normalize_tag("a\u{202e}b").is_err());
// ZWNBSP / BOM at the boundary survives `trim()`.
assert!(normalize_tag("a\u{feff}b").is_err());
// ZWSP, RLM also rejected.
assert!(normalize_tag("a\u{200b}b").is_err());
assert!(normalize_tag("a\u{200f}b").is_err());
// Plane-14 LANGUAGE TAG + TAG-character block. These tunnel
// ASCII invisibly via the supplementary tag mechanism.
assert!(normalize_tag("a\u{E0001}b").is_err());
assert!(normalize_tag("a\u{E0020}b").is_err());
assert!(normalize_tag("a\u{E007F}b").is_err());
}
#[test]
fn rejects_path_breaking_and_like_wildcards() {
// `/` would survive %-encoding round-trip but axum decodes
// %2F back to `/` after path routing, so the DELETE path
// would never match. Same for `?`, `#`, `\`.
assert!(normalize_tag("a/b").is_err());
assert!(normalize_tag("a\\b").is_err());
assert!(normalize_tag("a?b").is_err());
assert!(normalize_tag("a#b").is_err());
// `%` and `_` are SQL LIKE wildcards in the prefix filter.
assert!(normalize_tag("a%b").is_err());
assert!(normalize_tag("a_b").is_err());
}
#[test]
fn rejects_too_long() {
let long = "a".repeat(65);
assert!(normalize_tag(&long).is_err());
let ok_len = "a".repeat(64);
assert!(normalize_tag(&ok_len).is_ok());
}
#[test]
fn rejects_pathologically_long_input_before_allocating() {
// 10 MiB of `a` — should fail-fast on the byte cap, not
// allocate twice through the lowercase/whitespace passes.
let huge = "a".repeat(10 * 1024 * 1024);
assert!(normalize_tag(&huge).is_err());
// Just under cap still goes through the rest of the pipeline
// and fails on the char count.
let near_cap = "a".repeat(super::MAX_TAG_BYTES);
assert!(normalize_tag(&near_cap).is_err());
}
}