chore(quotesdb): add tickets for footer, hidden quotes, reporting, moderation, and rate limiting

New tickets:
- b2af7f: ui — footer with contact email
- 8a7fba: api — hidden flag for quotes (schema + endpoints)
- 77237f: api — reports table + POST /api/quotes/:id/report
- 6c5904: api — admin moderation endpoints
- f4930e: ui — hidden toggle on quote pages
- 354276: ui — report button with modal and Turnstile captcha
- 3f22f2: ui — admin moderation tab
- cb8de0: ui — admin auth-first flow, remove from default nav
- 06d304: infra — Cloudflare rate limiting

Reopened sub-project and root tickets; wired dependencies.
main
Elijah Voigt 3 months ago
parent 89a235bfa3
commit 2272a258f6

@ -0,0 +1,52 @@
+++
title = "quotesdb/infra: Cloudflare rate limiting (WAF rules or Workers rate limiting per IP)"
priority = 5
status = "todo"
ticket_type = "feature"
dependencies = []
+++
## Summary
Investigate and implement rate limiting for the quotesdb API and frontend using Cloudflare's native tooling.
## Options to Evaluate
### 1. Cloudflare WAF Rate Limiting Rules (Recommended starting point)
- Available on free tier with limits; full control on Pro+
- Rules can match on IP, path, method
- Configure via OpenTofu (cloudflare_ruleset resource, phase: http_ratelimit)
- Example: max 10 POST/PUT/DELETE requests per IP per minute to /api/*
### 2. Cloudflare Workers Rate Limiting API
- workers-rs has bindings for the Rate Limiting API (available on paid plans)
- More fine-grained: can key on IP + user-defined keys (e.g., quote ID)
- Useful for per-resource rate limits (e.g., max N reports per IP per quote)
### 3. KV-based rate limiting in the Worker
- Manual implementation using Cloudflare KV as a counter store
- Works on free tier but adds latency and KV cost
- Last resort if WAF rules are insufficient
## Suggested Limits (to start)
- POST /api/quotes (create): 5 per IP per 10 minutes
- POST /api/quotes/:id/report: 3 per IP per hour
- POST /api/quotes/:id (update): 10 per IP per minute
- DELETE /api/quotes/:id: 10 per IP per minute
- GET endpoints: more generous or no limit (Cloudflare CDN caches anyway)
## Tasks
- [ ] Research which Cloudflare plan features are available for this project
- [ ] Implement WAF rate limiting rules in OpenTofu (infra/main.tf or new infra/rate-limits.tf)
- [ ] If Workers Rate Limiting API is needed, add workers-rs bindings and implement in api/main.rs
- [ ] Document the approach and any plan requirements in docs/ARCHITECTURE.md
- [ ] Verify rules are applied with a test script (curl loop)
## Notes
- CAPTCHA on the report endpoint (ticket 354276) provides an additional layer of bot protection
- Rate limiting should complement, not replace, CAPTCHA
## Validation
```sh
# from infra/
tofu validate
tofu plan
```

@ -1,9 +1,9 @@
+++
title = "quotesdb/infra"
priority = 7
status = "done"
status = "todo"
ticket_type = "project"
dependencies = ["07feaa", "5c0c64", "fc9bfd", "07cafb", "e2bd9b", "efee79", "2d1371", "d0da0b", "a23489", "ae886f", "ae6a82", "657836", "75489a", "71b1d4", "d5839a", "3781c9", "5137d7", "57fe5e"]
dependencies = ["06d304"]
+++
<context>

@ -0,0 +1,43 @@
+++
title = "quotesdb/ui: report button with modal (reason field + captcha)"
priority = 5
status = "todo"
ticket_type = "feature"
dependencies = ["77237f"]
+++
## Summary
Add a Report button to quote detail pages that opens a modal with an optional reason field and a CAPTCHA to prevent abuse.
## Details
- Report button appears on /quotes/:id page
- Clicking opens a modal with:
- Optional reason textarea (max 256 characters, show character counter)
- CAPTCHA widget (Cloudflare Turnstile — see infra, or hCaptcha as fallback)
- Submit button (disabled until CAPTCHA is completed)
- Cancel button
- On submit:
- POST /api/quotes/:id/report with { reason?, captcha_token }
- API verifies CAPTCHA server-side before creating report
- Show success message on 201
- Show error message on failure
## CAPTCHA
Use Cloudflare Turnstile (free, privacy-friendly). Site key stored as an environment variable in Trunk.toml or index.html. The API worker verifies the token via the Turnstile verify endpoint.
## Acceptance Criteria
- [ ] Report button visible on /quotes/:id
- [ ] Modal opens with reason textarea and CAPTCHA
- [ ] Character counter on reason field
- [ ] Submit disabled until CAPTCHA solved
- [ ] Correct POST request on submit
- [ ] Success and error feedback shown
- [ ] Modal closes on cancel
## Depends on
- 77237f (reports API endpoint)
## Validation
```sh
cargo fmt && cargo check && cargo clippy
trunk build
```

@ -0,0 +1,41 @@
+++
title = "quotesdb/ui: admin moderation tab (paginated reports, per-quote modal with delete/hide)"
priority = 5
status = "todo"
ticket_type = "feature"
dependencies = ["6c5904", "cb8de0"]
+++
## Summary
Add a Moderation tab to the admin page showing a paginated list of reported quotes. Clicking a report opens a detail modal with the quote and all reports, plus action buttons.
## Tab: Moderation
- Only visible on the admin page after auth is unlocked
- Paginated list (10/page) of quotes with at least one report
- Each row shows: quote text (truncated), author, report count, most recent report date
- Clicking a row opens a Report Detail Modal
## Report Detail Modal
- Shows the full quote (text, author, source, date, tags)
- Lists all reports below: reason (or "No reason given"), reported date
- Action buttons:
- "Delete Quote" — calls DELETE /api/admin/reports/:quote_id/quote, closes modal, refreshes list
- "Hide Quote" — calls POST /api/admin/reports/:quote_id/hide, shows success, refreshes list
- "Dismiss Reports" — calls DELETE /api/admin/reports/:quote_id/reports, closes modal, refreshes list
- Close/Cancel button
## Depends on
- 6c5904 (admin moderation API endpoints)
- cb8de0 (admin auth-first flow)
## Acceptance Criteria
- [ ] Moderation tab visible on /admin after unlock
- [ ] Paginated list of reported quotes
- [ ] Report detail modal with quote + reports
- [ ] Delete, Hide, Dismiss actions work and refresh list
- [ ] Loading and error states handled
## Validation
```sh
cargo fmt && cargo check && cargo clippy
trunk build
```

@ -0,0 +1,48 @@
+++
title = "quotesdb/api: admin moderation endpoints (list reports, delete/hide from report)"
priority = 5
status = "todo"
ticket_type = "feature"
dependencies = ["8a7fba", "77237f"]
+++
## Summary
Add admin endpoints for viewing and acting on reported quotes.
## Endpoints
GET /api/admin/reports?page=N
- Returns paginated list of reported quotes (10/page)
- Each entry: quote summary + report count + most recent report date
- Requires X-Admin-Auth-Code header (admin auth)
- Returns 403 on auth mismatch
GET /api/admin/reports/:quote_id
- Returns the full quote + all reports for that quote (id, reason, created_at)
- Requires X-Admin-Auth-Code header
DELETE /api/admin/reports/:quote_id/quote
- Deletes the quote (cascades to reports via FK)
- Requires X-Admin-Auth-Code header
POST /api/admin/reports/:quote_id/hide
- Sets hidden=1 on the quote
- Requires X-Admin-Auth-Code header
DELETE /api/admin/reports/:quote_id/reports
- Clears all reports for a quote (dismiss reports without acting on the quote)
- Requires X-Admin-Auth-Code header
## Notes
- Admin auth is validated against the admin_auth_code in the DB (same as existing admin endpoints)
- Depends on: 8a7fba (hidden flag), 77237f (reports table)
## Acceptance Criteria
- [ ] All endpoints return correct data
- [ ] All endpoints require and validate admin auth
- [ ] Pagination works for GET /api/admin/reports
- [ ] Delete cascades correctly
- [ ] Unit tests for each endpoint
## Validation
```sh
cargo fmt && cargo check && cargo clippy && cargo test
```

@ -0,0 +1,42 @@
+++
title = "quotesdb/api: reports table and POST /api/quotes/:id/report endpoint"
priority = 5
status = "todo"
ticket_type = "feature"
dependencies = []
+++
## Summary
Create a reports table and a public endpoint for reporting quotes for moderation review.
## Schema
```sql
CREATE TABLE reports (
id TEXT PRIMARY KEY, -- NanoID
quote_id TEXT NOT NULL REFERENCES quotes(id) ON DELETE CASCADE,
reason TEXT, -- optional, max 256 chars
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
```
## API Endpoint
POST /api/quotes/:id/report
- Body: { reason?: string } — reason is optional, max 256 chars
- Validates reason length (400 if > 256 chars)
- Creates a report record
- Returns 201 on success
- Returns 404 if quote not found
## Rate Limiting Note
Rate limiting will be handled separately at the Cloudflare layer (see infra ticket 06d304). No application-level rate limiting needed here.
## Acceptance Criteria
- [ ] reports table created in migration
- [ ] POST /api/quotes/:id/report works
- [ ] reason is optional and validated (max 256 chars)
- [ ] 404 on unknown quote_id
- [ ] Unit tests cover success, missing quote, reason too long
## Validation
```sh
cargo fmt && cargo check && cargo clippy && cargo test
```

@ -0,0 +1,37 @@
+++
title = "quotesdb/api: hidden flag for quotes (schema migration + endpoints)"
priority = 5
status = "todo"
ticket_type = "feature"
dependencies = []
+++
## Summary
Add a boolean hidden flag to quotes. Hidden quotes are excluded from listing endpoints and require direct URL access. Changing a quote from hidden to public requires the auth code.
## Schema Migration
Add column to quotes table:
```sql
ALTER TABLE quotes ADD COLUMN hidden INTEGER NOT NULL DEFAULT 0;
```
## API Changes
- GET /api/quotes — filter out hidden=1 quotes by default
- GET /api/quotes/random — exclude hidden quotes
- GET /api/quotes/:id — return hidden quotes (direct access allowed)
- PUT /api/quotes — new quotes default to hidden=0 (not hidden)
- POST /api/quotes/:id — allow toggling hidden field; requires X-Auth-Code header
- Changing hidden=1 → hidden=0 (unhide) requires valid auth code
- Changing hidden=0 → hidden=1 (hide) also requires valid auth code
- The quote response body should include the hidden field
## Acceptance Criteria
- [ ] Schema migration applied
- [ ] Listing endpoints exclude hidden quotes
- [ ] Direct quote access (/api/quotes/:id) works for hidden quotes
- [ ] Toggle hidden requires valid X-Auth-Code (403 on mismatch)
- [ ] All existing tests pass
## Validation
```sh
cargo fmt && cargo check && cargo clippy && cargo test
```

@ -0,0 +1,27 @@
+++
title = "quotesdb/ui: add footer with contact email"
priority = 3
status = "todo"
ticket_type = "feature"
dependencies = []
+++
## Summary
Add a footer to every page in the quotesdb UI displaying a contact email address.
## Details
- Footer text: "Contact: quotes@elijah.run"
- Should appear at the bottom of every page/route
- Style consistently with the rest of the site (minimal, unobtrusive)
- Make the email a clickable mailto: link
## Acceptance Criteria
- [ ] Footer is visible on all routes (/, /browse, /quotes/:id, /author/:name, /submit, /admin)
- [ ] Email is a mailto: link
- [ ] Styling is consistent with site theme
## Validation
Run from quotesdb/ root:
```sh
cargo fmt && cargo check && cargo clippy
trunk build
```

@ -1,9 +1,9 @@
+++
title = "quotesdb/ui"
priority = 7
status = "done"
status = "todo"
ticket_type = "project"
dependencies = ["166996", "5e3e37", "a9534d", "93515e", "dc3d2b", "04f865", "1e6a09", "0d987f", "2c5a57", "d3d502", "fc2f51", "f850c6", "1a274d", "1ba523", "5f1112", "5cdbd9", "b3ef98", "372790", "0fbdd5", "00d6d7", "9ef703", "5379eb"]
dependencies = ["b2af7f", "f4930e", "354276", "3f22f2", "cb8de0"]
+++
<context>

@ -0,0 +1,40 @@
+++
title = "quotesdb/ui: admin page auth-first flow and remove from default nav"
priority = 5
status = "todo"
ticket_type = "feature"
dependencies = []
+++
## Summary
Two related admin UX improvements:
1. Remove the admin link from all default navigation/page footers — admins access /admin directly via URL.
2. Rework the /admin page so it prompts for the auth code first; the rest of the admin controls are locked until auth succeeds.
## Details
### Remove Admin from Nav
- Audit all pages and the nav component for any link to /admin
- Remove them — /admin should not be discoverable from normal browsing
- The route itself (/admin) remains accessible by direct URL
### Auth-First Admin Page
Currently the /admin page may show controls before authenticating. Change the flow:
- On load, /admin shows only an auth code input field and a submit button
- On submit, call the existing admin status/verify endpoint (or any lightweight admin endpoint) with the provided auth code
- On success: unlock and display all admin tabs (existing controls + new Moderation tab)
- On failure (403): show an error message, keep page locked
- The auth code is kept in component state (not localStorage) — refreshing the page requires re-entering it
## Acceptance Criteria
- [ ] No /admin link anywhere in default navigation or footer
- [ ] /admin loads in locked state showing only auth input
- [ ] Correct admin endpoints called with entered auth code
- [ ] On success: all tabs visible and functional
- [ ] On failure: error shown, page remains locked
- [ ] Re-visiting /admin requires re-authenticating
## Validation
```sh
cargo fmt && cargo check && cargo clippy
trunk build
```

@ -1,7 +1,7 @@
+++
title = "quotesdb"
priority = 8
status = "done"
status = "todo"
ticket_type = "project"
dependencies = ["ce1e4f", "f3dc74", "c3503b", "25c413"]
+++

@ -1,9 +1,9 @@
+++
title = "quotesdb/api"
priority = 7
status = "done"
status = "todo"
ticket_type = "project"
dependencies = ["00aff0", "af56a7", "9c9546", "8892d5"]
dependencies = ["8a7fba", "77237f", "6c5904"]
+++
<context>

@ -0,0 +1,36 @@
+++
title = "quotesdb/ui: hidden toggle on quote pages (auth required to unhide)"
priority = 5
status = "todo"
ticket_type = "feature"
dependencies = ["8a7fba"]
+++
## Summary
Add UI for toggling the hidden state of a quote on the quote detail page. Requires the quote's auth code to change hidden state.
## Details
On the /quotes/:id page:
- If the quote is hidden, show a visible "Hidden" badge/banner near the top of the quote
- Show a toggle button: "Make Public" (if hidden) or "Hide" (if visible)
- Clicking the toggle opens an auth prompt modal asking for the auth code
- On submit, call POST /api/quotes/:id with the new hidden value and the X-Auth-Code header
- On success, update the UI to reflect new state
- On 403, show an error message (invalid auth code)
## Notes
- Hidden quotes are still accessible at /quotes/:id (direct URL)
- Depends on: 8a7fba (API hidden flag support)
## Acceptance Criteria
- [ ] Hidden badge visible when quote.hidden is true
- [ ] Toggle button shown on quote detail page
- [ ] Auth modal appears on click
- [ ] Correct API call on submit
- [ ] Success and error states handled
- [ ] Works for both hiding and unhiding
## Validation
```sh
cargo fmt && cargo check && cargo clippy
trunk build
```
Loading…
Cancel
Save