chore(quotesdb): commit tickets, TODO, and infra README update
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>quotesdb
parent
c4a59ec9ad
commit
00d195c86f
@ -0,0 +1,89 @@
|
|||||||
|
+++
|
||||||
|
title = "quotesdb/api: POST /api/admin/lock and /api/admin/unlock endpoints"
|
||||||
|
priority = 6
|
||||||
|
status = "done"
|
||||||
|
ticket_type = "feature"
|
||||||
|
dependencies = ["69a2c5"]
|
||||||
|
+++
|
||||||
|
## POST /api/admin/lock and /api/admin/unlock endpoints
|
||||||
|
|
||||||
|
Add the two admin-protected endpoints that toggle the global submissions lock. Both require `X-Admin-Code` and return the current lock state after the operation.
|
||||||
|
|
||||||
|
Note: This ticket depends on ticket 35685a (GET /api/status) because that ticket adds `get_submissions_locked` and `set_submissions_locked` to the `QuoteRepository` trait and seeds the `submissions_locked` row in the database. Complete 35685a first.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files to modify
|
||||||
|
|
||||||
|
- `src/bin/api/handlers/mod.rs` (or `src/bin/api/handlers/admin.rs`) — add `lock_submissions` and `unlock_submissions` handlers
|
||||||
|
- `src/bin/api/main.rs` — register the two new routes
|
||||||
|
|
||||||
|
No new DB trait methods are needed; both handlers reuse `set_submissions_locked(bool)` introduced in 35685a.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Handlers
|
||||||
|
|
||||||
|
```rust
|
||||||
|
/// POST /api/admin/lock
|
||||||
|
/// Requires X-Admin-Code header. Sets submissions_locked = true.
|
||||||
|
/// Response: 200 { "submissions_locked": true } or 403 on bad code.
|
||||||
|
pub async fn lock_submissions(
|
||||||
|
State(repo): State<Arc<dyn QuoteRepository>>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let admin_code = extract_admin_code(&headers);
|
||||||
|
if !verify_admin_code(&repo, admin_code).await { ... }
|
||||||
|
match repo.set_submissions_locked(true).await {
|
||||||
|
Ok(()) => Json(json!({ "submissions_locked": true })).into_response(),
|
||||||
|
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /api/admin/unlock
|
||||||
|
/// Requires X-Admin-Code header. Sets submissions_locked = false.
|
||||||
|
/// Response: 200 { "submissions_locked": false } or 403 on bad code.
|
||||||
|
pub async fn unlock_submissions(
|
||||||
|
State(repo): State<Arc<dyn QuoteRepository>>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
// same pattern, locked = false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Implement a shared helper `verify_admin_code(repo, code) -> bool` (or extract inline) that fetches the stored admin code from `admin_config` and compares it. Use constant-time comparison if possible.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Route registration (src/bin/api/main.rs)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
.route("/api/admin/lock", post(handlers::lock_submissions))
|
||||||
|
.route("/api/admin/unlock", post(handlers::unlock_submissions))
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
- `POST /api/admin/lock` with correct `X-Admin-Code` → `200 { "submissions_locked": true }`
|
||||||
|
- `POST /api/admin/unlock` with correct `X-Admin-Code` → `200 { "submissions_locked": false }`
|
||||||
|
- `POST /api/admin/lock` with wrong code → `403`
|
||||||
|
- `POST /api/admin/unlock` with missing header → `403`
|
||||||
|
- Lock/unlock idempotent: locking when already locked still returns `200 { "submissions_locked": true }`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo fmt && cargo check && cargo clippy && cargo test
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Commit
|
||||||
|
|
||||||
|
```
|
||||||
|
feat(quotesdb): POST /api/admin/lock and /api/admin/unlock endpoints
|
||||||
|
```
|
||||||
@ -0,0 +1,100 @@
|
|||||||
|
+++
|
||||||
|
title = "quotesdb/ui: /submit page locked-state banner"
|
||||||
|
priority = 5
|
||||||
|
status = "done"
|
||||||
|
ticket_type = "feature"
|
||||||
|
dependencies = ["161f32"]
|
||||||
|
+++
|
||||||
|
## /submit page locked-state banner (UI)
|
||||||
|
|
||||||
|
Modify the existing /submit page to check the submission lock on mount. When the lock is active, hide the submission form entirely and show a closed-submissions banner in its place.
|
||||||
|
|
||||||
|
Depends on ticket 161f32 (admin API client functions). Complete that ticket first.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files to modify
|
||||||
|
|
||||||
|
- `src/bin/ui/pages/submit.rs` — add status check on mount and conditional rendering
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes to SubmitPage component
|
||||||
|
|
||||||
|
### New state fields
|
||||||
|
|
||||||
|
Add to the existing component state:
|
||||||
|
|
||||||
|
| Field | Type | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `submissions_locked` | `Option<bool>` | `None` while loading, `Some(true/false)` after status check |
|
||||||
|
| `status_error` | `bool` | Set if `get_status()` itself fails (show form as fallback) |
|
||||||
|
|
||||||
|
### On mount
|
||||||
|
|
||||||
|
Spawn an async task that calls `api::get_status()`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// In use_effect_with or similar hook, fired once on mount:
|
||||||
|
let submissions_locked = submissions_locked.clone();
|
||||||
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
|
match api::get_status().await {
|
||||||
|
Ok(status) => submissions_locked.set(Some(status.submissions_locked)),
|
||||||
|
Err(_) => {
|
||||||
|
// On error, default to showing the form (fail open).
|
||||||
|
submissions_locked.set(Some(false));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Render logic
|
||||||
|
|
||||||
|
```
|
||||||
|
if submissions_locked == None:
|
||||||
|
show a loading indicator (or nothing / skeleton)
|
||||||
|
elif submissions_locked == Some(true):
|
||||||
|
show closed banner, hide form
|
||||||
|
else:
|
||||||
|
show form as normal
|
||||||
|
```
|
||||||
|
|
||||||
|
### Closed banner markup (approximate)
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="submissions-closed-banner">
|
||||||
|
<p>Submissions are currently closed.</p>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
Style the banner to be visually distinct — use a muted/warning colour. Add the `.submissions-closed-banner` CSS class to `src/bin/ui/style.css`.
|
||||||
|
|
||||||
|
### Fail-open behaviour
|
||||||
|
|
||||||
|
If `api::get_status()` returns an error, treat it as unlocked (`Some(false)`) and display the form. Do not block a user from submitting due to a network error on the status check.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
No runtime unit tests (wasm-only). Verify the build:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo check --target wasm32-unknown-unknown --bin ui
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo fmt && cargo check --target wasm32-unknown-unknown --bin ui
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Commit
|
||||||
|
|
||||||
|
```
|
||||||
|
feat(quotesdb): show locked banner on /submit when submissions are closed
|
||||||
|
```
|
||||||
@ -0,0 +1,122 @@
|
|||||||
|
+++
|
||||||
|
title = "quotesdb/ui: admin API client functions"
|
||||||
|
priority = 5
|
||||||
|
status = "done"
|
||||||
|
ticket_type = "feature"
|
||||||
|
dependencies = []
|
||||||
|
+++
|
||||||
|
## Admin API client functions (UI)
|
||||||
|
|
||||||
|
Add four async functions to the UI API client module that cover every admin and status endpoint introduced by the API tickets. These functions are consumed by the /admin page and the /submit page.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files to modify
|
||||||
|
|
||||||
|
- `src/bin/ui/api.rs` — add four new public async functions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## New types
|
||||||
|
|
||||||
|
Add to `src/bin/ui/api.rs` (or to a shared types module imported by api.rs):
|
||||||
|
|
||||||
|
```rust
|
||||||
|
/// Response from GET /api/status.
|
||||||
|
#[derive(Deserialize, Clone, PartialEq)]
|
||||||
|
pub struct StatusResponse {
|
||||||
|
pub submissions_locked: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Response from POST /api/admin/reset-auth-code.
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct ResetAuthCodeResponse {
|
||||||
|
pub auth_code: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Response from POST /api/admin/lock or /api/admin/unlock.
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct LockResponse {
|
||||||
|
pub submissions_locked: bool,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## New functions
|
||||||
|
|
||||||
|
```rust
|
||||||
|
/// Fetch the current submission lock state from GET /api/status.
|
||||||
|
/// Returns Ok(StatusResponse) on success or ApiError on failure.
|
||||||
|
pub async fn get_status() -> Result<StatusResponse, ApiError> { ... }
|
||||||
|
|
||||||
|
/// Call POST /api/admin/reset-auth-code.
|
||||||
|
/// Sends X-Admin-Code: admin_code in the request header.
|
||||||
|
/// Body: { "new_code": new_code } (omit field if new_code is None).
|
||||||
|
/// Returns the new auth code string on success, or ApiError on failure.
|
||||||
|
pub async fn admin_reset_auth_code(
|
||||||
|
current: &str,
|
||||||
|
new_code: Option<&str>,
|
||||||
|
admin_code: &str,
|
||||||
|
) -> Result<String, ApiError> { ... }
|
||||||
|
|
||||||
|
/// Call POST /api/admin/lock.
|
||||||
|
/// Sends X-Admin-Code: admin_code in the request header.
|
||||||
|
/// Returns Ok(true) on success, or ApiError (including ApiError::Forbidden on 403).
|
||||||
|
pub async fn admin_lock(admin_code: &str) -> Result<bool, ApiError> { ... }
|
||||||
|
|
||||||
|
/// Call POST /api/admin/unlock.
|
||||||
|
/// Sends X-Admin-Code: admin_code in the request header.
|
||||||
|
/// Returns Ok(false) on success, or ApiError (including ApiError::Forbidden on 403).
|
||||||
|
pub async fn admin_unlock(admin_code: &str) -> Result<bool, ApiError> { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
Implementation notes:
|
||||||
|
- Use the same `gloo_net::http::Request` pattern already used in `api.rs` for other endpoints.
|
||||||
|
- Add an `ApiError::Forbidden` variant (or reuse an existing error variant) to communicate `403` responses back to the UI so pages can show "Wrong auth code." without a generic error.
|
||||||
|
- `admin_reset_auth_code`: serialize the body as `{ "new_code": "..." }` when `new_code` is `Some`, or as `{}` when `None`.
|
||||||
|
- `admin_lock` and `admin_unlock` send no request body (empty POST).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ApiError extension
|
||||||
|
|
||||||
|
If `ApiError` does not already have a `Forbidden` variant, add one:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub enum ApiError {
|
||||||
|
// ... existing variants ...
|
||||||
|
/// The server returned 403 Forbidden (wrong admin code).
|
||||||
|
Forbidden,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Map HTTP 403 → `ApiError::Forbidden` in each new function before returning.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
This module compiles only for `wasm32-unknown-unknown` so no `cargo test` unit tests are practical here. Instead, verify the build compiles cleanly:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo check --target wasm32-unknown-unknown --bin ui
|
||||||
|
```
|
||||||
|
|
||||||
|
Write a brief doc-comment on each function describing its endpoint, required header, and error conditions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo fmt && cargo check --target wasm32-unknown-unknown --bin ui
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Commit
|
||||||
|
|
||||||
|
```
|
||||||
|
feat(quotesdb): admin API client functions in UI
|
||||||
|
```
|
||||||
@ -0,0 +1,100 @@
|
|||||||
|
+++
|
||||||
|
title = "quotesdb/api: GET /api/status public endpoint"
|
||||||
|
priority = 6
|
||||||
|
status = "done"
|
||||||
|
ticket_type = "feature"
|
||||||
|
dependencies = ["69a2c5"]
|
||||||
|
+++
|
||||||
|
## GET /api/status public endpoint
|
||||||
|
|
||||||
|
Add a public status endpoint that exposes whether submissions are currently locked. The UI calls this on mount for both the /submit and /admin pages.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files to modify
|
||||||
|
|
||||||
|
- `src/bin/api/db/mod.rs` — add `get_submissions_locked` and `set_submissions_locked` to the `QuoteRepository` trait
|
||||||
|
- `src/bin/api/db/d1.rs` — implement the two new trait methods for the D1 backend; seed `submissions_locked = "0"` alongside `admin_auth_code` in the startup migration if not already present
|
||||||
|
- `src/bin/api/db/native.rs` — implement the two new trait methods for the native/SQLite backend
|
||||||
|
- `src/bin/api/handlers/mod.rs` (or a new `src/bin/api/handlers/status.rs`) — add the `get_status` handler
|
||||||
|
- `src/bin/api/main.rs` — register the new route
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Database
|
||||||
|
|
||||||
|
The `admin_config` key/value table gains a second row. Seed it on startup (alongside `admin_auth_code`) if it does not already exist:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
INSERT OR IGNORE INTO admin_config (key, value) VALUES ('submissions_locked', '0');
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## New trait methods (src/bin/api/db/mod.rs)
|
||||||
|
|
||||||
|
Add to the `QuoteRepository` trait:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
/// Return whether submissions are currently locked.
|
||||||
|
async fn get_submissions_locked(&self) -> Result<bool, DbError>;
|
||||||
|
|
||||||
|
/// Set the submissions lock state.
|
||||||
|
async fn set_submissions_locked(&self, locked: bool) -> Result<(), DbError>;
|
||||||
|
```
|
||||||
|
|
||||||
|
Both implementations read/write the `submissions_locked` key in `admin_config`, treating `"1"` as `true` and anything else as `false`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Handler
|
||||||
|
|
||||||
|
Add to the handlers module:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
/// GET /api/status — returns current submission lock state; requires no auth.
|
||||||
|
pub async fn get_status(State(repo): State<Arc<dyn QuoteRepository>>) -> impl IntoResponse {
|
||||||
|
match repo.get_submissions_locked().await {
|
||||||
|
Ok(locked) => Json(json!({ "submissions_locked": locked })).into_response(),
|
||||||
|
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Route registration (src/bin/api/main.rs)
|
||||||
|
|
||||||
|
Register before the quotes router:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
.route("/api/status", get(handlers::get_status))
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
In `src/bin/api/handlers/` (or the relevant test module), add unit tests covering:
|
||||||
|
|
||||||
|
- `GET /api/status` returns `200` with `{ "submissions_locked": false }` when the DB value is `"0"`
|
||||||
|
- `GET /api/status` returns `200` with `{ "submissions_locked": true }` when the DB value is `"1"`
|
||||||
|
- `get_submissions_locked` returns `false` for missing key (graceful default)
|
||||||
|
|
||||||
|
Use a mock or in-memory SQLite repo for all handler tests.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo fmt && cargo check && cargo clippy && cargo test
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Commit
|
||||||
|
|
||||||
|
```
|
||||||
|
feat(quotesdb): GET /api/status public endpoint
|
||||||
|
```
|
||||||
@ -0,0 +1,45 @@
|
|||||||
|
+++
|
||||||
|
title = "Add workers-rs WASM entry point to api binary"
|
||||||
|
priority = 7
|
||||||
|
status = "done"
|
||||||
|
ticket_type = "feature"
|
||||||
|
dependencies = []
|
||||||
|
+++
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Enable `cargo build --release --bin api --target wasm32-unknown-unknown` so the api binary deploys as a Cloudflare Worker (see `infra/worker.tf`).
|
||||||
|
|
||||||
|
## Changes Required
|
||||||
|
|
||||||
|
### 1. `Cargo.toml`
|
||||||
|
Add axum to `[target.'cfg(target_arch = "wasm32")'.dependencies]` with tokio disabled:
|
||||||
|
```toml
|
||||||
|
axum = { version = "0.8", default-features = false, features = ["json"] }
|
||||||
|
```
|
||||||
|
All axum types used in handlers (Router, Path, Query, State, Json, etc.) are available without the tokio feature.
|
||||||
|
|
||||||
|
### 2. `src/bin/api/main.rs`
|
||||||
|
- Wrap existing native code (`mod handlers;`, `#[tokio::main] async fn main()`) in `#[cfg(not(target_arch = "wasm32"))]`
|
||||||
|
- Add `#[cfg(target_arch = "wasm32")] mod handlers;` (no change to handlers themselves)
|
||||||
|
- Add `#[event(fetch)]` workers-rs entry point that:
|
||||||
|
1. Gets D1 binding from env: `env.d1("DB")`
|
||||||
|
2. Creates `D1Repository::new(db)` — see companion ticket for D1 implementation
|
||||||
|
3. Calls `repo.run_migrations()`
|
||||||
|
4. Wraps repo in `Arc<dyn QuoteRepository + Send + Sync>`
|
||||||
|
5. Builds Axum router via existing `handlers::router(repo)`
|
||||||
|
6. Converts `worker::Request` → `http::Request<axum::body::Body>` (method, uri, headers, body bytes)
|
||||||
|
7. Calls router via `tower_service::Service::call()`
|
||||||
|
8. Converts `http::Response<axum::body::Body>` → `worker::Response` (status, headers, body bytes)
|
||||||
|
|
||||||
|
`tower_service` is already a transitive dep of axum.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
- Companion ticket for D1Repository implementation must be done first (or in parallel).
|
||||||
|
D1Repository must have `unsafe impl Send` and `unsafe impl Sync` for the Arc<dyn ... + Send + Sync> wrapper to work.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
```sh
|
||||||
|
cargo build --release --bin api --target wasm32-unknown-unknown
|
||||||
|
cargo build --release --bin api # native must still work
|
||||||
|
cargo fmt && cargo check && cargo clippy && cargo test
|
||||||
|
```
|
||||||
@ -0,0 +1,121 @@
|
|||||||
|
+++
|
||||||
|
title = "quotesdb/api: POST /api/admin/reset-auth-code endpoint"
|
||||||
|
priority = 6
|
||||||
|
status = "done"
|
||||||
|
ticket_type = "feature"
|
||||||
|
dependencies = ["69a2c5"]
|
||||||
|
+++
|
||||||
|
## POST /api/admin/reset-auth-code endpoint
|
||||||
|
|
||||||
|
Add the admin-protected endpoint that replaces the stored admin auth code. The caller must supply the current code via `X-Admin-Code`. A new code may be provided in the request body; if omitted, the server generates a fresh 4-word passphrase.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files to modify
|
||||||
|
|
||||||
|
- `src/bin/api/db/mod.rs` — add `update_admin_auth_code` to the `QuoteRepository` trait
|
||||||
|
- `src/bin/api/db/d1.rs` — implement `update_admin_auth_code` for D1
|
||||||
|
- `src/bin/api/db/native.rs` — implement `update_admin_auth_code` for native SQLite
|
||||||
|
- `src/bin/api/handlers/mod.rs` (or a new `src/bin/api/handlers/admin.rs`) — add the `reset_auth_code` handler
|
||||||
|
- `src/bin/api/main.rs` — register the new route
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## New trait method (src/bin/api/db/mod.rs)
|
||||||
|
|
||||||
|
Add to the `QuoteRepository` trait:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
/// Replace the admin auth code if `current` matches the stored value.
|
||||||
|
/// If `new_code` is `None`, generates a fresh 4-word passphrase.
|
||||||
|
/// Returns the new auth code string on success, or `DbError::Unauthorized`
|
||||||
|
/// if `current` does not match.
|
||||||
|
async fn update_admin_auth_code(
|
||||||
|
&self,
|
||||||
|
current: &str,
|
||||||
|
new_code: Option<&str>,
|
||||||
|
) -> Result<String, DbError>;
|
||||||
|
```
|
||||||
|
|
||||||
|
Implementation steps:
|
||||||
|
1. Fetch the stored `admin_auth_code` from `admin_config`.
|
||||||
|
2. If it does not match `current`, return `DbError::Unauthorized` (or a dedicated variant).
|
||||||
|
3. Determine the new code: use `new_code` if provided, otherwise call the existing passphrase-generation utility.
|
||||||
|
4. Write the new value to `admin_config` with `UPDATE`.
|
||||||
|
5. Return the new code string.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Request / response types
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct ResetAuthCodeRequest {
|
||||||
|
new_code: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct ResetAuthCodeResponse {
|
||||||
|
auth_code: String,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Handler
|
||||||
|
|
||||||
|
```rust
|
||||||
|
/// POST /api/admin/reset-auth-code
|
||||||
|
/// Requires X-Admin-Code header matching the stored admin passphrase.
|
||||||
|
/// Body: { "new_code": "optional-string" }
|
||||||
|
/// Response: 200 { "auth_code": "new-code" } or 403 on mismatch.
|
||||||
|
pub async fn reset_auth_code(
|
||||||
|
State(repo): State<Arc<dyn QuoteRepository>>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Json(payload): Json<ResetAuthCodeRequest>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let admin_code = match headers.get("x-admin-code").and_then(|v| v.to_str().ok()) {
|
||||||
|
Some(c) => c.to_owned(),
|
||||||
|
None => return StatusCode::FORBIDDEN.into_response(),
|
||||||
|
};
|
||||||
|
match repo.update_admin_auth_code(&admin_code, payload.new_code.as_deref()).await {
|
||||||
|
Ok(new_code) => Json(ResetAuthCodeResponse { auth_code: new_code }).into_response(),
|
||||||
|
Err(DbError::Unauthorized) => StatusCode::FORBIDDEN.into_response(),
|
||||||
|
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Route registration (src/bin/api/main.rs)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
.route("/api/admin/reset-auth-code", post(handlers::reset_auth_code))
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
- `POST /api/admin/reset-auth-code` with correct `X-Admin-Code` and no body `new_code` → `200`, response contains a non-empty `auth_code`
|
||||||
|
- `POST /api/admin/reset-auth-code` with correct `X-Admin-Code` and explicit `new_code` → `200`, `auth_code` equals the supplied value
|
||||||
|
- `POST /api/admin/reset-auth-code` with wrong `X-Admin-Code` → `403`
|
||||||
|
- `POST /api/admin/reset-auth-code` with missing `X-Admin-Code` header → `403`
|
||||||
|
- After a successful reset, subsequent calls with the old code return `403` and with the new code return `200`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo fmt && cargo check && cargo clippy && cargo test
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Commit
|
||||||
|
|
||||||
|
```
|
||||||
|
feat(quotesdb): POST /api/admin/reset-auth-code endpoint
|
||||||
|
```
|
||||||
@ -0,0 +1,64 @@
|
|||||||
|
+++
|
||||||
|
title = "quotesdb/api: DB layer — add submissions_locked + update_admin_auth_code"
|
||||||
|
priority = 7
|
||||||
|
status = "done"
|
||||||
|
ticket_type = "feature"
|
||||||
|
dependencies = []
|
||||||
|
+++
|
||||||
|
## Goal
|
||||||
|
Extend the DB abstraction layer with three new trait methods and seed on startup.
|
||||||
|
|
||||||
|
## New trait methods (add to `src/bin/api/db/mod.rs`)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
/// Replace the admin auth code if `current` matches.
|
||||||
|
/// If `new_code` is None, generates a fresh 4-word passphrase.
|
||||||
|
/// 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.
|
||||||
|
async fn get_submissions_locked(&self) -> Result<bool, DbError>;
|
||||||
|
|
||||||
|
/// Persist the submissions lock state.
|
||||||
|
async fn set_submissions_locked(&self, locked: bool) -> Result<(), DbError>;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Implementations
|
||||||
|
|
||||||
|
Implement in both:
|
||||||
|
- `src/bin/api/db/native.rs` (NativeRepository — rusqlite)
|
||||||
|
- `src/bin/api/db/d1.rs` (D1Repository — Cloudflare Workers WASM)
|
||||||
|
|
||||||
|
## Seeding (startup)
|
||||||
|
|
||||||
|
In `src/bin/api/main.rs` (both native and wasm32 paths), after seeding
|
||||||
|
`admin_auth_code`, also seed `submissions_locked = '0'` using
|
||||||
|
`INSERT OR IGNORE` (use `set_submissions_locked` only when the key is absent,
|
||||||
|
or add a dedicated `seed_submissions_locked` helper).
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Add unit/integration tests in `src/bin/api/handlers/mod.rs` test module
|
||||||
|
or `tests/` covering:
|
||||||
|
- get_submissions_locked returns false by default
|
||||||
|
- set_submissions_locked(true) then get_submissions_locked returns true
|
||||||
|
- update_admin_auth_code with correct current succeeds
|
||||||
|
- update_admin_auth_code with wrong current returns Forbidden
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
Run from `quotesdb/`:
|
||||||
|
```
|
||||||
|
cargo fmt && cargo check && cargo clippy && cargo test
|
||||||
|
```
|
||||||
|
|
||||||
|
## Commit scope
|
||||||
|
`feat(quotesdb): ...`
|
||||||
|
|
||||||
|
## Design reference
|
||||||
|
`docs/plans/2026-03-04-admin-features-design.md`
|
||||||
@ -0,0 +1,168 @@
|
|||||||
|
+++
|
||||||
|
title = "Admin super auth code: delete any quote regardless of per-quote auth"
|
||||||
|
priority = 7
|
||||||
|
status = "done"
|
||||||
|
ticket_type = "feature"
|
||||||
|
dependencies = []
|
||||||
|
+++
|
||||||
|
## Feature
|
||||||
|
|
||||||
|
Add an **admin super auth code** — a single global passphrase that can delete (and update) any quote, bypassing the per-quote `auth_code` check. This allows the operator to moderate content without needing the original submitter's code.
|
||||||
|
|
||||||
|
The admin code is:
|
||||||
|
- Generated once on first startup using the same 4-word passphrase generator (`generate_auth_code` in `src/lib.rs`).
|
||||||
|
- Stored in the database in a new `admin_config` table.
|
||||||
|
- Printed prominently to stderr on every startup so the operator can note it.
|
||||||
|
- Never exposed via the API.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 1 — Database: new migration
|
||||||
|
|
||||||
|
Add a new migration constant in `src/bin/api/db/migrations.rs`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
/// Creates the admin_config key/value table for storing global configuration.
|
||||||
|
///
|
||||||
|
/// Stores a single row for the admin auth code under the 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
|
||||||
|
)";
|
||||||
|
```
|
||||||
|
|
||||||
|
Run this migration in `QuoteRepository::run_migrations` after the existing migrations. The implementation then seeds the admin auth code if absent (see Part 2).
|
||||||
|
|
||||||
|
Update `infra/schema.sql` to include:
|
||||||
|
```sql
|
||||||
|
CREATE TABLE IF NOT EXISTS admin_config (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 2 — Repository trait: `db/mod.rs`
|
||||||
|
|
||||||
|
Add two new methods to `QuoteRepository`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
/// Retrieve the admin super auth code from `admin_config`.
|
||||||
|
///
|
||||||
|
/// Returns `Ok(None)` if the table is empty (should not happen after migrations).
|
||||||
|
async fn get_admin_auth_code(&self) -> Result<Option<String>, DbError>;
|
||||||
|
|
||||||
|
/// Insert the admin auth code into `admin_config` if it is not already set.
|
||||||
|
///
|
||||||
|
/// Called once during startup, after `run_migrations`.
|
||||||
|
async fn seed_admin_auth_code(&self, code: &str) -> Result<(), DbError>;
|
||||||
|
```
|
||||||
|
|
||||||
|
The startup sequence (in `main.rs`) becomes:
|
||||||
|
```rust
|
||||||
|
repo.run_migrations().await?;
|
||||||
|
// Seed admin code on first run
|
||||||
|
if repo.get_admin_auth_code().await?.is_none() {
|
||||||
|
let code = quotesdb::generate_auth_code();
|
||||||
|
repo.seed_admin_auth_code(&code).await?;
|
||||||
|
}
|
||||||
|
// Always print the admin code at startup
|
||||||
|
let admin_code = repo.get_admin_auth_code().await?.unwrap();
|
||||||
|
eprintln!("╔══════════════════════════════════════════════╗");
|
||||||
|
eprintln!("║ ADMIN AUTH CODE: {admin_code:<28}║");
|
||||||
|
eprintln!("╚══════════════════════════════════════════════╝");
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 3 — Native implementation: `db/native.rs`
|
||||||
|
|
||||||
|
Implement `get_admin_auth_code` and `seed_admin_auth_code` using rusqlite.
|
||||||
|
|
||||||
|
**Extend `delete_quote`** to accept the admin code as a fallback:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
async fn delete_quote(&self, id: &str, auth_code: &str) -> Result<DeleteResult, DbError> {
|
||||||
|
// ... existing logic ...
|
||||||
|
// Before returning Forbidden, check admin auth code
|
||||||
|
let admin_code = self.get_admin_auth_code().await?;
|
||||||
|
if Some(auth_code) == admin_code.as_deref() {
|
||||||
|
// Admin code matches — delete unconditionally
|
||||||
|
// ... execute DELETE without checking quotes.auth_code ...
|
||||||
|
return Ok(DeleteResult::Deleted);
|
||||||
|
}
|
||||||
|
Ok(DeleteResult::Forbidden)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Similarly extend `update_quote` to allow admin override.
|
||||||
|
|
||||||
|
The cleanest approach is to refactor `delete_quote` and `update_quote` to first attempt the per-quote auth check, and if it fails, check against the admin code.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 4 — D1 implementation: `db/d1.rs`
|
||||||
|
|
||||||
|
Apply the same changes as Part 3 for the WASM/Cloudflare D1 path.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 5 — API startup: `src/bin/api/main.rs`
|
||||||
|
|
||||||
|
Update the startup sequence as shown in Part 2. The admin code print must be clearly visible in logs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 6 — Mock repo in tests: `handlers/mod.rs`
|
||||||
|
|
||||||
|
Add stub implementations of `get_admin_auth_code` and `seed_admin_auth_code` to `MockRepo`:
|
||||||
|
```rust
|
||||||
|
async fn get_admin_auth_code(&self) -> Result<Option<String>, DbError> {
|
||||||
|
Ok(None) // no admin code in tests by default
|
||||||
|
}
|
||||||
|
async fn seed_admin_auth_code(&self, _code: &str) -> Result<(), DbError> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Design notes
|
||||||
|
|
||||||
|
- The admin code is **never returned by any API endpoint** — there is no way to discover it via HTTP.
|
||||||
|
- The admin code is stored plaintext in `admin_config`, consistent with per-quote auth codes. This is acceptable given the stated security model (simple passphrase, no user accounts).
|
||||||
|
- Only `delete_quote` and `update_quote` check the admin code. Read operations are unaffected.
|
||||||
|
- The admin code is **not rotatable** via the API — an operator who needs to rotate it must manually update the database row.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files touched
|
||||||
|
|
||||||
|
- `src/bin/api/db/migrations.rs` — `CREATE_ADMIN_CONFIG` constant
|
||||||
|
- `src/bin/api/db/mod.rs` — two new trait methods + updated docstrings for `delete_quote`/`update_quote`
|
||||||
|
- `src/bin/api/db/native.rs` — implementations + admin fallback logic
|
||||||
|
- `src/bin/api/db/d1.rs` — same for D1
|
||||||
|
- `src/bin/api/handlers/mod.rs` — `MockRepo` stubs
|
||||||
|
- `src/bin/api/main.rs` — seed + print admin code on startup
|
||||||
|
- `infra/schema.sql` — `admin_config` table
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# From quotesdb/ root
|
||||||
|
cargo fmt && cargo check && cargo clippy && cargo test
|
||||||
|
```
|
||||||
|
|
||||||
|
Manual test:
|
||||||
|
1. Start the server: `cargo run`
|
||||||
|
2. Observe admin code printed to stderr.
|
||||||
|
3. Create a quote: `curl -X PUT http://localhost:3000/api/quotes -H 'Content-Type: application/json' -d '{"text":"Test","author":"A","tags":[]}'`
|
||||||
|
4. Try deleting with wrong code: should return 403.
|
||||||
|
5. Try deleting with admin code: should return 204.
|
||||||
|
6. Restart the server: same admin code should be printed (not regenerated).
|
||||||
|
|
||||||
|
## Commit scope
|
||||||
|
|
||||||
|
`feat(quotesdb): admin super auth code for quote moderation`
|
||||||
@ -0,0 +1,46 @@
|
|||||||
|
+++
|
||||||
|
title = "Home page: show friendly empty state when no quotes in database"
|
||||||
|
priority = 7
|
||||||
|
status = "done"
|
||||||
|
ticket_type = "bug"
|
||||||
|
dependencies = []
|
||||||
|
+++
|
||||||
|
## Bug
|
||||||
|
|
||||||
|
When the database is empty, `GET /api/quotes/random` returns a 404 response. The home page (`src/bin/ui/pages/home.rs`) currently treats all errors (including 404) the same way — it sets the `error` state and displays it via `<ErrorDisplay>`, which results in something like a raw JSON error message being shown to the user.
|
||||||
|
|
||||||
|
## Expected behaviour
|
||||||
|
|
||||||
|
When the API returns a 404 on `/api/quotes/random`, the home page should display a friendly empty-state message instead of a generic error:
|
||||||
|
|
||||||
|
> "Nothing here yet. Submit a quote!"
|
||||||
|
|
||||||
|
The message should include a link to `/submit`.
|
||||||
|
|
||||||
|
## How to fix
|
||||||
|
|
||||||
|
In `src/bin/ui/pages/home.rs`, in the `use_effect_with` block, inspect the `Err` value. The API client returns `ApiError::Server { status, .. }` for HTTP error codes. When `status == 404`, set a dedicated "empty" state (or detect it from the error) and render the friendly message instead of `<ErrorDisplay>`.
|
||||||
|
|
||||||
|
Relevant code: `src/bin/ui/pages/home.rs` — the `use_effect_with` error branch and the HTML render block.
|
||||||
|
|
||||||
|
## File
|
||||||
|
|
||||||
|
- `src/bin/ui/pages/home.rs`
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# From quotesdb/ root
|
||||||
|
cargo fmt && cargo check && cargo clippy && cargo test
|
||||||
|
```
|
||||||
|
|
||||||
|
Also manually test with an empty database:
|
||||||
|
```sh
|
||||||
|
rm -f quotesdb.sqlite
|
||||||
|
cargo run &
|
||||||
|
# navigate to http://localhost:3000 — should show the friendly message, not a raw error
|
||||||
|
```
|
||||||
|
|
||||||
|
## Commit scope
|
||||||
|
|
||||||
|
`fix(quotesdb): home page empty state`
|
||||||
@ -0,0 +1,68 @@
|
|||||||
|
+++
|
||||||
|
title = "quotesdb/api: enforce submission lock on PUT /api/quotes"
|
||||||
|
priority = 6
|
||||||
|
status = "done"
|
||||||
|
ticket_type = "feature"
|
||||||
|
dependencies = ["69a2c5"]
|
||||||
|
+++
|
||||||
|
## Enforce submission lock on PUT /api/quotes
|
||||||
|
|
||||||
|
Modify the quote-creation handler to check the submissions lock before accepting a new quote. If locked, return `423 Locked` with a JSON error body.
|
||||||
|
|
||||||
|
Note: This ticket depends on ticket 35685a (GET /api/status) because that ticket adds `get_submissions_locked` to the `QuoteRepository` trait. Complete 35685a first.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files to modify
|
||||||
|
|
||||||
|
- `src/bin/api/handlers/mod.rs` — modify the `create_quote` handler (the handler for `PUT /api/quotes`) to add a lock pre-flight check
|
||||||
|
|
||||||
|
No new DB methods, routes, or types are needed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Change to create_quote handler
|
||||||
|
|
||||||
|
At the top of the handler body, before any other logic, add:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// Pre-flight: reject new submissions when locked.
|
||||||
|
match repo.get_submissions_locked().await {
|
||||||
|
Ok(true) => {
|
||||||
|
return (
|
||||||
|
StatusCode::LOCKED,
|
||||||
|
Json(json!({ "error": "submissions are closed" })),
|
||||||
|
).into_response();
|
||||||
|
}
|
||||||
|
Ok(false) => {}
|
||||||
|
Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
HTTP 423 is `StatusCode::LOCKED` in axum/hyper.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
- `PUT /api/quotes` while `submissions_locked = false` → `201` (existing behaviour unchanged)
|
||||||
|
- `PUT /api/quotes` while `submissions_locked = true` → `423` with body `{ "error": "submissions are closed" }`
|
||||||
|
- After unlocking (`submissions_locked = false`), `PUT /api/quotes` succeeds again → `201`
|
||||||
|
|
||||||
|
Use the in-memory/mock repo already used by other handler tests; expose a method to toggle the lock state on the test double.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo fmt && cargo check && cargo clippy && cargo test
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Commit
|
||||||
|
|
||||||
|
```
|
||||||
|
feat(quotesdb): enforce submission lock on PUT /api/quotes
|
||||||
|
```
|
||||||
@ -0,0 +1,144 @@
|
|||||||
|
+++
|
||||||
|
title = "Cloudflare Turnstile CAPTCHA on quote submission"
|
||||||
|
priority = 7
|
||||||
|
status = "done"
|
||||||
|
ticket_type = "feature"
|
||||||
|
dependencies = []
|
||||||
|
+++
|
||||||
|
## Feature
|
||||||
|
|
||||||
|
Add Cloudflare Turnstile CAPTCHA to protect the `PUT /api/quotes` endpoint (and the submit form in the UI) from bots and spam. This is a three-part change: infra, API, and UI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 1 — Infra: Turnstile widget resource
|
||||||
|
|
||||||
|
Create `infra/turnstile.tf` with a `cloudflare_turnstile_widget` resource.
|
||||||
|
|
||||||
|
```hcl
|
||||||
|
# Turnstile CAPTCHA widget protecting the quote submission form.
|
||||||
|
# Provides a site_key (public, embedded in the UI) and secret_key
|
||||||
|
# (private, used by the API to verify tokens server-side).
|
||||||
|
resource "cloudflare_turnstile_widget" "submit" {
|
||||||
|
account_id = var.cloudflare_account_id
|
||||||
|
name = "quotesdb-submit"
|
||||||
|
# "managed" mode: Turnstile decides whether to show a visible challenge.
|
||||||
|
mode = "managed"
|
||||||
|
# Restrict the widget to the production domain.
|
||||||
|
domains = [var.domain]
|
||||||
|
}
|
||||||
|
|
||||||
|
output "turnstile_site_key" {
|
||||||
|
description = "Turnstile site key — safe to embed in the UI."
|
||||||
|
value = cloudflare_turnstile_widget.submit.id
|
||||||
|
}
|
||||||
|
|
||||||
|
output "turnstile_secret_key" {
|
||||||
|
description = "Turnstile secret key — inject into Workers via wrangler secret."
|
||||||
|
value = cloudflare_turnstile_widget.submit.secret
|
||||||
|
sensitive = true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `var.domain` variable should already exist or be added alongside `var.cloudflare_account_id` in `providers.tf` / `variables.tf`.
|
||||||
|
|
||||||
|
After `tofu apply`, inject the secret into the Worker:
|
||||||
|
```sh
|
||||||
|
wrangler secret put TURNSTILE_SECRET_KEY
|
||||||
|
# paste the value from `tofu output -raw turnstile_secret_key`
|
||||||
|
```
|
||||||
|
|
||||||
|
**Validate:**
|
||||||
|
```sh
|
||||||
|
# From infra/ directory
|
||||||
|
tofu validate
|
||||||
|
tofu plan
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 2 — API: Verify Turnstile token in create handler
|
||||||
|
|
||||||
|
The API must verify the Turnstile token before creating a quote.
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
|
||||||
|
**`src/lib.rs` (or a new `turnstile` module in `src/bin/api/`):**
|
||||||
|
|
||||||
|
Add a `verify_turnstile(token: &str, secret: &str, remote_ip: Option<&str>) -> Result<bool, Error>` function that POSTs to `https://challenges.cloudflare.com/turnstile/v0/siteverify`.
|
||||||
|
|
||||||
|
**`quotesdb::CreateQuoteInput` in `src/lib.rs`:**
|
||||||
|
|
||||||
|
Add a `cf_turnstile_token: Option<String>` field. It is optional so that local/test environments can skip verification when no secret is configured.
|
||||||
|
|
||||||
|
**`src/bin/api/handlers/mod.rs` — `create_handler`:**
|
||||||
|
|
||||||
|
Before calling `repo.create_quote(input)`, check:
|
||||||
|
1. Read `TURNSTILE_SECRET_KEY` from the environment.
|
||||||
|
2. If the env var is set:
|
||||||
|
- Extract `cf_turnstile_token` from the request body.
|
||||||
|
- If the token is absent, return `400 Bad Request`.
|
||||||
|
- Call `verify_turnstile(token, secret, remote_ip)`.
|
||||||
|
- If verification fails, return `403 Forbidden` with `{"error": "CAPTCHA verification failed"}`.
|
||||||
|
3. If the env var is absent (local dev), skip verification.
|
||||||
|
|
||||||
|
**HTTP client:** Add `reqwest` (with `default-features = false, features = ["json"]`) as a non-wasm32 dependency for the Turnstile API call. On wasm32 the create handler does not exist, so no conflict.
|
||||||
|
|
||||||
|
**Important:** Strip `cf_turnstile_token` from the `CreateQuoteInput` before passing it to the repository — the DB doesn't store it.
|
||||||
|
|
||||||
|
**Validation:**
|
||||||
|
```sh
|
||||||
|
# From quotesdb/ root
|
||||||
|
cargo fmt && cargo check && cargo clippy && cargo test
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 3 — UI: Embed Turnstile widget in submit form
|
||||||
|
|
||||||
|
### `index.html`
|
||||||
|
|
||||||
|
Add the Turnstile JS script tag to the `<head>`:
|
||||||
|
```html
|
||||||
|
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
|
||||||
|
```
|
||||||
|
|
||||||
|
### `src/bin/ui/pages/submit.rs`
|
||||||
|
|
||||||
|
1. Add a `turnstile_token: UseStateHandle<Option<String>>` state handle.
|
||||||
|
2. Add the Turnstile widget div in the form, before the submit button:
|
||||||
|
```html
|
||||||
|
<div class="cf-turnstile"
|
||||||
|
data-sitekey="TURNSTILE_SITE_KEY_HERE"
|
||||||
|
data-callback="turnstile_callback">
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
The `data-callback` JS function name must be registered in `window`. Use `web_sys::window()` and `js_sys::Function` to expose a Rust closure that sets `turnstile_token` state.
|
||||||
|
3. Include the token in the `CreateQuoteInput` sent to the API.
|
||||||
|
|
||||||
|
**Site key:** The Turnstile site key is public and safe to hardcode in the UI source. Retrieve it from `tofu output -raw turnstile_site_key` after applying infra. Add a note in `docs/LOCAL_DEV.md` that local dev skips CAPTCHA (no env var set).
|
||||||
|
|
||||||
|
**Validation:**
|
||||||
|
```sh
|
||||||
|
cargo fmt && cargo check && cargo clippy && cargo test
|
||||||
|
trunk build
|
||||||
|
```
|
||||||
|
|
||||||
|
Manually verify: the submit form shows the Turnstile widget and submission is blocked if the challenge is not completed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files touched
|
||||||
|
|
||||||
|
- `infra/turnstile.tf` (new)
|
||||||
|
- `src/lib.rs` — `CreateQuoteInput` + `verify_turnstile`
|
||||||
|
- `src/bin/api/handlers/mod.rs` — `create_handler`
|
||||||
|
- `src/bin/ui/pages/submit.rs` — widget embed + token state
|
||||||
|
- `index.html` — Turnstile JS script
|
||||||
|
- `Cargo.toml` — `reqwest` dependency (non-wasm32)
|
||||||
|
- `api/openapi.yaml` — document `cf_turnstile_token` field
|
||||||
|
- `docs/LOCAL_DEV.md` — note on local dev CAPTCHA bypass
|
||||||
|
|
||||||
|
## Commit scope
|
||||||
|
|
||||||
|
`feat(quotesdb): Cloudflare Turnstile CAPTCHA on submit`
|
||||||
@ -0,0 +1,62 @@
|
|||||||
|
+++
|
||||||
|
title = "Fix compiler warnings in api and ui binaries"
|
||||||
|
priority = 7
|
||||||
|
status = "done"
|
||||||
|
ticket_type = "bug"
|
||||||
|
dependencies = []
|
||||||
|
+++
|
||||||
|
## Bug
|
||||||
|
|
||||||
|
Running `cargo build --target wasm32-unknown-unknown` (and `trunk build`) produces compiler warnings in both the `api` and `ui` binaries. All warnings should be resolved so the build is clean.
|
||||||
|
|
||||||
|
## Warnings
|
||||||
|
|
||||||
|
Reproduce with:
|
||||||
|
```sh
|
||||||
|
# From quotesdb/ root
|
||||||
|
cargo build --target wasm32-unknown-unknown 2>&1 | grep -E 'warning\[|warning: (fields|unused|duplicated)'
|
||||||
|
```
|
||||||
|
|
||||||
|
### UI binary (1 warning)
|
||||||
|
|
||||||
|
**`src/bin/ui/api.rs:21:9`** — `fields page and total_count are never read`
|
||||||
|
|
||||||
|
The `QuotesResponse` struct (or equivalent) has `page` and `total_count` fields that are deserialized from the API but never read by any UI code. Either:
|
||||||
|
- Remove the fields if they are genuinely unused, or
|
||||||
|
- Add `#[allow(dead_code)]` with a comment explaining they are reserved for future pagination UI, or
|
||||||
|
- Actually use them (e.g. pass `total_count` to the browse page for "X quotes total" display)
|
||||||
|
|
||||||
|
Preferred fix: use them or remove them. Avoid bare `#[allow(dead_code)]` without justification.
|
||||||
|
|
||||||
|
### API binary (2 warnings)
|
||||||
|
|
||||||
|
**`src/bin/api/db/d1.rs:9:8`** — `duplicated attribute`
|
||||||
|
|
||||||
|
A `#[cfg(...)]` or other attribute is duplicated on the same item. Remove the duplicate.
|
||||||
|
|
||||||
|
**`src/bin/api/db/mod.rs:27:9`** — `unused import: d1::D1Repository`
|
||||||
|
|
||||||
|
`D1Repository` is imported but never used in this module. Remove the import.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- `src/bin/ui/api.rs` (line 21)
|
||||||
|
- `src/bin/api/db/d1.rs` (line 9)
|
||||||
|
- `src/bin/api/db/mod.rs` (line 27)
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# From quotesdb/ root
|
||||||
|
cargo fmt && cargo check && cargo clippy && cargo test
|
||||||
|
|
||||||
|
# Confirm zero warnings for ui binary
|
||||||
|
cargo build --target wasm32-unknown-unknown 2>&1 | grep 'warning:.*generated'
|
||||||
|
# Expected: no output (or "0 warnings")
|
||||||
|
```
|
||||||
|
|
||||||
|
Also run `trunk build` and confirm no warnings are emitted for the `quotesdb` crate (dependency warnings from third-party crates are acceptable).
|
||||||
|
|
||||||
|
## Commit scope
|
||||||
|
|
||||||
|
`fix(quotesdb): resolve compiler warnings in api and ui`
|
||||||
@ -0,0 +1,68 @@
|
|||||||
|
+++
|
||||||
|
title = "Implement D1Repository for Cloudflare Workers"
|
||||||
|
priority = 7
|
||||||
|
status = "done"
|
||||||
|
ticket_type = "feature"
|
||||||
|
dependencies = []
|
||||||
|
+++
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Implement all 7 stub methods in `src/bin/api/db/d1.rs` so the API works against Cloudflare D1 in production.
|
||||||
|
|
||||||
|
## Changes Required
|
||||||
|
|
||||||
|
### `src/bin/api/db/d1.rs`
|
||||||
|
|
||||||
|
**1. Add unsafe Send/Sync** (wasm32 is single-threaded; safe in practice):
|
||||||
|
```rust
|
||||||
|
// SAFETY: wasm32-unknown-unknown is single-threaded.
|
||||||
|
unsafe impl Send for D1Repository {}
|
||||||
|
unsafe impl Sync for D1Repository {}
|
||||||
|
```
|
||||||
|
These are required so `D1Repository` satisfies the `Arc<dyn QuoteRepository + Send + Sync>` bound used in the Axum state.
|
||||||
|
|
||||||
|
**2. Helper row structs** (serde::Deserialize, field names = SQL column names):
|
||||||
|
- `QuoteRow { id, text, author, source: Option<String>, date: Option<String>, created_at, updated_at }`
|
||||||
|
- `AuthRow { auth_code: String }`
|
||||||
|
- `TagRow { tag: String }`
|
||||||
|
- `CountRow { count: u32 }`
|
||||||
|
|
||||||
|
**3. Helper method** `fetch_tags(&self, id: &str) -> Result<Vec<String>, DbError>`:
|
||||||
|
`SELECT tag FROM quote_tags WHERE quote_id = ?1 ORDER BY tag`, bind with `JsValue::from_str(id)`, deserialise as `Vec<TagRow>`.
|
||||||
|
|
||||||
|
**4. Implement each QuoteRepository method:**
|
||||||
|
|
||||||
|
- **run_migrations**: Call `self.db.exec()` for each migration constant from `migrations::` in sequence (CREATE_QUOTES, CREATE_QUOTE_TAGS, CREATE_TAG_INDEX, CREATE_AUTHOR_INDEX).
|
||||||
|
|
||||||
|
- **list_quotes(page, author, tag)**: Dynamic SQL with positional params (mirror native.rs logic). Run COUNT query for total_count, then data query with LIMIT 10 / OFFSET. Fetch tags per quote via helper. Page size = 10.
|
||||||
|
|
||||||
|
- **get_quote(id)**: `SELECT ... FROM quotes WHERE id = ?1`. Use `.first::<QuoteRow>(None)`. Fetch tags. Return `Ok(None)` if missing.
|
||||||
|
|
||||||
|
- **get_random_quote**: `SELECT ... FROM quotes ORDER BY RANDOM() LIMIT 1`. Use `.first::<QuoteRow>(None)`.
|
||||||
|
|
||||||
|
- **create_quote(input)**:
|
||||||
|
1. `generate_id()`, `auth_code = input.auth_code.unwrap_or_else(generate_auth_code)`
|
||||||
|
2. INSERT quotes row (bind NULL for optional source/date with `JsValue::NULL`)
|
||||||
|
3. Batch INSERT tags via `db.batch()`
|
||||||
|
4. SELECT back the row to get timestamps
|
||||||
|
5. Return `(quote, auth_code)`
|
||||||
|
|
||||||
|
- **update_quote(id, input, auth_code)**:
|
||||||
|
1. SELECT auth_code — return Forbidden on mismatch
|
||||||
|
2. Build dynamic SET clause for non-None fields + `updated_at = datetime('now')`
|
||||||
|
3. Execute UPDATE
|
||||||
|
4. If tags provided: DELETE existing, batch INSERT new
|
||||||
|
5. SELECT updated row; return it
|
||||||
|
|
||||||
|
- **delete_quote(id, auth_code)**:
|
||||||
|
1. SELECT auth_code — return NotFound if absent, Forbidden on mismatch
|
||||||
|
2. DELETE FROM quotes (tags cascade)
|
||||||
|
3. Return `DeleteResult::Deleted`
|
||||||
|
|
||||||
|
**JsValue bindings**: `JsValue::from_str(s)` for strings, `JsValue::from_f64(n as f64)` for integers, `JsValue::NULL` for SQL NULL.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
```sh
|
||||||
|
cargo build --release --bin api --target wasm32-unknown-unknown
|
||||||
|
cargo fmt && cargo check && cargo clippy && cargo test
|
||||||
|
```
|
||||||
@ -0,0 +1,103 @@
|
|||||||
|
+++
|
||||||
|
title = "quotesdb/ui: /admin page component"
|
||||||
|
priority = 5
|
||||||
|
status = "done"
|
||||||
|
ticket_type = "feature"
|
||||||
|
dependencies = ["161f32"]
|
||||||
|
+++
|
||||||
|
## /admin page component (UI)
|
||||||
|
|
||||||
|
Create the /admin route and page component. The page provides a persistent admin auth code input, an auth code reset section, and a submissions lock/unlock section.
|
||||||
|
|
||||||
|
Depends on ticket 161f32 (admin API client functions). Complete that ticket first.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files to create / modify
|
||||||
|
|
||||||
|
- `src/bin/ui/pages/admin.rs` — **create** the new page component
|
||||||
|
- `src/bin/ui/pages/mod.rs` — add `pub mod admin;`
|
||||||
|
- `src/bin/ui/main.rs` — add the `/admin` route to the Yew Router switch and add an "Admin" link to the top nav (or wherever the nav links are defined)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Component: AdminPage
|
||||||
|
|
||||||
|
The component holds the following local state:
|
||||||
|
|
||||||
|
| State field | Type | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `admin_code` | `String` | The persistent admin auth code input |
|
||||||
|
| `new_passphrase` | `String` | Optional input for reset section |
|
||||||
|
| `reset_result` | `Option<String>` | Newly returned auth code after reset |
|
||||||
|
| `reset_error` | `Option<String>` | Error message for the reset section |
|
||||||
|
| `submissions_locked` | `Option<bool>` | Current lock state, fetched on mount |
|
||||||
|
| `lock_error` | `Option<String>` | Error message for lock/unlock section |
|
||||||
|
| `loading` | `bool` | Disables buttons during in-flight requests |
|
||||||
|
|
||||||
|
### On mount
|
||||||
|
|
||||||
|
Call `api::get_status()` and set `submissions_locked` from the response.
|
||||||
|
|
||||||
|
### Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
[ Admin ] (page heading)
|
||||||
|
|
||||||
|
Admin auth code: [__________________] (persistent text input)
|
||||||
|
|
||||||
|
--- Reset auth code ---
|
||||||
|
New passphrase (optional): [__________]
|
||||||
|
[ Reset ]
|
||||||
|
> (success) New code: ocean-table-purple-storm
|
||||||
|
> (error) Wrong auth code.
|
||||||
|
|
||||||
|
--- Submissions ---
|
||||||
|
Status: Open | Closed
|
||||||
|
[ Lock submissions ] / [ Unlock submissions ]
|
||||||
|
> (error) Wrong auth code.
|
||||||
|
```
|
||||||
|
|
||||||
|
### Behaviour
|
||||||
|
|
||||||
|
- **Reset button:** calls `api::admin_reset_auth_code(&admin_code, new_passphrase_opt, &admin_code)`. On success, shows the new code in `reset_result` and clears `reset_error`. On `ApiError::Forbidden`, sets `reset_error = "Wrong auth code."`. On other errors, sets a generic message.
|
||||||
|
- **Lock button** (shown when `submissions_locked == Some(false)`): calls `api::admin_lock(&admin_code)`. On success, sets `submissions_locked = Some(true)`. On `ApiError::Forbidden`, sets `lock_error`.
|
||||||
|
- **Unlock button** (shown when `submissions_locked == Some(true)`): calls `api::admin_unlock(&admin_code)`. On success, sets `submissions_locked = Some(false)`. On `ApiError::Forbidden`, sets `lock_error`.
|
||||||
|
- While `loading = true`, disable all buttons.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Route registration (src/bin/ui/main.rs)
|
||||||
|
|
||||||
|
Add to the router switch:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
Route { path: "/admin".to_string(), render: ... }
|
||||||
|
// or whichever router pattern is already in use
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
No runtime unit tests (wasm-only). Verify the build:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo check --target wasm32-unknown-unknown --bin ui
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo fmt && cargo check --target wasm32-unknown-unknown --bin ui
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Commit
|
||||||
|
|
||||||
|
```
|
||||||
|
feat(quotesdb): /admin page component
|
||||||
|
```
|
||||||
@ -0,0 +1,2 @@
|
|||||||
|
* add an about page.
|
||||||
|
* add a footer to all pages that says something like "Contact quotes@elijah.run for any inquiries"
|
||||||
Loading…
Reference in New Issue