You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
948 lines
34 KiB
Rust
948 lines
34 KiB
Rust
//! 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 crate::{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.
|
|
///
|
|
/// Uses `prepare(sql).run()` instead of `exec(sql)` because D1's `exec()`
|
|
/// treats newlines as statement separators, which truncates multiline DDL
|
|
/// statements. `prepare().run()` treats the entire string as one statement.
|
|
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
|
|
.prepare(*sql)
|
|
.run()
|
|
.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.prepare(ALTER_QUOTES_ADD_HIDDEN).run().await; // ignore "column exists" error
|
|
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()))
|
|
}
|
|
}
|