refactor(quotesdb): move db/handlers to lib modules, upgrade worker to 0.7, update infra

- Move src/bin/api/db/ and src/bin/api/handlers/ to src/db/ and
  src/handlers/ so they compile as library modules accessible to both
  the native binary and the Cloudflare Workers entry point
- Upgrade worker crate 0.5 → 0.7; add workers-api feature flag and
  cdylib/rlib crate-type to Cargo.toml
- Update flake.nix: add worker-build and just to the dev shell; bump
  flake.lock (nixpkgs + rust-overlay)
- Consolidate rate limit rules to one (Free plan allows only 1 rule
  per zone in the http_ratelimit phase)
- Update infra/worker.tf to deploy via wrangler rather than Terraform
  (Cloudflare provider v4 can't upload ES module + wasm bundles)
- Extend .gitignore to exclude *.wasm build artifacts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
quotesdb
Elijah Voigt 3 months ago
parent b00f24ae85
commit deb3ec40f6

@ -51,11 +51,11 @@
},
"nixpkgs_2": {
"locked": {
"lastModified": 1772419343,
"narHash": "sha256-QU3Cd5DJH7dHyMnGEFfPcZDaCAsJQ6tUD+JuUsYqnKU=",
"lastModified": 1772956932,
"narHash": "sha256-M0yS4AafhKxPPmOHGqIV0iKxgNO8bHDWdl1kOwGBwRY=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "93178f6a00c22fcdee1c6f5f9ab92f2072072ea9",
"rev": "608d0cadfed240589a7eea422407a547ad626a14",
"type": "github"
},
"original": {
@ -101,11 +101,11 @@
]
},
"locked": {
"lastModified": 1772420823,
"narHash": "sha256-q3oVwz1Rx41D1D+F6vg41kpOkk3Zi3KwnkHEZp7DCGs=",
"lastModified": 1773025773,
"narHash": "sha256-Wik8+xApNfldpUFjPmJkPdg0RrvUPSWGIZis+A/0N1w=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "458eea8d905c609e9d889423e6b8a1c7bc2f792c",
"rev": "3c06fdbbd36ff60386a1e590ee0cd52dcd1892bf",
"type": "github"
},
"original": {

@ -43,6 +43,7 @@
# Cloudflare
pkgs.wrangler
pkgs.worker-build
# General
pkgs.pkg-config
@ -62,6 +63,8 @@
# Build book
pkgs.mdbook
pkgs.just
];
shellHook = ''

@ -1,2 +1,7 @@
dist/
# Binary database file
quotesdb.sqlite*
# compiled files
*.wasm

47
quotesdb/Cargo.lock generated

@ -455,7 +455,7 @@ dependencies = [
"gloo-events",
"gloo-utils",
"serde",
"serde-wasm-bindgen 0.6.5",
"serde-wasm-bindgen",
"serde_urlencoded",
"thiserror 1.0.69",
"wasm-bindgen",
@ -1465,17 +1465,6 @@ dependencies = [
"serde_derive",
]
[[package]]
name = "serde-wasm-bindgen"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3b143e2833c57ab9ad3ea280d21fd34e285a42837aeb0ee301f4f41890fa00e"
dependencies = [
"js-sys",
"serde",
"wasm-bindgen",
]
[[package]]
name = "serde-wasm-bindgen"
version = "0.6.5"
@ -2091,9 +2080,9 @@ dependencies = [
[[package]]
name = "wasm-streams"
version = "0.4.2"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb"
dependencies = [
"futures-util",
"js-sys",
@ -2404,9 +2393,9 @@ dependencies = [
[[package]]
name = "worker"
version = "0.5.0"
version = "0.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "727789ca7eff9733efbea9d0e97779edc1cf1926e98aee7d7d8afe32805458aa"
checksum = "7267f3baa986254a8dace6f6a7c6ab88aef59f00c03aaad6749e048b5faaf6f6"
dependencies = [
"async-trait",
"bytes",
@ -2419,7 +2408,7 @@ dependencies = [
"matchit 0.7.3",
"pin-project",
"serde",
"serde-wasm-bindgen 0.6.5",
"serde-wasm-bindgen",
"serde_json",
"serde_urlencoded",
"tokio",
@ -2428,31 +2417,15 @@ dependencies = [
"wasm-bindgen-futures",
"wasm-streams",
"web-sys",
"worker-kv",
"worker-macros",
"worker-sys",
]
[[package]]
name = "worker-kv"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f06d4d1416a9f8346ee9123b0d9a11b3cfa38e6cfb5a139698017d1597c4d41"
dependencies = [
"js-sys",
"serde",
"serde-wasm-bindgen 0.5.0",
"serde_json",
"thiserror 1.0.69",
"wasm-bindgen",
"wasm-bindgen-futures",
]
[[package]]
name = "worker-macros"
version = "0.5.0"
version = "0.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d625c24570ba9207a2617476013335f28a95cbe513e59bb814ffba092a18058"
checksum = "7410081121531ec2fa111ab17b911efc601d7b6d590c0a92b847874ebeff0030"
dependencies = [
"async-trait",
"proc-macro2",
@ -2466,9 +2439,9 @@ dependencies = [
[[package]]
name = "worker-sys"
version = "0.5.0"
version = "0.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34563340d41016b4381257c5a16b0d2bc590dbe00500ecfbebcaa16f5f85ce90"
checksum = "4777582bf8a04174a034cb336f3702eb0e5cb444a67fdaa4fd44454ff7e2dd95"
dependencies = [
"cfg-if",
"js-sys",

@ -5,6 +5,15 @@ edition = "2021"
license = "MIT OR Apache-2.0"
default-run = "api"
[features]
# Enable API server code: Cloudflare D1 bindings, route handlers, and the
# Workers fetch event entry point. Required for `cargo run` (native) and
# `worker-build` (wasm32 Workers). Do NOT enable for Trunk UI builds.
workers-api = []
[lib]
crate-type = ["cdylib", "rlib"]
[[bin]]
name = "api"
path = "src/bin/api/main.rs"
@ -52,7 +61,7 @@ uuid = { version = "1", features = ["js"] }
# Cloudflare Workers SDK — provides D1 bindings, fetch, KV, etc.
# The `http` feature makes the fetch handler accept/return standard http types,
# enabling direct Axum router integration without manual request conversion.
worker = { version = "0.5", features = ["d1", "http"] }
worker = { version = "0.7", features = ["d1", "http"] }
# Axum HTTP framework for the WASM Workers entry point — minimal feature set
# (no full tokio runtime needed; json and query features required for handlers).
axum = { version = "0.8", default-features = false, features = ["json", "query"] }

@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>QuotesDB</title>
<link data-trunk rel="css" href="src/bin/ui/style.css"/>
<link data-trunk rel="rust" data-bin="ui" />
<link data-trunk rel="rust" data-bin="ui" data-wasm-opt-params="--all-features" />
<link data-trunk rel="copy-file" href="_redirects"/>
<!-- Cloudflare Turnstile CAPTCHA widget -->
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>

@ -8,7 +8,7 @@ OpenTofu configuration for deploying quotesdb to Cloudflare.
|---|---|
| `cloudflare_d1_database.quotesdb` | D1 SQLite database backing the API |
| `cloudflare_workers_script.api` | Compiled Wasm Worker serving `/api/*` |
| `cloudflare_worker_route.api` | Routes `quotes.elijah.run/api/*` to the Worker |
| `cloudflare_workers_route.api` | Routes `quotes.elijah.run/api/*` to the Worker |
| `cloudflare_pages_project.ui` | Pages project hosting the Yew SPA |
| `cloudflare_record.ui` | CNAME `quotes.elijah.run` → Pages |
| `cloudflare_pages_domain.ui` | Custom domain binding on Pages |

@ -7,5 +7,10 @@ terraform {
source = "registry.terraform.io/cloudflare/cloudflare"
version = "~> 4"
}
# null provider for wrangler-based worker deployment via local-exec.
null = {
source = "registry.terraform.io/hashicorp/null"
version = "~> 3"
}
}
}

@ -1,8 +1,9 @@
# Cloudflare WAF rate limiting rules for the quotesdb API.
# Uses the http_ratelimit phase of cloudflare_ruleset to enforce per-IP request caps
# on all mutating endpoints. Rules are evaluated top-to-bottom; first match wins.
# The report rule must appear before the general update rule to prevent the broader
# /api/quotes/* pattern from matching /api/quotes/*/report first.
# on all mutating endpoints.
#
# NOTE: The Cloudflare Free plan allows only 1 rule per zone in the http_ratelimit
# phase, so all mutating endpoints are combined into a single rule.
resource "cloudflare_ruleset" "api_rate_limits" {
# Scoped to the elijah.run zone that hosts quotes.elijah.run.
zone_id = var.cloudflare_zone_id
@ -12,58 +13,22 @@ resource "cloudflare_ruleset" "api_rate_limits" {
phase = "http_ratelimit"
rules {
# Limit quote creation via PUT /api/quotes: 5 requests per IP per 10 minutes.
# PUT is the create verb per the quotesdb API design.
description = "Limit PUT /api/quotes to 5 per IP per 10 minutes"
expression = "(http.request.method eq \"PUT\" and http.request.uri.path eq \"/api/quotes\")"
# Limit all requests to /api/ to 2 per IP per 10-second window.
# Free plan restrictions:
# - expression fields: Path and Verified Bot only (no Method, no regex)
# - characteristics: IP only (no cf.colo.id)
# - period: must be 10 (only allowed value on Free)
# - operator: no `matches` (regex); use `contains` instead
description = "Limit /api/ requests to 2 per IP per 10 seconds"
expression = "http.request.uri.path contains \"/api/quotes\""
action = "block"
ratelimit {
characteristics = ["ip.src"]
period = 600
requests_per_period = 5
mitigation_timeout = 600
}
}
rules {
# Limit report submissions: 3 POST /api/quotes/:id/report per IP per hour.
# This rule must precede the general update rule below so the more-specific
# /report path is matched first before the broader /api/quotes/* pattern.
description = "Limit POST /api/quotes/*/report to 3 per IP per hour"
expression = "(http.request.method eq \"POST\" and http.request.uri.path matches \"/api/quotes/[^/]+/report$\")"
action = "block"
ratelimit {
characteristics = ["ip.src"]
period = 3600
requests_per_period = 3
mitigation_timeout = 3600
}
}
rules {
# Limit quote updates: 10 POST /api/quotes/:id per IP per minute.
# Excludes the /report sub-path (handled by the rule above).
description = "Limit POST /api/quotes/:id to 10 per IP per minute"
expression = "(http.request.method eq \"POST\" and http.request.uri.path matches \"/api/quotes/[^/]+$\")"
action = "block"
ratelimit {
characteristics = ["ip.src"]
period = 60
requests_per_period = 10
mitigation_timeout = 60
}
}
rules {
# Limit quote deletes: 10 DELETE /api/quotes/:id per IP per minute.
description = "Limit DELETE /api/quotes/:id to 10 per IP per minute"
expression = "(http.request.method eq \"DELETE\" and http.request.uri.path matches \"/api/quotes/[^/]+$\")"
action = "block"
ratelimit {
characteristics = ["ip.src"]
period = 60
requests_per_period = 10
mitigation_timeout = 60
# cf.colo.id is required by the API (20155) rate limiting is processed
# per-colocation and must always be included alongside ip.src.
characteristics = ["ip.src", "cf.colo.id"]
period = 10
requests_per_period = 2
mitigation_timeout = 10
}
}
}

@ -1,27 +1,30 @@
# Cloudflare Workers script for the quotesdb API.
# Compiled from the `api` binary targeting wasm32-unknown-unknown.
# Build before applying: cargo build --release --bin api --target wasm32-unknown-unknown
resource "cloudflare_workers_script" "api" {
account_id = var.cloudflare_account_id
# Script name used in the Cloudflare dashboard and for routing.
name = "quotesdb-api"
# Compiled Wasm binary path is relative to the infra/ directory.
content = filebase64("../target/wasm32-unknown-unknown/release/api.wasm")
# D1 database binding referenced in workers-rs code as `env.DB`.
# Depends implicitly on cloudflare_d1_database.quotesdb via attribute reference.
d1_database_binding {
name = "DB"
database_id = cloudflare_d1_database.quotesdb.id
# Deploys the quotesdb API Worker using wrangler.
# The Cloudflare Terraform provider v4 cannot upload ES module workers that
# import wasm files (error 10021). wrangler handles the multipart module bundle
# upload correctly, so we use a null_resource with local-exec instead.
#
# The worker route (quotes.elijah.run/api/*) is also managed by wrangler via
# wrangler.toml [[routes]] keeping route and script lifecycle together avoids
# authentication errors from split ownership.
#
# Build before applying: just build-api (runs worker-build --release)
# Output lands in build/: index.js (ES module entry) + index_bg.wasm (wasm-bindgen output).
resource "null_resource" "worker_deploy" {
# Re-deploy whenever the compiled JS or wasm changes.
triggers = {
js_hash = filemd5("../build/index.js")
wasm_hash = filemd5("../build/index_bg.wasm")
}
}
# Route that maps quotes.elijah.run/api/* to the quotesdb-api Worker.
# All other requests on the domain are served by Cloudflare Pages.
resource "cloudflare_worker_route" "api" {
zone_id = var.cloudflare_zone_id
pattern = "quotes.elijah.run/api/*"
script_name = cloudflare_workers_script.api.name
# Run wrangler deploy from the quotesdb project root (where build/ and wrangler.toml live).
provisioner "local-exec" {
command = "wrangler deploy --config wrangler.toml"
working_dir = ".."
environment = {
CLOUDFLARE_API_TOKEN = var.cloudflare_api_token
# Providing the account ID avoids a /memberships API call that fails
# with restricted-scope tokens.
CLOUDFLARE_ACCOUNT_ID = var.cloudflare_account_id
}
}
}

@ -1,46 +0,0 @@
//! Database connection setup for the native API server.
//!
//! Provides [`open`] which opens a `tokio-rusqlite` connection, configures
//! SQLite pragmas for WAL mode and foreign key enforcement, and wraps the
//! result in a [`NativeRepository`].
use super::{DbError, NativeRepository};
use tokio_rusqlite::Connection;
/// Open a SQLite database at `path` and return a configured [`NativeRepository`].
///
/// This function:
/// 1. Opens the file-backed SQLite connection via `tokio_rusqlite::Connection::open`.
/// 2. Enables Write-Ahead Logging (`PRAGMA journal_mode=WAL`) for better
/// concurrent read performance.
/// 3. Enables foreign key enforcement (`PRAGMA foreign_keys=ON`) so that
/// `ON DELETE CASCADE` works on the `quote_tags` table.
///
/// Returns `Err(DbError::Internal(...))` if the file cannot be opened or if
/// the pragma commands fail.
///
/// # Examples
///
/// ```no_run
/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
/// let repo = quotesdb::db::connection::open("quotesdb.sqlite").await?;
/// repo.run_migrations().await?;
/// # Ok(())
/// # }
/// ```
pub async fn open(path: &str) -> Result<NativeRepository, DbError> {
let conn = Connection::open(path)
.await
.map_err(|e| DbError::Internal(format!("failed to open database: {e}")))?;
// Configure SQLite pragmas on the connection thread
conn.call(|c| {
// WAL mode improves concurrent reader throughput
c.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;")?;
Ok(())
})
.await
.map_err(|e| DbError::Internal(format!("pragma configuration failed: {e}")))?;
Ok(NativeRepository::new(conn))
}

@ -1,942 +0,0 @@
//! Cloudflare D1 repository implementation (wasm32 only).
//!
//! [`D1Repository`] wraps a [`worker::d1::Database`] and provides full
//! implementations of all [`super::QuoteRepository`] methods using the
//! Cloudflare D1 API from workers-rs 0.5.
//!
//! This module is only compiled for `wasm32-unknown-unknown` targets.
use super::{DbError, DeleteResult, ListResult, QuoteRepository};
use quotesdb::{generate_auth_code, generate_id, CreateQuoteInput, Quote, UpdateQuoteInput};
use wasm_bindgen::JsValue;
// ── Helper structs ────────────────────────────────────────────────────────────
/// Row shape for quote SELECT queries.
#[derive(Debug, serde::Deserialize)]
struct QuoteRow {
id: String,
text: String,
author: String,
source: Option<String>,
date: Option<String>,
/// Stored as an integer (0 = visible, 1 = hidden); converted to bool on deserialization.
hidden: i64,
created_at: String,
updated_at: String,
}
impl QuoteRow {
/// Convert this row into a [`Quote`] by attaching a pre-fetched tags list.
fn into_quote(self, tags: Vec<String>) -> Quote {
Quote {
id: self.id,
text: self.text,
author: self.author,
source: self.source,
date: self.date,
hidden: self.hidden != 0,
created_at: self.created_at,
updated_at: self.updated_at,
tags,
}
}
}
/// Row shape for auth_code lookups.
#[derive(Debug, serde::Deserialize)]
struct AuthRow {
auth_code: String,
}
/// Row shape for tag lookups.
#[derive(Debug, serde::Deserialize)]
struct TagRow {
tag: String,
}
/// Row shape for COUNT(*) queries.
#[derive(Debug, serde::Deserialize)]
struct CountRow {
count: u32,
}
// ── Repository struct ─────────────────────────────────────────────────────────
/// Cloudflare D1-backed repository (wasm32 only).
///
/// Wraps a [`worker::d1::D1Database`] handle provided by the Workers runtime.
/// All methods use the D1 prepared-statement API to execute SQL queries.
pub struct D1Repository {
/// The Cloudflare D1 database handle.
pub db: worker::d1::D1Database,
}
// SAFETY: wasm32-unknown-unknown is single-threaded; JS values are never sent
// across threads. Required to satisfy Arc<dyn QuoteRepository + Send + Sync>.
unsafe impl Send for D1Repository {}
unsafe impl Sync for D1Repository {}
impl D1Repository {
/// Create a new [`D1Repository`] wrapping the given D1 database handle.
pub fn new(db: worker::d1::D1Database) -> Self {
Self { db }
}
/// Fetch all tags for a quote, sorted alphabetically.
///
/// Returns a sorted `Vec<String>` of tag values, or an empty vec if none exist.
async fn fetch_tags(&self, id: &str) -> Result<Vec<String>, DbError> {
let rows = self
.db
.prepare("SELECT tag FROM quote_tags WHERE quote_id = ?1 ORDER BY tag")
.bind(&[JsValue::from_str(id)])
.map_err(|e| DbError::Internal(e.to_string()))?
.all()
.await
.map_err(|e| DbError::Internal(e.to_string()))?
.results::<TagRow>()
.map_err(|e| DbError::Internal(e.to_string()))?;
Ok(rows.into_iter().map(|r| r.tag).collect())
}
}
// ── QuoteRepository impl ──────────────────────────────────────────────────────
#[async_trait::async_trait(?Send)]
impl QuoteRepository for D1Repository {
/// Run all DDL migration statements from [`super::migrations`].
///
/// Covers `quotes`, `quote_tags`, tag index, author index, `admin_config`,
/// and the `hidden` column ALTER. Safe to call multiple times — CREATE
/// statements use `IF NOT EXISTS`. The ALTER TABLE error (column already
/// exists) is intentionally ignored for idempotency.
async fn run_migrations(&self) -> Result<(), DbError> {
use super::migrations::*;
for sql in &[
CREATE_QUOTES,
CREATE_QUOTE_TAGS,
CREATE_TAG_INDEX,
CREATE_AUTHOR_INDEX,
CREATE_ADMIN_CONFIG,
CREATE_REPORTS,
] {
self.db
.exec(sql)
.await
.map_err(|e| DbError::Internal(e.to_string()))?;
}
// ALTER TABLE does not support IF NOT EXISTS — ignore the error when
// the column already exists (idempotent on re-runs).
let _ = self.db.exec(ALTER_QUOTES_ADD_HIDDEN).await;
Ok(())
}
/// List quotes with optional author/tag/date filters and 1-based pagination.
///
/// Page size is fixed at 10. Uses `COLLATE NOCASE` for author filtering.
/// `date_after` and `date_before` are ISO date prefix strings compared via
/// `>=` / `<=` against the stored `date` column; rows where `date IS NULL`
/// are excluded when either bound is set.
/// Tags for each returned quote are fetched in a second query per quote to
/// avoid duplicate rows from a JOIN.
async fn list_quotes(
&self,
page: u32,
author: Option<&str>,
tag: Option<&str>,
date_after: Option<&str>,
date_before: Option<&str>,
) -> Result<ListResult, DbError> {
const PAGE_SIZE: u32 = 10;
let page = page.max(1);
// ── Build WHERE clause with positional params ──────────────────────
// Always exclude hidden quotes from listing endpoints.
let mut conditions: Vec<String> = vec!["q.hidden = 0".to_owned()];
let mut binds: Vec<JsValue> = Vec::new();
let mut param_idx: u32 = 1;
if let Some(a) = author {
conditions.push(format!("q.author = ?{param_idx} COLLATE NOCASE"));
binds.push(JsValue::from_str(a));
param_idx += 1;
}
if let Some(t) = tag {
conditions.push(format!(
"q.id IN (SELECT quote_id FROM quote_tags WHERE tag = ?{param_idx})"
));
binds.push(JsValue::from_str(t));
param_idx += 1;
}
// Exclude NULL dates when any date bound is active
if date_after.is_some() || date_before.is_some() {
conditions.push("q.date IS NOT NULL".to_owned());
}
if let Some(da) = date_after {
conditions.push(format!("q.date >= ?{param_idx}"));
binds.push(JsValue::from_str(da));
param_idx += 1;
}
if let Some(db) = date_before {
conditions.push(format!("q.date <= ?{param_idx}"));
binds.push(JsValue::from_str(db));
param_idx += 1;
}
let where_clause = format!("WHERE {}", conditions.join(" AND "));
// ── Count total matching rows ──────────────────────────────────────
let count_sql = format!("SELECT COUNT(*) as count FROM quotes q {where_clause}");
let count_row = self
.db
.prepare(&count_sql)
.bind(&binds)
.map_err(|e| DbError::Internal(e.to_string()))?
.first::<CountRow>(None)
.await
.map_err(|e| DbError::Internal(e.to_string()))?
.ok_or_else(|| DbError::Internal("count query returned no row".to_string()))?;
let total_count = count_row.count;
let total_pages = ((total_count + PAGE_SIZE - 1) / PAGE_SIZE).max(1);
let offset = (page - 1) * PAGE_SIZE;
// ── Fetch the page of quotes ───────────────────────────────────────
let list_sql = format!(
"SELECT q.id, q.text, q.author, q.source, q.date, \
q.hidden, q.created_at, q.updated_at \
FROM quotes q {where_clause} \
ORDER BY q.created_at DESC \
LIMIT ?{param_idx} OFFSET ?{}",
param_idx + 1
);
let mut list_binds = binds.clone();
list_binds.push(JsValue::from_f64(PAGE_SIZE as f64));
list_binds.push(JsValue::from_f64(offset as f64));
let rows = self
.db
.prepare(&list_sql)
.bind(&list_binds)
.map_err(|e| DbError::Internal(e.to_string()))?
.all()
.await
.map_err(|e| DbError::Internal(e.to_string()))?
.results::<QuoteRow>()
.map_err(|e| DbError::Internal(e.to_string()))?;
// Second pass: fetch tags for each quote
let mut quotes: Vec<Quote> = Vec::with_capacity(rows.len());
for row in rows {
let tags = self.fetch_tags(&row.id).await?;
quotes.push(row.into_quote(tags));
}
Ok(ListResult {
quotes,
page,
total_pages,
total_count,
})
}
/// Retrieve a single quote by its primary key.
///
/// Returns `Ok(None)` when no row matches `id`.
async fn get_quote(&self, id: &str) -> Result<Option<Quote>, DbError> {
let row = self
.db
.prepare(
"SELECT id, text, author, source, date, hidden, created_at, updated_at \
FROM quotes WHERE id = ?1",
)
.bind(&[JsValue::from_str(id)])
.map_err(|e| DbError::Internal(e.to_string()))?
.first::<QuoteRow>(None)
.await
.map_err(|e| DbError::Internal(e.to_string()))?;
match row {
None => Ok(None),
Some(r) => {
let tags = self.fetch_tags(&r.id).await?;
Ok(Some(r.into_quote(tags)))
}
}
}
/// Return one quote chosen at random.
///
/// Returns `Ok(None)` when the `quotes` table is empty.
async fn get_random_quote(&self) -> Result<Option<Quote>, DbError> {
let row = self
.db
.prepare(
"SELECT id, text, author, source, date, hidden, created_at, updated_at \
FROM quotes WHERE hidden = 0 ORDER BY RANDOM() LIMIT 1",
)
.first::<QuoteRow>(None)
.await
.map_err(|e| DbError::Internal(e.to_string()))?;
match row {
None => Ok(None),
Some(r) => {
let tags = self.fetch_tags(&r.id).await?;
Ok(Some(r.into_quote(tags)))
}
}
}
/// Insert a new quote row and its associated tags.
///
/// If `input.auth_code` is `None`, a 4-word passphrase is generated.
/// Returns the persisted [`Quote`] (without `auth_code`) and the raw
/// auth-code string so the caller can include it in the creation response.
async fn create_quote(&self, input: CreateQuoteInput) -> Result<(Quote, String), DbError> {
let id = generate_id();
let auth_code = input.auth_code.unwrap_or_else(generate_auth_code);
// Insert the quote row
self.db
.prepare(
"INSERT INTO quotes (id, text, author, source, date, auth_code) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
)
.bind(&[
JsValue::from_str(&id),
JsValue::from_str(&input.text),
JsValue::from_str(&input.author),
input
.source
.as_deref()
.map(JsValue::from_str)
.unwrap_or(JsValue::NULL),
input
.date
.as_deref()
.map(JsValue::from_str)
.unwrap_or(JsValue::NULL),
JsValue::from_str(&auth_code),
])
.map_err(|e| DbError::Internal(e.to_string()))?
.run()
.await
.map_err(|e| DbError::Internal(e.to_string()))?;
// Batch insert tags
if !input.tags.is_empty() {
let tag_stmts: Vec<worker::d1::D1PreparedStatement> = input
.tags
.iter()
.map(|tag| {
self.db
.prepare("INSERT OR IGNORE INTO quote_tags (quote_id, tag) VALUES (?1, ?2)")
.bind(&[JsValue::from_str(&id), JsValue::from_str(tag)])
.map_err(|e| DbError::Internal(e.to_string()))
})
.collect::<Result<Vec<_>, _>>()?;
self.db
.batch(tag_stmts)
.await
.map_err(|e| DbError::Internal(e.to_string()))?;
}
// Read back the row to get server-generated timestamps
let row = self
.db
.prepare(
"SELECT id, text, author, source, date, hidden, created_at, updated_at \
FROM quotes WHERE id = ?1",
)
.bind(&[JsValue::from_str(&id)])
.map_err(|e| DbError::Internal(e.to_string()))?
.first::<QuoteRow>(None)
.await
.map_err(|e| DbError::Internal(e.to_string()))?
.ok_or_else(|| DbError::Internal("inserted row not found on read-back".to_string()))?;
let tags = self.fetch_tags(&id).await?;
Ok((row.into_quote(tags), auth_code))
}
/// Update non-`None` fields on an existing quote.
///
/// Verifies `auth_code` before making any changes. If `input.tags` is
/// `Some`, the entire tag set is replaced. Updates `updated_at` to the
/// current UTC time.
async fn update_quote(
&self,
id: &str,
input: UpdateQuoteInput,
auth_code: &str,
) -> Result<Quote, DbError> {
// Phase 1: fetch stored auth_code
let auth_row = self
.db
.prepare("SELECT auth_code FROM quotes WHERE id = ?1")
.bind(&[JsValue::from_str(id)])
.map_err(|e| DbError::Internal(e.to_string()))?
.first::<AuthRow>(None)
.await
.map_err(|e| DbError::Internal(e.to_string()))?;
match auth_row {
None => return Err(DbError::NotFound),
Some(ref r) if r.auth_code == auth_code => {} // exact match, proceed
Some(_) => {
// Check admin code fallback
let admin = self.get_admin_auth_code().await?;
if admin.as_deref() != Some(auth_code) {
return Err(DbError::Forbidden);
}
}
}
// Phase 2: build dynamic SET clause with positional params
let mut sets: Vec<String> = Vec::new();
let mut binds: Vec<JsValue> = Vec::new();
let mut param_idx: u32 = 1;
if let Some(ref text) = input.text {
sets.push(format!("text = ?{param_idx}"));
binds.push(JsValue::from_str(text));
param_idx += 1;
}
if let Some(ref author) = input.author {
sets.push(format!("author = ?{param_idx}"));
binds.push(JsValue::from_str(author));
param_idx += 1;
}
// source and date always updated (None clears the field)
sets.push(format!("source = ?{param_idx}"));
binds.push(
input
.source
.as_deref()
.map(JsValue::from_str)
.unwrap_or(JsValue::NULL),
);
param_idx += 1;
sets.push(format!("date = ?{param_idx}"));
binds.push(
input
.date
.as_deref()
.map(JsValue::from_str)
.unwrap_or(JsValue::NULL),
);
param_idx += 1;
// hidden is only updated when explicitly provided
if let Some(h) = input.hidden {
sets.push(format!("hidden = ?{param_idx}"));
binds.push(JsValue::from_f64(if h { 1.0 } else { 0.0 }));
param_idx += 1;
}
sets.push("updated_at = datetime('now')".to_string());
let update_sql = format!(
"UPDATE quotes SET {} WHERE id = ?{param_idx}",
sets.join(", ")
);
binds.push(JsValue::from_str(id));
self.db
.prepare(&update_sql)
.bind(&binds)
.map_err(|e| DbError::Internal(e.to_string()))?
.run()
.await
.map_err(|e| DbError::Internal(e.to_string()))?;
// Phase 3: replace tags if provided
if let Some(ref tags) = input.tags {
self.db
.prepare("DELETE FROM quote_tags WHERE quote_id = ?1")
.bind(&[JsValue::from_str(id)])
.map_err(|e| DbError::Internal(e.to_string()))?
.run()
.await
.map_err(|e| DbError::Internal(e.to_string()))?;
if !tags.is_empty() {
let tag_stmts: Vec<worker::d1::D1PreparedStatement> = tags
.iter()
.map(|tag| {
self.db
.prepare(
"INSERT OR IGNORE INTO quote_tags (quote_id, tag) \
VALUES (?1, ?2)",
)
.bind(&[JsValue::from_str(id), JsValue::from_str(tag)])
.map_err(|e| DbError::Internal(e.to_string()))
})
.collect::<Result<Vec<_>, _>>()?;
self.db
.batch(tag_stmts)
.await
.map_err(|e| DbError::Internal(e.to_string()))?;
}
}
// Phase 4: read back the updated quote
let row = self
.db
.prepare(
"SELECT id, text, author, source, date, hidden, created_at, updated_at \
FROM quotes WHERE id = ?1",
)
.bind(&[JsValue::from_str(id)])
.map_err(|e| DbError::Internal(e.to_string()))?
.first::<QuoteRow>(None)
.await
.map_err(|e| DbError::Internal(e.to_string()))?
.ok_or(DbError::NotFound)?;
let fetched_tags = self.fetch_tags(&row.id).await?;
Ok(row.into_quote(fetched_tags))
}
/// Delete a quote by ID after verifying the auth code.
///
/// Returns [`DeleteResult::NotFound`] if no quote has that ID,
/// [`DeleteResult::Forbidden`] if neither the per-quote auth code nor the
/// admin super auth code matches, or [`DeleteResult::Deleted`] on success.
/// Tags are removed automatically by the `ON DELETE CASCADE` constraint on
/// `quote_tags`.
async fn delete_quote(&self, id: &str, auth_code: &str) -> Result<DeleteResult, DbError> {
// Fetch stored auth_code
let auth_row = self
.db
.prepare("SELECT auth_code FROM quotes WHERE id = ?1")
.bind(&[JsValue::from_str(id)])
.map_err(|e| DbError::Internal(e.to_string()))?
.first::<AuthRow>(None)
.await
.map_err(|e| DbError::Internal(e.to_string()))?;
match auth_row {
None => return Ok(DeleteResult::NotFound),
Some(ref r) if r.auth_code == auth_code => {
// Per-quote auth matches — fall through to delete
}
Some(_) => {
// Check admin code as fallback
let admin = self.get_admin_auth_code().await?;
if admin.as_deref() != Some(auth_code) {
return Ok(DeleteResult::Forbidden);
}
}
}
self.db
.prepare("DELETE FROM quotes WHERE id = ?1")
.bind(&[JsValue::from_str(id)])
.map_err(|e| DbError::Internal(e.to_string()))?
.run()
.await
.map_err(|e| DbError::Internal(e.to_string()))?;
Ok(DeleteResult::Deleted)
}
/// Retrieve the admin super auth code from `admin_config`.
///
/// Returns `Ok(None)` if the admin code has not been seeded yet.
async fn get_admin_auth_code(&self) -> Result<Option<String>, DbError> {
#[derive(serde::Deserialize)]
struct ValueRow {
value: String,
}
self.db
.prepare("SELECT value FROM admin_config WHERE key = 'admin_auth_code'")
.first::<ValueRow>(None)
.await
.map(|opt| opt.map(|r| r.value))
.map_err(|e| DbError::Internal(e.to_string()))
}
/// Insert the admin auth code if not already present.
///
/// Uses `INSERT OR IGNORE` so calling it multiple times is safe.
async fn seed_admin_auth_code(&self, code: &str) -> Result<(), DbError> {
self.db
.prepare(
"INSERT OR IGNORE INTO admin_config (key, value) VALUES ('admin_auth_code', ?1)",
)
.bind(&[JsValue::from_str(code)])
.map_err(|e| DbError::Internal(e.to_string()))?
.run()
.await
.map(|_| ())
.map_err(|e| DbError::Internal(e.to_string()))
}
/// Replace the admin auth code if `current` matches the stored value.
///
/// Generates a fresh 4-word passphrase when `new_code` is `None`.
///
/// The check and update are performed atomically via a single
/// `UPDATE … WHERE key = 'admin_auth_code' AND value = ?current` statement.
/// The result metadata's `changes` count is inspected: if zero rows were
/// affected the stored code either does not exist or did not match `current`,
/// and `Err(DbError::Forbidden)` is returned. If one row was affected the new
/// code is returned.
async fn update_admin_auth_code(
&self,
current: &str,
new_code: Option<&str>,
) -> Result<String, DbError> {
let replacement = new_code
.map(|s| s.to_owned())
.unwrap_or_else(generate_auth_code);
let result = self
.db
.prepare(
"UPDATE admin_config \
SET value = ?1 \
WHERE key = 'admin_auth_code' AND value = ?2",
)
.bind(&[JsValue::from_str(&replacement), JsValue::from_str(current)])
.map_err(|e| DbError::Internal(e.to_string()))?
.run()
.await
.map_err(|e| DbError::Internal(e.to_string()))?;
let changes = result
.meta()
.map_err(|e| DbError::Internal(e.to_string()))?
.and_then(|m| m.changes)
.unwrap_or(0);
if changes == 0 {
return Err(DbError::Forbidden);
}
Ok(replacement)
}
/// Return whether submissions are currently locked.
///
/// Reads the `submissions_locked` key from `admin_config`.
/// Returns `false` if the key has not been seeded yet.
async fn get_submissions_locked(&self) -> Result<bool, DbError> {
#[derive(serde::Deserialize)]
struct ValueRow {
value: String,
}
let row = self
.db
.prepare("SELECT value FROM admin_config WHERE key = 'submissions_locked'")
.first::<ValueRow>(None)
.await
.map_err(|e| DbError::Internal(e.to_string()))?;
Ok(row.map(|r| r.value == "1").unwrap_or(false))
}
/// Persist the submissions lock state.
///
/// Upserts `"1"` (locked) or `"0"` (unlocked) into the `submissions_locked`
/// key in `admin_config`.
async fn set_submissions_locked(&self, locked: bool) -> Result<(), DbError> {
let value = if locked { "1" } else { "0" };
self.db
.prepare(
"INSERT INTO admin_config (key, value) VALUES ('submissions_locked', ?1) \
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
)
.bind(&[JsValue::from_str(value)])
.map_err(|e| DbError::Internal(e.to_string()))?
.run()
.await
.map(|_| ())
.map_err(|e| DbError::Internal(e.to_string()))
}
/// Seed the `submissions_locked` key as `"0"` if not already present.
///
/// Uses `INSERT OR IGNORE` so calling it multiple times is safe.
async fn seed_submissions_locked(&self) -> Result<(), DbError> {
self.db
.prepare(
"INSERT OR IGNORE INTO admin_config (key, value) \
VALUES ('submissions_locked', '0')",
)
.run()
.await
.map(|_| ())
.map_err(|e| DbError::Internal(e.to_string()))
}
/// Create a moderation report for an existing quote.
///
/// Checks that the quote exists via a COUNT query, then inserts a new row
/// into the `reports` table. Returns `Err(DbError::NotFound)` if no quote
/// with the given `quote_id` exists.
async fn create_report(&self, quote_id: &str, reason: Option<&str>) -> Result<(), DbError> {
// Step 1: verify the quote exists.
let exists_row = self
.db
.prepare("SELECT COUNT(*) AS count FROM quotes WHERE id = ?1")
.bind(&[JsValue::from_str(quote_id)])
.map_err(|e| DbError::Internal(e.to_string()))?
.first::<CountRow>(None)
.await
.map_err(|e| DbError::Internal(e.to_string()))?;
let exists = exists_row.map(|r| r.count > 0).unwrap_or(false);
if !exists {
return Err(DbError::NotFound);
}
// Step 2: insert the report row.
let id = generate_id();
let reason_value = match reason {
Some(r) => JsValue::from_str(r),
None => JsValue::NULL,
};
self.db
.prepare("INSERT INTO reports (id, quote_id, reason) VALUES (?1, ?2, ?3)")
.bind(&[
JsValue::from_str(&id),
JsValue::from_str(quote_id),
reason_value,
])
.map_err(|e| DbError::Internal(e.to_string()))?
.run()
.await
.map(|_| ())
.map_err(|e| DbError::Internal(e.to_string()))
}
/// List all quotes that have at least one report, paginated (10 per page).
///
/// Returns a [`super::ReportListResult`] ordered by most recent report
/// descending. Page numbers are 1-based.
async fn list_reports(&self, page: u32) -> Result<super::ReportListResult, DbError> {
#[derive(serde::Deserialize)]
struct TotalRow {
total: u32,
}
#[derive(serde::Deserialize)]
struct SummaryRow {
quote_id: String,
text: String,
author: String,
report_count: u32,
latest_report_at: String,
}
let page = page.max(1);
let offset = (page - 1) * 10;
// Count distinct quoted with at least one report.
let total_row = self
.db
.prepare("SELECT COUNT(DISTINCT quote_id) AS total FROM reports")
.first::<TotalRow>(None)
.await
.map_err(|e| DbError::Internal(e.to_string()))?;
let total_count = total_row.map(|r| r.total).unwrap_or(0);
let total_pages = total_count.div_ceil(10);
// Aggregate: one row per quote, sorted by most recent report.
let raw_rows = self
.db
.prepare(
"SELECT q.id AS quote_id, q.text, q.author, COUNT(r.id) AS report_count, \
MAX(r.created_at) AS latest_report_at \
FROM reports r \
JOIN quotes q ON q.id = r.quote_id \
GROUP BY r.quote_id \
ORDER BY latest_report_at DESC \
LIMIT 10 OFFSET ?1",
)
.bind(&[JsValue::from_f64(offset as f64)])
.map_err(|e| DbError::Internal(e.to_string()))?
.all()
.await
.map_err(|e| DbError::Internal(e.to_string()))?
.results::<SummaryRow>()
.map_err(|e| DbError::Internal(e.to_string()))?;
let summaries = raw_rows
.into_iter()
.map(|r| {
let truncated = if r.text.chars().count() > 80 {
r.text.chars().take(80).collect()
} else {
r.text
};
super::ReportSummary {
quote_id: r.quote_id,
text: truncated,
author: r.author,
report_count: r.report_count,
latest_report_at: r.latest_report_at,
}
})
.collect();
Ok(super::ReportListResult {
reports: summaries,
page,
total_pages,
total_count,
})
}
/// Return the full quote plus all individual report rows for a quote.
///
/// Reports are ordered oldest first by `created_at`.
/// Returns `Err(DbError::NotFound)` if no quote with `quote_id` exists.
async fn get_reports_for_quote(&self, quote_id: &str) -> Result<super::QuoteReports, DbError> {
#[derive(serde::Deserialize)]
struct ReportRowRaw {
id: String,
reason: Option<String>,
created_at: String,
}
// Fetch the quote.
let maybe_row = self
.db
.prepare(
"SELECT id, text, author, source, date, hidden, created_at, updated_at \
FROM quotes WHERE id = ?1",
)
.bind(&[JsValue::from_str(quote_id)])
.map_err(|e| DbError::Internal(e.to_string()))?
.first::<QuoteRow>(None)
.await
.map_err(|e| DbError::Internal(e.to_string()))?;
let row = maybe_row.ok_or(DbError::NotFound)?;
let tags = self.fetch_tags(&row.id).await?;
let quote = row.into_quote(tags);
// Fetch all reports.
let report_rows = self
.db
.prepare(
"SELECT id, reason, created_at FROM reports \
WHERE quote_id = ?1 ORDER BY created_at ASC",
)
.bind(&[JsValue::from_str(quote_id)])
.map_err(|e| DbError::Internal(e.to_string()))?
.all()
.await
.map_err(|e| DbError::Internal(e.to_string()))?
.results::<ReportRowRaw>()
.map_err(|e| DbError::Internal(e.to_string()))?;
let reports = report_rows
.into_iter()
.map(|r| super::ReportRow {
id: r.id,
reason: r.reason,
created_at: r.created_at,
})
.collect();
Ok(super::QuoteReports { quote, reports })
}
/// Delete a quote unconditionally (admin action).
///
/// Returns `Err(DbError::NotFound)` if the quote does not exist.
/// Tags and reports are removed via `ON DELETE CASCADE`.
async fn admin_delete_quote(&self, quote_id: &str) -> Result<(), DbError> {
// Verify the quote exists first.
let exists_row = self
.db
.prepare("SELECT COUNT(*) AS count FROM quotes WHERE id = ?1")
.bind(&[JsValue::from_str(quote_id)])
.map_err(|e| DbError::Internal(e.to_string()))?
.first::<CountRow>(None)
.await
.map_err(|e| DbError::Internal(e.to_string()))?;
let exists = exists_row.map(|r| r.count > 0).unwrap_or(false);
if !exists {
return Err(DbError::NotFound);
}
self.db
.prepare("DELETE FROM quotes WHERE id = ?1")
.bind(&[JsValue::from_str(quote_id)])
.map_err(|e| DbError::Internal(e.to_string()))?
.run()
.await
.map(|_| ())
.map_err(|e| DbError::Internal(e.to_string()))
}
/// Set `hidden = 1` on a quote (admin action).
///
/// Returns `Err(DbError::NotFound)` if the quote does not exist.
async fn hide_quote(&self, quote_id: &str) -> Result<(), DbError> {
// Verify the quote exists first.
let exists_row = self
.db
.prepare("SELECT COUNT(*) AS count FROM quotes WHERE id = ?1")
.bind(&[JsValue::from_str(quote_id)])
.map_err(|e| DbError::Internal(e.to_string()))?
.first::<CountRow>(None)
.await
.map_err(|e| DbError::Internal(e.to_string()))?;
let exists = exists_row.map(|r| r.count > 0).unwrap_or(false);
if !exists {
return Err(DbError::NotFound);
}
self.db
.prepare("UPDATE quotes SET hidden = 1 WHERE id = ?1")
.bind(&[JsValue::from_str(quote_id)])
.map_err(|e| DbError::Internal(e.to_string()))?
.run()
.await
.map(|_| ())
.map_err(|e| DbError::Internal(e.to_string()))
}
/// Delete all reports for a quote without deleting the quote itself.
///
/// Returns `Err(DbError::NotFound)` if the quote does not exist.
async fn clear_reports(&self, quote_id: &str) -> Result<(), DbError> {
// Verify the quote exists first.
let exists_row = self
.db
.prepare("SELECT COUNT(*) AS count FROM quotes WHERE id = ?1")
.bind(&[JsValue::from_str(quote_id)])
.map_err(|e| DbError::Internal(e.to_string()))?
.first::<CountRow>(None)
.await
.map_err(|e| DbError::Internal(e.to_string()))?;
let exists = exists_row.map(|r| r.count > 0).unwrap_or(false);
if !exists {
return Err(DbError::NotFound);
}
self.db
.prepare("DELETE FROM reports WHERE quote_id = ?1")
.bind(&[JsValue::from_str(quote_id)])
.map_err(|e| DbError::Internal(e.to_string()))?
.run()
.await
.map(|_| ())
.map_err(|e| DbError::Internal(e.to_string()))
}
}

@ -1,73 +0,0 @@
//! SQL migration strings for the `quotesdb` schema.
//!
//! These strings are run once on startup via [`super::QuoteRepository::run_migrations`].
//! Both the `D1Repository` (WASM) and `NativeRepository` (native) execute these
//! in sequence.
/// Creates the `quotes` table if it does not already exist.
///
/// Stores one row per quote with all core fields. The `auth_code` is stored
/// plaintext for simple passphrase-based ownership verification.
pub const CREATE_QUOTES: &str = "\
CREATE TABLE IF NOT EXISTS quotes (
id TEXT PRIMARY KEY,
text TEXT NOT NULL,
author TEXT NOT NULL,
source TEXT,
date TEXT,
auth_code TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)";
/// Creates the `quote_tags` join table if it does not already exist.
///
/// Uses `ON DELETE CASCADE` so tags are removed automatically when a quote
/// is deleted. The composite primary key prevents duplicate tags per quote.
pub const CREATE_QUOTE_TAGS: &str = "\
CREATE TABLE IF NOT EXISTS quote_tags (
quote_id TEXT NOT NULL REFERENCES quotes(id) ON DELETE CASCADE,
tag TEXT NOT NULL,
PRIMARY KEY (quote_id, tag)
)";
/// Creates an index on `quote_tags.quote_id` to speed up tag lookups.
pub const CREATE_TAG_INDEX: &str = "\
CREATE INDEX IF NOT EXISTS idx_quote_tags_quote_id ON quote_tags(quote_id)";
/// Creates a case-insensitive index on `quotes.author` for filter queries.
pub const CREATE_AUTHOR_INDEX: &str = "\
CREATE INDEX IF NOT EXISTS idx_quotes_author ON quotes(author COLLATE NOCASE)";
/// Creates the `admin_config` key/value table for global configuration.
///
/// Stores a single row for the admin auth code under key `admin_auth_code`.
pub const CREATE_ADMIN_CONFIG: &str = "\
CREATE TABLE IF NOT EXISTS admin_config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
)";
/// Adds the `hidden` column to the `quotes` table.
///
/// This is a schema migration for existing databases. The column defaults to
/// `0` (not hidden) so all pre-existing quotes remain publicly visible.
///
/// SQLite does not support `ADD COLUMN IF NOT EXISTS`, so callers must
/// ignore the error when the column already exists (e.g., on repeated startup).
pub const ALTER_QUOTES_ADD_HIDDEN: &str = "\
ALTER TABLE quotes ADD COLUMN hidden INTEGER NOT NULL DEFAULT 0";
/// Creates the `reports` table if it does not already exist.
///
/// Each row represents one user-submitted report against a quote.
/// `quote_id` references `quotes(id)` with `ON DELETE CASCADE` so reports
/// are removed automatically when the associated quote is deleted.
/// `reason` is optional and capped at 256 characters by application logic.
pub const CREATE_REPORTS: &str = "\
CREATE TABLE IF NOT EXISTS reports (
id TEXT PRIMARY KEY,
quote_id TEXT NOT NULL REFERENCES quotes(id) ON DELETE CASCADE,
reason TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)";

@ -1,275 +0,0 @@
//! Database abstraction layer for the `quotesdb` API.
//!
//! Provides the [`QuoteRepository`] async trait as a uniform interface over
//! two backend implementations:
//!
//! - [`NativeRepository`] — `rusqlite` + `tokio-rusqlite` for native/test targets.
//! - `D1Repository` — Cloudflare D1 via workers-rs for WASM/production targets.
//!
//! The correct implementation is selected at compile time via `cfg(target_arch)`.
//! No feature flags or runtime branching are needed.
pub mod migrations;
#[cfg(not(target_arch = "wasm32"))]
mod native;
#[cfg(target_arch = "wasm32")]
pub mod d1;
#[cfg(not(target_arch = "wasm32"))]
pub mod connection;
#[cfg(not(target_arch = "wasm32"))]
pub use native::NativeRepository;
use serde::{Deserialize, Serialize};
// ── Shared result types ───────────────────────────────────────────────────────
/// A paginated list of quotes.
///
/// Returned by [`QuoteRepository::list_quotes`].
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ListResult {
/// The quotes on this page.
pub quotes: Vec<quotesdb::Quote>,
/// Current page number (1-based).
pub page: u32,
/// Total number of pages.
pub total_pages: u32,
/// Total number of quotes matching the filter.
pub total_count: u32,
}
/// A single report row returned by admin report queries.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReportRow {
/// Unique report ID (NanoID).
pub id: String,
/// Optional human-readable reason supplied by the reporter.
pub reason: Option<String>,
/// ISO timestamp when the report was created.
pub created_at: String,
}
/// Summary of a reported quote, returned in the paginated reports list.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReportSummary {
/// The ID of the reported quote.
pub quote_id: String,
/// Abbreviated quote text (first 80 chars).
pub text: String,
/// Author of the reported quote.
pub author: String,
/// Total number of reports against this quote.
pub report_count: u32,
/// ISO timestamp of the most recent report.
pub latest_report_at: String,
}
/// A paginated list of reported quotes.
///
/// Returned by [`QuoteRepository::list_reports`].
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReportListResult {
/// Summaries of reported quotes on this page.
pub reports: Vec<ReportSummary>,
/// Current page number (1-based).
pub page: u32,
/// Total number of pages.
pub total_pages: u32,
/// Total number of quotes with at least one report.
pub total_count: u32,
}
/// Full details for a reported quote: the quote itself plus all report rows.
///
/// Returned by [`QuoteRepository::get_reports_for_quote`].
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QuoteReports {
/// The full quote object.
pub quote: quotesdb::Quote,
/// All reports submitted against this quote, ordered oldest first.
pub reports: Vec<ReportRow>,
}
/// Outcome of a delete operation.
#[derive(Debug, PartialEq)]
pub enum DeleteResult {
/// The quote was deleted successfully.
Deleted,
/// No quote with the given ID exists.
NotFound,
/// The auth code did not match — deletion refused.
Forbidden,
}
/// Errors returned by [`QuoteRepository`] methods.
#[derive(Debug, thiserror::Error)]
pub enum DbError {
/// An internal database error occurred.
#[error("database error: {0}")]
Internal(String),
/// The requested resource does not exist.
#[error("not found")]
NotFound,
/// The operation is forbidden (wrong auth code).
#[error("forbidden")]
Forbidden,
}
// ── Trait ─────────────────────────────────────────────────────────────────────
/// Async repository interface for all quote CRUD operations.
///
/// On native targets the trait uses `async_trait` (Send-capable futures),
/// which lets Axum share the repository across Tokio tasks.
/// On wasm32 the trait uses `async_trait(?Send)` because D1 database methods
/// internally use `JsFuture` (which is `!Send`). Handler futures are wrapped
/// with `#[worker::send]` at the call site to satisfy Axum's `Handler` bounds.
///
/// Implementations must be backed by a persistent store (SQLite for native,
/// Cloudflare D1 for WASM). The caller holds the repository behind an `Arc`
/// so it can be shared across Axum handler calls.
#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
pub trait QuoteRepository {
/// Run `CREATE TABLE IF NOT EXISTS` migrations.
///
/// Covers the `quotes`, `quote_tags`, index, and `admin_config` tables.
/// Must be called once on startup before any other operations.
async fn run_migrations(&self) -> Result<(), DbError>;
/// List quotes with optional filtering and pagination.
///
/// Page numbers are 1-based. Returns an empty `quotes` vec when `page`
/// is beyond the last page. `date_after` and `date_before` are ISO date
/// prefix strings (e.g. `"2020"`, `"2020-06"`, `"2020-06-15"`); the DB
/// layer uses `>=` / `<=` comparisons against the stored `date` column.
async fn list_quotes(
&self,
page: u32,
author: Option<&str>,
tag: Option<&str>,
date_after: Option<&str>,
date_before: Option<&str>,
) -> Result<ListResult, DbError>;
/// Retrieve a single quote by its ID.
///
/// Returns `Ok(None)` when no quote with the given ID exists.
async fn get_quote(&self, id: &str) -> Result<Option<quotesdb::Quote>, DbError>;
/// Return a single random quote.
///
/// Returns `Ok(None)` when the database is empty.
async fn get_random_quote(&self) -> Result<Option<quotesdb::Quote>, DbError>;
/// Create a new quote.
///
/// If `input.auth_code` is `None`, a 4-word passphrase is auto-generated.
/// Returns the stored quote (without auth_code) and the auth_code string.
async fn create_quote(
&self,
input: quotesdb::CreateQuoteInput,
) -> Result<(quotesdb::Quote, String), DbError>;
/// Update an existing quote.
///
/// The `auth_code` header value must match `quotes.auth_code`.
/// Returns `Err(DbError::NotFound)` if the ID does not exist.
/// Returns `Err(DbError::Forbidden)` if the auth code does not match.
async fn update_quote(
&self,
id: &str,
input: quotesdb::UpdateQuoteInput,
auth_code: &str,
) -> Result<quotesdb::Quote, DbError>;
/// Delete a quote by ID.
///
/// The `auth_code` header value must match `quotes.auth_code` or the
/// admin super auth code stored in `admin_config`.
/// Tags are removed automatically via `ON DELETE CASCADE`.
async fn delete_quote(&self, id: &str, auth_code: &str) -> Result<DeleteResult, DbError>;
/// Retrieve the admin super auth code from `admin_config`.
///
/// Returns `Ok(None)` if the admin code has not been seeded yet.
async fn get_admin_auth_code(&self) -> Result<Option<String>, DbError>;
/// Store the admin auth code in `admin_config` if not already present.
///
/// Uses `INSERT OR IGNORE` so calling it multiple times is safe.
async fn seed_admin_auth_code(&self, code: &str) -> Result<(), DbError>;
/// Replace the admin auth code if `current` matches the stored value.
///
/// If `new_code` is `None`, a fresh 4-word passphrase is auto-generated.
/// Returns the new auth code on success.
/// Returns `Err(DbError::Forbidden)` if `current` does not match.
async fn update_admin_auth_code(
&self,
current: &str,
new_code: Option<&str>,
) -> Result<String, DbError>;
/// Return whether submissions are currently locked.
///
/// Reads the `submissions_locked` key from `admin_config`.
/// Returns `false` if the key has not been seeded yet (defaults to open).
async fn get_submissions_locked(&self) -> Result<bool, DbError>;
/// Persist the submissions lock state.
///
/// Writes `"1"` (locked) or `"0"` (unlocked) to the `submissions_locked`
/// key in `admin_config`, upserting if necessary.
async fn set_submissions_locked(&self, locked: bool) -> Result<(), DbError>;
/// Seed the `submissions_locked` key as `"0"` if not already present.
///
/// Uses `INSERT OR IGNORE` so calling it multiple times is safe.
/// Call once on startup to ensure the key exists.
async fn seed_submissions_locked(&self) -> Result<(), DbError>;
/// Create a moderation report for an existing quote.
///
/// `reason` is optional and should be at most 256 chars (enforced at the
/// handler layer before this method is called).
///
/// Returns `Err(DbError::NotFound)` if `quote_id` does not exist in the
/// `quotes` table.
async fn create_report(&self, quote_id: &str, reason: Option<&str>) -> Result<(), DbError>;
/// List all quotes that have at least one report, paginated (10 per page).
///
/// Each entry includes: quote ID, truncated text, author, report count,
/// and the timestamp of the most recent report. Page numbers are 1-based.
async fn list_reports(&self, page: u32) -> Result<ReportListResult, DbError>;
/// Return the full quote plus all individual report rows for a quote.
///
/// Reports are ordered oldest first by `created_at`.
/// Returns `Err(DbError::NotFound)` if the quote does not exist.
async fn get_reports_for_quote(&self, quote_id: &str) -> Result<QuoteReports, DbError>;
/// Delete a quote unconditionally (admin action).
///
/// Does not verify per-quote auth code — caller must authenticate as admin
/// before invoking this method. Tags and reports are removed automatically
/// by the `ON DELETE CASCADE` constraints.
/// Returns `Err(DbError::NotFound)` if the quote does not exist.
async fn admin_delete_quote(&self, quote_id: &str) -> Result<(), DbError>;
/// Set `hidden = 1` on a quote (admin action).
///
/// Does not verify per-quote auth code — caller must authenticate as admin.
/// Returns `Err(DbError::NotFound)` if the quote does not exist.
async fn hide_quote(&self, quote_id: &str) -> Result<(), DbError>;
/// Delete all reports for a quote without deleting the quote itself.
///
/// Returns `Err(DbError::NotFound)` if the quote does not exist.
async fn clear_reports(&self, quote_id: &str) -> Result<(), DbError>;
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

@ -1,15 +1,17 @@
//! API server binary entrypoint.
//!
//! On native targets: starts a Tokio/Axum HTTP server on `0.0.0.0:3000`.
//! On wasm32 targets: exports a `fetch` event handler for Cloudflare Workers,
//! wiring Cloudflare D1 through [`db::d1::D1Repository`] into the Axum router.
//! On wasm32 targets: the Workers entry point is exported from the lib crate
//! (`src/lib.rs`) via `#[worker::event(fetch)]`.
// On wasm32 there is no `main` entry point; the Workers runtime invokes the
// exported `fetch` function produced by `#[worker::event(fetch)]` instead.
#![cfg_attr(target_arch = "wasm32", no_main)]
mod db;
mod handlers;
#[cfg(not(target_arch = "wasm32"))]
use quotesdb::db;
#[cfg(not(target_arch = "wasm32"))]
use quotesdb::handlers;
#[cfg(not(target_arch = "wasm32"))]
use std::sync::Arc;
@ -75,70 +77,5 @@ async fn main() {
axum::serve(listener, app).await.expect("server error");
}
/// Cloudflare Workers `fetch` event handler.
///
/// With the `http` feature enabled on `worker`, the macro converts the
/// incoming JS request into an `http::Request<worker::Body>` before passing
/// it here. The Axum router is called directly and its response is returned
/// as `http::Response<axum::body::Body>`, which the macro converts back to a
/// JS response before returning it to the runtime.
///
/// D1 bindings are retrieved from the Worker environment and wrapped in an
/// `Arc` for sharing across Axum state.
#[cfg(target_arch = "wasm32")]
#[worker::event(fetch)]
pub async fn fetch(
req: worker::HttpRequest,
env: worker::Env,
_ctx: worker::Context,
) -> worker::Result<http::Response<axum::body::Body>> {
use std::sync::Arc;
use tower_service::Service;
// Retrieve the D1 database binding named "DB" from the Worker environment.
let db = env.d1("DB")?;
// Create the D1-backed repository and run schema migrations on startup.
let repo = db::d1::D1Repository::new(db);
// Bring QuoteRepository trait into scope for `run_migrations`.
use db::QuoteRepository as _;
repo.run_migrations()
.await
.map_err(|e| worker::Error::RustError(e.to_string()))?;
// Seed admin auth code on first startup (no-op if already present).
if repo
.get_admin_auth_code()
.await
.map_err(|e| worker::Error::RustError(e.to_string()))?
.is_none()
{
let code = quotesdb::generate_auth_code();
repo.seed_admin_auth_code(&code)
.await
.map_err(|e| worker::Error::RustError(e.to_string()))?;
}
// Seed submissions_locked = '0' on first startup (INSERT OR IGNORE — no-op
// if the key already exists, so this never overwrites an active lock).
repo.seed_submissions_locked()
.await
.map_err(|e| worker::Error::RustError(e.to_string()))?;
// Wrap in Arc so it can be shared across handlers via Axum state.
// D1Repository is `unsafe impl Send + Sync` — safe on single-threaded wasm32.
let repo: Arc<dyn db::QuoteRepository + Send + Sync> = Arc::new(repo);
// Build the Axum router with all routes registered.
let mut router = handlers::router(repo);
// Call the Axum router directly; the http feature ensures request/response
// types are compatible with standard http crate types that Axum expects.
Ok(router
.call(req)
.await
.map_err(|e| worker::Error::RustError(e.to_string()))?)
}
#[cfg(test)]
mod tests {}

@ -1,4 +1,4 @@
//! Shared types and utilities for the `quotesdb` crate.
//! Shared types, utilities, and API modules for the `quotesdb` crate.
//!
//! This module is used by both the `api` and `ui` binaries.
//! Code placed here must compile for both the host target (api)
@ -7,6 +7,10 @@
//! Use `#[cfg(not(target_arch = "wasm32"))]` for host-only items
//! and `#[cfg(target_arch = "wasm32")]` for wasm-only items.
pub mod db;
#[cfg(any(not(target_arch = "wasm32"), feature = "workers-api"))]
pub mod handlers;
use serde::{Deserialize, Serialize};
// ── EFF Short Word List 1 (1296 words) ───────────────────────────────────────
@ -313,3 +317,70 @@ mod tests {
}
}
}
// ── Cloudflare Workers entry point ────────────────────────────────────────────
/// Cloudflare Workers `fetch` event handler.
///
/// With the `http` feature enabled on `worker`, the macro converts the
/// incoming JS request into an `http::Request<worker::Body>` before passing
/// it here. The Axum router is called directly and its response is returned
/// as `http::Response<axum::body::Body>`, which the macro converts back to a
/// JS response before returning it to the runtime.
///
/// D1 bindings are retrieved from the Worker environment and wrapped in an
/// `Arc` for sharing across Axum state.
#[cfg(all(target_arch = "wasm32", feature = "workers-api"))]
#[worker::event(fetch)]
pub async fn fetch(
req: worker::HttpRequest,
env: worker::Env,
_ctx: worker::Context,
) -> worker::Result<http::Response<axum::body::Body>> {
use std::sync::Arc;
use tower_service::Service;
// Retrieve the D1 database binding named "DB" from the Worker environment.
let db = env.d1("DB")?;
// Create the D1-backed repository and run schema migrations on startup.
let repo = crate::db::d1::D1Repository::new(db);
// Bring QuoteRepository trait into scope for `run_migrations`.
use crate::db::QuoteRepository as _;
repo.run_migrations()
.await
.map_err(|e| worker::Error::RustError(e.to_string()))?;
// Seed admin auth code on first startup (no-op if already present).
if repo
.get_admin_auth_code()
.await
.map_err(|e| worker::Error::RustError(e.to_string()))?
.is_none()
{
let code = crate::generate_auth_code();
repo.seed_admin_auth_code(&code)
.await
.map_err(|e| worker::Error::RustError(e.to_string()))?;
}
// Seed submissions_locked = '0' on first startup (INSERT OR IGNORE — no-op
// if the key already exists, so this never overwrites an active lock).
repo.seed_submissions_locked()
.await
.map_err(|e| worker::Error::RustError(e.to_string()))?;
// Wrap in Arc so it can be shared across handlers via Axum state.
// D1Repository is `unsafe impl Send + Sync` — safe on single-threaded wasm32.
let repo: Arc<dyn crate::db::QuoteRepository + Send + Sync> = Arc::new(repo);
// Build the Axum router with all routes registered.
let mut router = crate::handlers::router(repo);
// Call the Axum router directly; the http feature ensures request/response
// types are compatible with standard http crate types that Axum expects.
Ok(router
.call(req)
.await
.map_err(|e| worker::Error::RustError(e.to_string()))?)
}

Loading…
Cancel
Save