feat(pages): static-site host at pages.elijah.run (v0)

Micro static-site host running entirely on Cloudflare: a Rust->wasm
Worker (workers-rs 0.7 + axum 0.8) serving user-uploaded .html/.zip
content from R2 at pages.elijah.run/<slug>/, with D1 for users/
sessions/page metadata and GitHub OAuth for login. Admins approve
uploaders, delete pages, and can mark pages/users trusted.

Security design (docs/plans/2026-07-12-pages-design.md): hosted
content is served with a CSP sandbox (opaque origin — no cookies or
storage, cannot make credentialed /api calls) unless admin-trusted;
strict slug grammar + reserved names; streaming zip validation
(traversal, bombs, size/entry/depth limits); SHA-256-hashed session
tokens in __Host- cookies; Origin checks on all mutations; upload
quotas and size caps.

Includes OpenTofu infra (R2, D1, DNS; wrangler owns the Worker
deploy), an embedded single-file management UI, deployment guide,
and 148 native unit tests + 18 doctests (clippy -D warnings clean on
native and wasm32). Deployed and verified in production.

Squash of 15 commits from pages-v0: each ticket implemented by a
Claude Sonnet agent, reviewed by a Claude Opus agent, tracked with
beans (pages-lxjz..pages-shqc).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
main
Elijah Voigt 1 week ago
parent 814d1f50cb
commit d9c4830cf9

@ -12,6 +12,7 @@ A mono-repo of CLI tools and self-contained HTTP web services built in Rust, tar
| Service | Description | | Service | Description |
|---|---| |---|---|
| `common` | Shared library crate — types, utilities, and definitions used across services | | `common` | Shared library crate — types, utilities, and definitions used across services |
| `pages` | Micro static-site host at `pages.elijah.run` — upload an `.html`/`.zip`, served sandboxed at `/<name>/` (Cloudflare Workers + R2 + D1, Rust→wasm, GitHub OAuth) |
_Services will be listed here as they are added._ _Services will be listed here as they are added._

@ -0,0 +1,6 @@
beans:
path: .beans
prefix: pages-
id_length: 4
default_status: todo
default_type: task

@ -0,0 +1,15 @@
---
# pages-77lk
title: 'pages: static-site host on Cloudflare (v0)'
status: completed
type: epic
priority: normal
created_at: 2026-07-13T02:07:28Z
updated_at: 2026-07-13T22:38:04Z
---
Root epic for the v0 implementation of pages.elijah.run. Design + security analysis: docs/plans/2026-07-12-pages-design.md. All work happens on branch pages-v0, one commit per child bean.
## Summary of Changes
All 8 child beans completed on branch pages-v0, one reviewed commit each (Sonnet implementation agents, Opus review agents, review findings applied before every commit). Final state: 142 native unit tests + 17 doctests, clippy -D warnings clean on native and wasm32, tofu validate clean. Deployment is phase 2 — follow infra/README.md.

@ -0,0 +1,27 @@
---
# pages-adl9
title: Page content serving from R2
status: completed
type: feature
priority: normal
created_at: 2026-07-13T02:09:09Z
updated_at: 2026-07-13T17:11:23Z
parent: pages-77lk
blocked_by:
- pages-kns6
---
Implement design doc §4.7. src/routes/serve.rs (wasm-only): GET /:slug -> 301 /:slug/; GET /:slug/ -> R2 slug/index.html; GET /:slug/*path -> R2 slug/path, on miss retry slug/path/index.html, else minimal 404 HTML page. Invalid/reserved slugs 404. Every user-content response gets headers from core::headers: CSP sandbox (no allow-same-origin), X-Content-Type-Options nosniff, Referrer-Policy no-referrer, Cache-Control public max-age=300, Access-Control-Allow-Origin *, Content-Type from core::mime by extension. Route registration must not shadow /, /api, /auth. Path normalization logic (join, index fallback candidates) in core as pure function with native tests.
- [x] serve.rs handlers + router wiring (fallback route)
- [x] pure path-resolution helper in core + native tests
- [x] all §4.7 headers on every response incl. 404s for slugs
- [x] full validation suite passes
## Summary of Changes
R2 page serving by a Sonnet agent; Opus review APPROVE (no blockers, nits were doc-only and applied).
- src/core/serve_path.rs: pure candidate resolver (exact -> dir-index fallback) with traversal/length/shape rejection, 19 native tests.
- src/routes/serve.rs: /{slug} 301, /{slug}/ index, /{slug}/{*rest} assets; single apply_user_content_headers choke point puts CSP sandbox, nosniff, no-referrer, cache-control, ACAO * on every 200/301/404/500.
- core/headers.rs gained the ACAO constant.
Route precedence verified against matchit semantics; reserved slugs are a second 404 layer. 142 unit tests + 17 doctests green.

@ -0,0 +1,27 @@
---
# pages-ihlp
title: OpenTofu infra, deployment docs, project docs polish
status: completed
type: task
priority: normal
created_at: 2026-07-13T02:09:09Z
updated_at: 2026-07-13T22:38:04Z
parent: pages-77lk
blocked_by:
- pages-jaqz
---
infra/ per design §3/§4.4 mirroring ../quotesdb/infra style: providers.tf (cloudflare provider, variables for account_id/zone_id/api_token), r2.tf (pages bucket), d1.tf (pages database), dns.tf (pages.elijah.run record for worker custom domain or route), rate-limits.tf (rule on POST /api/* and /auth/*), every resource/data block commented. infra/README.md: apply order, wrangler deploy step, wrangler secret put GITHUB_CLIENT_SECRET, D1 schema apply command, GitHub OAuth app setup (callback URL). Update wrangler.toml with route/custom-domain + real binding placeholders and comments. tofu fmt + tofu validate (best effort if provider download blocked — say so). Update docs/PLANNING.md work log + README as needed.
- [x] infra/*.tf with comments
- [x] infra/README.md deployment guide
- [x] wrangler.toml finalized
- [x] tofu fmt/validate attempted, result recorded
- [x] PLANNING.md updated
## Summary of Changes
OpenTofu infra + deployment docs by a Sonnet agent; Opus review APPROVE with 2 should-fixes applied:
- infra/{providers,variables,r2,d1,dns,rate-limits}.tf (cloudflare ~> 4, AAAA 100:: placeholder + wrangler-owned route, http_ratelimit ruleset), infra/README.md end-to-end deploy guide, infra/.gitignore.
- Fixes: zone-entrypoint ruleset guidance corrected (one http_ratelimit ruleset per zone on every plan — merging with quotesdb's is mandatory when both apply); unused var.domain removed.
- tofu fmt -check + tofu validate pass; wrangler.toml left with placeholders pending real tofu apply.

@ -0,0 +1,26 @@
---
# pages-jaqz
title: Embedded management UI (static/index.html)
status: completed
type: feature
priority: normal
created_at: 2026-07-13T02:09:09Z
updated_at: 2026-07-13T17:22:22Z
parent: pages-77lk
blocked_by:
- pages-adl9
---
Single-file management UI per design §4.1/§5, served at GET / via include_str!. Vanilla HTML+CSS+JS inline, no external resources (would break under our own CSP? — the / route is NOT sandboxed; still keep it dependency-free). Features: fetch /api/pages and render list with links; fetch /api/me — show Login with GitHub (/auth/login) or user + Logout (POST /auth/logout); upload card (only when can_upload): file input + drag-drop for .html/.zip, name field defaulting to filename minus extension (mirror core::slug::default_slug_from_filename rules in JS), submit via fetch POST /api/pages?name=... with file as body and its Content-Type (application/zip or text/html), show success with link, 409 -> inline 'name taken' error keeping form state, 403 -> 'not authorized' warning; delete buttons on own pages (confirm()), admins see all-page delete + Users panel (list, can_upload checkboxes -> PATCH). Clean minimal styling, dark-mode friendly via prefers-color-scheme. Serve / with standard security headers (nosniff, no CSP sandbox, a strict same-origin CSP allowing inline script/style is fine).
- [x] static/index.html complete UI
- [x] GET / route with headers
- [x] name defaulting + error paths match API contract
- [x] full validation suite passes
## Summary of Changes
Single-file management UI (static/index.html, inline CSS/JS, no external resources) served at GET / via include_str!, by a Sonnet agent; Opus review APPROVE (no XSS, contract-faithful, CSP verified).
- List/login/logout, drag-drop upload with slug defaulting mirroring core::slug, 201/409/403/401/other handling, owner/admin delete, lazy admin users panel.
- Strict non-sandbox CSP on / (inline allowed, connect/img/frame-ancestors locked down) + nosniff/no-referrer/no-store.
- Post-review polish: client-side 10 MiB pre-check, dead el() attrs branch removed.

@ -0,0 +1,25 @@
---
# pages-jgc4
title: D1 schema and query layer
status: completed
type: feature
priority: normal
created_at: 2026-07-13T02:09:08Z
updated_at: 2026-07-13T02:57:07Z
parent: pages-77lk
blocked_by:
- pages-saom
---
migrations/schema.sql exactly per design doc §4.2 (users, sessions, pages). src/db.rs (wasm-only): thin typed wrappers over worker d1 — upsert_user_on_login(github_id, login, avatar_url, admin_login_match) handling ADMIN_GITHUB_LOGIN auto-promotion (design §2.4), get_user_by_session(token_hash) with expiry check, create_session/delete_session, list_pages (join owner login), get_page, count_pages_for_user, insert_page (surface PK conflict as typed NameTaken error), delete_page, list_users, set_can_upload. Row structs with serde.
- [x] migrations/schema.sql
- [x] src/db.rs typed query layer
- [x] wasm32 check + clippy pass; native suite unaffected
## Summary of Changes
migrations/schema.sql (idempotent, indexed) + src/db.rs typed D1 query layer (wasm-only), by a Sonnet agent. Opus review: REQUEST-CHANGES then fixed:
- BLOCKER: session expiry compare now normalizes both sides via datetime() (lexicographic ISO-T/Z trap removed); create_session param/doc contract fixed.
- NIT: NameTaken detection tightened to 'unique' matches only (real FK on pages.owner_id would have misclassified as 409).
Validation green on both targets; native suite unaffected (72 tests + 4 doctests).

@ -0,0 +1,29 @@
---
# pages-kns6
title: Pages and users JSON API
status: completed
type: feature
priority: normal
created_at: 2026-07-13T02:09:09Z
updated_at: 2026-07-13T16:47:43Z
parent: pages-77lk
blocked_by:
- pages-zd46
---
Implement design doc §4.1 API rows + §4.6 upload pipeline. src/routes/api.rs (wasm-only): GET /api/me; GET /api/pages (public); POST /api/pages?name= — auth (401), can_upload (403), Origin check vs BASE_URL (403), slug validation/reserved (400) via core, quota 50 pages/user (403), Content-Length + buffered size cap 10 MiB (413), core::upload::build_manifest by Content-Type text/html vs application/zip (400 on other types), D1 insert first (409 NameTaken), then R2 puts under slug/ with best-effort rollback on failure (500); DELETE /api/pages/:slug — owner or admin, delete R2 prefix (list+delete loop) then D1 row; GET /api/users + PATCH /api/users/:id {can_upload} — admin only. All errors JSON {"error": "..."} with correct status. Wire into router. Keep handlers thin; any new pure logic goes in core with native tests.
- [x] /api/me, GET /api/pages
- [x] POST /api/pages full pipeline in §4.6 order
- [x] DELETE /api/pages/:slug
- [x] admin user endpoints
- [x] Origin-check helper + native tests where pure
- [x] full validation suite passes
## Summary of Changes
Full JSON API by a Sonnet agent; Opus review APPROVE with fixes applied:
- src/routes/api.rs: /api/me, GET/POST /api/pages (§4.6 pipeline: auth, can_upload, Origin, slug, quota, size caps, manifest, D1-first slug reservation, R2 puts with rollback), DELETE with paginated R2 prefix cleanup, admin /api/users GET/PATCH.
- src/core/origin.rs: origin_allowed + can_delete pure helpers (12 tests + 2 doctests).
- Review fixes: DefaultBodyLimit::max(10 MiB) instead of disable() (memory bounded at body-collection time); DELETE Origin check hoisted before DB lookups per §4.6.
122 unit tests + 16 doctests green; both targets clippy-clean.

@ -0,0 +1,25 @@
---
# pages-lxjz
title: Scaffold worker crate (hello world builds)
status: completed
type: task
priority: normal
created_at: 2026-07-13T02:07:52Z
updated_at: 2026-07-13T02:18:40Z
parent: pages-77lk
---
Create the pages crate skeleton per design doc §4.5 and quotesdb patterns: Cargo.toml (cdylib+rlib, worker 0.7 with d1+http features, axum 0.8 no-default-features json+query, tower-service, http, serde/serde_json, thiserror, zip no-default-features deflate, getrandom wasm_js — mirror ../quotesdb/Cargo.toml target-gating), src/lib.rs with wasm-only #[worker::event(fetch)] returning a placeholder response via an axum Router, wrangler.toml (name=pages, worker-build, D1 binding DB, R2 binding PAGES, vars BASE_URL/GITHUB_CLIENT_ID/ADMIN_GITHUB_LOGIN), .dev.vars.example, release profile size opts.
- [x] Cargo.toml with target-gated deps and release profile
- [x] src/lib.rs hello-world axum router behind cfg(wasm32)
- [x] wrangler.toml + .dev.vars.example
- [x] cargo check --target wasm32-unknown-unknown passes
- [x] cargo check + clippy (both targets) + test pass
## Summary of Changes
Crate skeleton implemented by a Sonnet agent, reviewed by an Opus agent (verdict: APPROVE).
- Cargo.toml (target-gated deps mirroring quotesdb), src/lib.rs wasm-only axum fetch glue, src/tests.rs, wrangler.toml (DB/PAGES bindings, vars), .dev.vars.example.
- Review fixes applied: dropped dead duplicate getrandom 0.3 dep and corrected its comment; added commented [[routes]] placeholder for pages.elijah.run to wrangler.toml.
- Validation green on both targets (fmt, check, clippy -D warnings, test).

@ -0,0 +1,32 @@
---
# pages-saom
title: 'Core policy modules: slug, mime, upload validation, headers'
status: completed
type: feature
priority: normal
created_at: 2026-07-13T02:09:08Z
updated_at: 2026-07-13T02:42:56Z
parent: pages-77lk
blocked_by:
- pages-lxjz
---
Implement src/core/ per design doc §2.2, §2.3, §2.6, §4.5 — pure Rust, no worker deps, compiles + unit-tested natively.
slug.rs: validate_slug() enforcing ^[a-z0-9][a-z0-9-]{0,62}$ after lowercasing, reserved-name list from design §2.3, and default_slug_from_filename() (my-project.zip -> my-project, lowercased, invalid chars -> hyphen, collapse repeats/trim).
mime.rs: extension->MIME allowlist (html, htm, css, js, mjs, json, txt, md, png, jpg, jpeg, gif, svg, webp, avif, ico, woff, woff2, ttf, otf, wasm, map, xml, webmanifest, mp3, mp4, webm, pdf); unknown -> application/octet-stream.
upload.rs: UploadLimits (10 MiB compressed, 25 MiB uncompressed total, 5 MiB per file, 300 entries, path len 180, depth 10) and build_manifest(kind, bytes) -> Result<Vec<(String, Vec<u8>)>, UploadError>. Html kind -> single index.html entry. Zip kind: use zip crate, reject path traversal (.. segments, absolute, backslash), reject duplicates, skip directories, enforce limits while decompressing (count streamed bytes, abort on breach), require root index.html. Typed UploadError with user-facing Display messages.
headers.rs: constants for CSP sandbox value (design §2.1), nosniff, referrer-policy, cache-control.
- [x] slug.rs + tests (valid/invalid/reserved/default-name derivation)
- [x] mime.rs + tests
- [x] upload.rs + tests (traversal, bomb via high-ratio zip, dup entries, missing index.html, happy paths html+zip) using zip writer in tests to build fixtures
- [x] headers.rs + tests
- [x] full validation suite passes (both targets)
## Summary of Changes
src/core/ implemented by a Sonnet agent, adversarial Opus review (verdict: APPROVE, no bypasses found).
- slug.rs (grammar + 17 reserved names + default_slug_from_filename), mime.rs (allocation-free allowlist), upload.rs (streaming zip validation: traversal, bombs, limits; html manifest), headers.rs (CSP sandbox etc.).
- 72 native unit tests + 4 doctests incl. crafted-zip attack fixtures and accept-at-limit boundary cases (added post-review).
- Design doc §2.2 wording reconciled (path length is bytes).

@ -0,0 +1,39 @@
---
# pages-shqc
title: 'Per-page and per-user trust: opt out of CSP sandbox'
status: completed
type: feature
priority: normal
created_at: 2026-07-13T23:44:29Z
updated_at: 2026-07-13T23:58:27Z
---
Allow admin-designated trusted pages/users whose content is served WITHOUT the CSP sandbox (design §2.1), so local-first apps can use localStorage. Effective trust = pages.trusted OR users.trusted (owner), computed at serve time via D1 join.
Scope:
- migrations: add users.trusted + pages.trusted (INTEGER NOT NULL DEFAULT 0); base schema.sql updated for fresh installs + incremental 0002 migration for live DB
- db.rs: trusted fields on User/PageRow, get_page joins owner trust, set_page_trusted, set_user_trusted
- api.rs: PATCH /api/pages/{slug} {trusted} (admin+Origin), PATCH /api/users/{id} accepts {can_upload?, trusted?}
- serve.rs: D1 lookup first (404 for unknown slug before R2), conditional sandbox header
- core: pure helper for conditional header set + tests
- UI: admin trust toggles (users panel + per-page), trusted badge
- docs: design §2.1 addendum — trusted content can act as any visitor incl. admins; trust only content you authored
- [x] schema + migration
- [x] db.rs trust support
- [x] api endpoints
- [x] serve-time conditional sandbox
- [x] core helper + tests
- [x] UI toggles/badge
- [x] docs updated
- [x] full validation suite green
## Summary of Changes
Per-page + per-user trust escape hatch by a Sonnet agent; Opus production-rigor review: APPROVE, no blockers.
- Effective trust = pages.trusted OR owner users.trusted, computed via live join at serve time; only the 200 content path can drop the sandbox — 301/404/500 and all error branches stay sandboxed (fail-safe verified).
- Trust settable only by admins (Origin-checked PATCH /api/pages/{slug} + extended PATCH /api/users/{id}); login upsert can never touch it.
- migrations/0002-trust.sql for the live DB (must run BEFORE deploy — old columns missing → 500s, review-confirmed fail-safe but an outage).
- UI: trusted badges + admin toggles; docs §2.1 addendum with the explicit trusted-content warning.
- Review hygiene fix: tfplan* gitignored and removed from the tree.
148 unit tests + 18 doctests green; both targets clippy-clean.

@ -0,0 +1,27 @@
---
# pages-zd46
title: GitHub OAuth, sessions, auth middleware
status: completed
type: feature
priority: normal
created_at: 2026-07-13T02:09:08Z
updated_at: 2026-07-13T16:29:44Z
parent: pages-77lk
blocked_by:
- pages-jgc4
---
Implement design doc §2.4 + §4.1 auth rows. src/auth.rs: pure helpers compiled on both targets — random 32-byte hex token gen (getrandom), sha256 hex (add sha2 crate, works on wasm), cookie header building/parsing (__Host-session HttpOnly Secure SameSite=Lax Path=/ Max-Age 30d; state cookie 10min), GitHub authorize URL builder — all native-tested. src/routes/auth.rs (wasm-only): GET /auth/login (state cookie + redirect), GET /auth/callback (verify state, exchange code via worker Fetch to github.com with Accept: application/json + User-Agent header, fetch /user, upsert user via db.rs, create session, redirect /), POST /auth/logout (delete session, clear cookie). Request extension/helper current_user(env, req) for other routes. Wire routes into lib.rs router.
- [x] auth.rs pure helpers + native tests (cookie parse/build, authorize URL, token/hash)
- [x] routes/auth.rs OAuth flow
- [x] current_user session resolution helper
- [x] full validation suite passes
## Summary of Changes
GitHub OAuth + sessions by a Sonnet agent (resumed once after a session-limit interruption; #[worker::send] fixed the axum Send bound). Opus security review: APPROVE.
- src/auth.rs: token gen (getrandom), sha256_hex, __Host- cookie build/parse (zero-alloc), authorize URL with hand-rolled percent-encoding, hand-implemented civil_from_days expiry formatting — 38 new unit tests + 9 doctests.
- src/routes/{mod,auth}.rs: login/callback/logout, state-cookie CSRF binding, hashed session storage, generic 502s (no secret leakage), current_user helper. AppState (Db + Config) via with_state.
- Review fix applied: admin login comparison now eq_ignore_ascii_case (GitHub logins are case-insensitive).
110 unit tests + 14 doctests green; both targets clippy-clean.

@ -0,0 +1,17 @@
# Example local dev vars for the pages Worker.
# Copy to `.dev.vars` (git-ignored) and fill in real values for local
# `wrangler dev` runs. Never commit the real `.dev.vars` file.
# Public base URL used for OAuth redirects and the Origin check on mutating
# API routes. Local dev typically uses http://localhost:8787.
BASE_URL=http://localhost:8787
# GitHub OAuth App client ID (public).
GITHUB_CLIENT_ID=
# GitHub OAuth App client secret. In production this is a wrangler secret
# (`wrangler secret put GITHUB_CLIENT_SECRET`); for local dev it goes here.
GITHUB_CLIENT_SECRET=
# GitHub login that is auto-promoted to admin on first login.
ADMIN_GITHUB_LOGIN=

9
pages/.gitignore vendored

@ -0,0 +1,9 @@
/target
/build
.env
.dev.vars
.wrangler/
infra/.terraform/
infra/*.tfstate*
infra/.terraform.lock.hcl
infra/tfplan*

@ -0,0 +1,64 @@
@../CLAUDE.md
# CLAUDE.md — pages
`pages` is a micro static-site host: upload an `.html` or `.zip`, get it
served at `pages.elijah.run/<slug>/`. One Cloudflare Worker (Rust → wasm via
workers-rs + axum), an R2 bucket for content, a D1 database for
users/sessions/page metadata, GitHub OAuth for login.
**Design reference (read first):** `docs/plans/2026-07-12-pages-design.md`
<working-directory>
Run all commands from `pages/` (or `pages/infra/` for OpenTofu).
</working-directory>
<beans>
Beans are scoped to this directory — run `beans` commands from `pages/`.
</beans>
<deviations>
## Deviations from repo defaults (deliberate, see design doc §3)
- **No SQLx** — SQLx cannot run on Cloudflare Workers; D1 access uses the
`worker` crate bindings directly.
- **No Yew/Trunk frontend** — v0 ships a single `static/index.html`
(inline CSS/JS) embedded in the Worker via `include_str!`.
- **Worker deploys via `wrangler deploy`**, not OpenTofu (Terraform provider
can't upload module Workers with wasm). OpenTofu owns R2/D1/DNS/rate
limits in `infra/`.
</deviations>
<validation>
## Validation (run from `pages/`, in order)
```sh
cargo fmt
cargo check --target wasm32-unknown-unknown # worker code is wasm-only
cargo check # native (pure logic modules)
cargo clippy --all-targets -- -D warnings
cargo clippy --target wasm32-unknown-unknown -- -D warnings
cargo test # native unit tests
```
For infra changes (from `pages/infra/`): `tofu fmt` and `tofu validate`.
</validation>
<wasm-notes>
## wasm / workers-rs notes
- Follow the `../quotesdb` pattern for the `#[worker::event(fetch)]` + axum
glue (`quotesdb/src/lib.rs` bottom) and for `Cargo.toml` target-gated
dependencies (getrandom `wasm_js`, worker `http` feature, tower-service).
- Keep all policy logic (slug/zip/MIME/cookie parsing) in pure modules that
compile natively so `cargo test` covers them; wasm-only code
(`cfg(target_arch = "wasm32")`) should be thin glue.
- Workers wasm is single-threaded: `unsafe impl Send/Sync` on Arc-wrapped
state wrappers is the accepted pattern (as in quotesdb).
</wasm-notes>

1118
pages/Cargo.lock generated

File diff suppressed because it is too large Load Diff

@ -0,0 +1,54 @@
[package]
name = "pages"
version = "0.1.0"
edition = "2021"
license = "MIT OR Apache-2.0"
[lib]
crate-type = ["cdylib", "rlib"]
# Shared dependencies: compile for both native host and wasm32-unknown-unknown.
[dependencies]
# Serialisation for API request/response payloads and D1 row (de)serialisation.
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# Error types with Display/std::error::Error derive — works on both native and wasm32.
thiserror = "2"
# Zip reading for upload validation (core::upload); deflate is the only
# compression method we need to support. Pure Rust, compiles on wasm32.
zip = { version = "2", default-features = false, features = ["deflate"] }
# SHA-256 for session token hashing (auth); pure Rust, compiles on wasm32.
sha2 = "0.10"
# Entropy for session/state token generation (auth.rs). Declared unconditionally
# (no features) so `generate_token` compiles and is unit-testable on the native
# host target too; the wasm_js feature required for wasm32-unknown-unknown is
# layered on in the wasm-only dependency table below — Cargo unifies the two
# declarations of the same crate/version, adding the feature only on that
# target.
getrandom = "0.4"
# WASM-only dependencies (Workers entry point).
# workers-rs, getrandom/wasm_js, and the axum Workers glue are wasm32-specific.
[target.'cfg(target_arch = "wasm32")'.dependencies]
# Cloudflare Workers SDK — provides D1 bindings, fetch, KV, etc.
# The `http` feature makes the fetch handler accept/return standard http types,
# enabling direct Axum router integration without manual request conversion.
worker = { version = "0.7", features = ["d1", "http"] }
# Axum HTTP framework for the WASM Workers entry point — minimal feature set
# (no full tokio runtime needed; json and query features required for handlers).
axum = { version = "0.8", default-features = false, features = ["json", "query"] }
# Tower service trait — required by the WASM entry point to call the Axum router.
tower-service = "0.3"
# http crate — re-exported by axum and worker; needed explicitly so the return
# type `http::Response<axum::body::Body>` resolves in the fetch event handler.
http = "1"
# Enables the wasm_js feature of the unconditional `getrandom` dependency
# above (see its comment) — routes entropy to crypto.getRandomValues() via
# the Web Crypto API, required for wasm32-unknown-unknown.
getrandom = { version = "0.4", features = ["wasm_js"] }
[profile.release]
opt-level = "z"
lto = true
strip = true
codegen-units = 1

@ -0,0 +1,71 @@
# pages
A micro static-site host at `pages.elijah.run`, running entirely on
Cloudflare (Workers + R2 + D1) with the Worker written in Rust compiled to
WebAssembly.
## What it does
- Browse all live pages at `pages.elijah.run`.
- Log in with GitHub, upload a single `.html` file or a `.zip` of static
assets (drag & drop), pick a name, and the content is served at
`pages.elijah.run/<name>/`.
- Admins approve which users may upload, and can delete any page.
Hosted pages are intended to be **client-side-only single-page apps**. They
are served with a sandboxing Content-Security-Policy (opaque origin): no
cookies, no localStorage — see `docs/plans/2026-07-12-pages-design.md` §2.1.
An admin can mark a specific page, or all of a user's pages, as **trusted**
to opt them out of that sandbox (e.g. for local-first apps that need
`localStorage`) — see the "Trust escape hatch" addendum in §2.1 for the
exact semantics and the security trade-off that comes with it.
## How it works
One Cloudflare Worker routes everything:
- `/` — embedded management UI (list, login, upload, admin)
- `/auth/*` — GitHub OAuth + session cookies (sessions in D1, hashed)
- `/api/*` — JSON API (list/upload/delete pages, manage users)
- `/<slug>/*` — page content served from R2 with strict security headers
Uploads are validated hard: strict slug grammar + reserved names, zip
path-traversal rejection, decompression limits (10 MiB compressed,
25 MiB uncompressed, 300 files). See the design doc for the full security
analysis.
## Run locally
```sh
# from pages/
cp .dev.vars.example .dev.vars # fill in a GitHub OAuth app's id/secret
wrangler d1 execute pages --local --file migrations/schema.sql
wrangler dev # builds via worker-build and serves locally
```
## Test
```sh
# from pages/
cargo fmt
cargo check --target wasm32-unknown-unknown
cargo clippy --all-targets -- -D warnings
cargo test
```
## Deploy
Infrastructure (R2 bucket, D1 database, DNS, rate limits) is OpenTofu in
`infra/`; the Worker itself deploys with `wrangler deploy` (see
`infra/README.md`). Secrets via `wrangler secret put GITHUB_CLIENT_SECRET`.
## License
Dual-licensed under [Apache-2.0](../LICENSE-APACHE) and
[MIT](../LICENSE-MIT), like the rest of this repository.
## Disclaimer
This software was written with Claude Code. Design, review and orchestration
by Claude Fable 5 (`claude-fable-5`); implementation by Claude Sonnet
agents with Claude Opus review agents.

@ -0,0 +1,41 @@
# pages — Architecture
Full design + security analysis: `plans/2026-07-12-pages-design.md`.
## Components
```
┌─────────────────────────────────────────┐
browser ──────────▶ │ Cloudflare Worker (Rust → wasm, axum) │
│ │
GET / │ routes/mod.rs router construction │
GET /auth/* │ routes/auth.rs GitHub OAuth, sessions │──▶ github.com (token exchange)
* /api/* │ routes/api.rs pages + users JSON API │──▶ D1 `DB` (users/sessions/pages)
GET /<slug>/* │ routes/serve.rs page content serving │──▶ R2 `PAGES` (<slug>/<path>)
│ │
│ core/ pure policy logic (native- │
│ tested): slug, mime, upload │
│ validation, security headers │
└─────────────────────────────────────────┘
```
## Component interactions
- **core/** has no Cloudflare dependencies; it compiles natively and holds
every security-relevant decision (slug grammar, reserved names, zip
limits, MIME allowlist, CSP header values). Unit tests live here.
- **db.rs** (wasm-only) wraps D1 queries; **routes/** (wasm-only) are thin
axum handlers that call core + db + R2.
- **auth.rs** builds OAuth URLs and cookie/session token logic (pure parts
native-tested); the GitHub HTTP calls are wasm-only.
- **static/index.html** is the whole management UI, embedded at compile time
with `include_str!` and served at `/`.
## Trust boundaries
1. **Uploaded content** is untrusted: validated in core/upload.rs, stored in
R2, and always served with a sandboxing CSP (opaque origin) so it can
never make credentialed calls to `/api`.
2. **Browser → API**: session cookie (`__Host-`, HttpOnly, SameSite=Lax,
hashed in D1) + Origin check on mutating routes.
3. **Worker → GitHub**: client secret only ever used server-side.

@ -0,0 +1,48 @@
# pages — Planning
## Phases
| Phase | Content | Status |
|---|---|---|
| 0 | Security analysis + design (`plans/2026-07-12-pages-design.md`) | done |
| 1 | v0 implementation on branch `pages-v0` (beans below) | done |
| 2 | Deploy + manual QA against real Cloudflare account | deployed; manual QA (login/upload/delete) pending |
| 3 | v1 candidates: per-page subdomains, page overwrite, Yew UI, OpenAPI | not started |
## v0 tickets (beans, serial)
1. scaffold — crate skeleton, wrangler.toml, hello-world worker builds
2. core — slug/mime/upload/header policy modules + unit tests
3. db — D1 schema + query layer
4. auth — GitHub OAuth, sessions, auth middleware
5. api — pages + users JSON API
6. serve — page content serving from R2
7. frontend — embedded management UI
8. infra — OpenTofu + deployment docs
## Work log
- **2026-07-12** — Project initialized. Security analysis and v0 design
written; beans created; implementation pipeline started (Sonnet
implementation agents, Opus review agents, one commit per ticket).
- **2026-07-13** — infra ticket (pages-ihlp) done: `infra/*.tf` (R2 bucket,
D1 database, DNS placeholder record, rate limiting ruleset) mirroring
`../quotesdb/infra` style, `infra/README.md` deployment guide, `tofu fmt`
+ `tofu validate` passing.
- **2026-07-13** — v0 implementation complete: all 8 beans done on
`pages-v0`, one reviewed commit per ticket (Sonnet implementation, Opus
review). 142 unit tests + 17 doctests; both-target clippy clean. Next:
phase 2 (real deploy + manual QA per `infra/README.md`).
- **2026-07-13** — Deployed. tofu applied R2 + D1 + DNS (rate-limit ruleset
deliberately NOT applied — quotesdb's live http_ratelimit ruleset owns
the zone's single slot; merge required if wanted). Remote schema applied;
GITHUB_CLIENT_SECRET secret set; route pages.elijah.run/* live. Smoke
tests pass (/, /api/me, /auth/login 302, reserved + unknown slugs 404
with sandbox CSP). Pending manual QA: GitHub login as admin, upload,
view, delete.
- **2026-07-13** — pages-shqc: per-page/per-user "trusted" escape hatch from
the CSP sandbox (`pages.trusted OR owner's users.trusted`, computed at
serve time). `migrations/0002-trust.sql` added for the live DB (not yet
applied remotely — manual step); schema.sql, db.rs, api.rs, serve.rs,
core/headers.rs, and the admin UI all updated; 148 unit + 18 doctests,
both-target clippy clean.

@ -0,0 +1,283 @@
# pages.elijah.run — Design & Security Analysis
**Date:** 2026-07-12
**Status:** Approved for v0 implementation (branch `pages-v0`)
## 1. What it is
A micro static-site host running entirely on Cloudflare:
- Anyone can browse `pages.elijah.run` and see all live pages.
- Authenticated + authorized users upload a single `.html` file or a `.zip`
of static assets; the content is served at `pages.elijah.run/<slug>/`.
- Admins can delete any page and grant/revoke upload permission for users.
- Auth is GitHub OAuth (free, no infrastructure of our own).
Components: one Cloudflare Worker (Rust → wasm via workers-rs), one R2 bucket
(page content), one D1 database (users, sessions, page metadata).
## 2. Security analysis
### 2.1 Hosted pages share the origin with the management app (critical)
User-uploaded HTML served from `pages.elijah.run/<slug>/` is **same-origin**
with the upload/admin UI at `pages.elijah.run/`. Untrusted JS on the same
origin could otherwise make credentialed `fetch("/api/…")` calls (delete
pages, admin actions) — the session cookie would be attached automatically.
**Mitigations (layered):**
1. **CSP sandbox — primary.** Every response serving user content carries
`Content-Security-Policy: sandbox allow-scripts allow-forms allow-modals allow-popups allow-pointer-lock`
— critically **without** `allow-same-origin`. The browser gives the
document an *opaque origin*: no cookies are sent on its requests, no
localStorage/IndexedDB, no same-origin DOM access. The session cookie is
therefore never attached to requests initiated by hosted pages.
2. **HttpOnly session cookie** — not readable from JS even if sandbox were
bypassed.
3. **Origin check on all mutating API routes**`POST`/`PATCH`/`DELETE`
require an `Origin` header equal to the configured `BASE_URL`. Sandboxed
documents send `Origin: null`.
**Accepted residual risks:** hosted pages can still phish (render a fake
login form), display arbitrary content under the elijah.run name, and cannot
use cookies/localStorage themselves (documented limitation of hosting here).
A stronger isolation (per-page subdomains, `<slug>.pages.elijah.run`) is the
v1 upgrade path if these limits bite.
**Trust escape hatch (v0.1, pages-shqc).** Some hosted pages are legitimately
local-first apps (a habit tracker, a notes app) that need `localStorage` to
be useful at all — the CSP sandbox above denies that categorically. An admin
can mark either a specific page (`pages.trusted`) or a user
(`users.trusted`) as *trusted*; a page's **effective trust** is
`pages.trusted OR <owner's> users.trusted`, computed at serve time via a D1
join (`src/db.rs::PageRow::effective_trust`). A trusted page is served with
every header above *except* `Content-Security-Policy` — no sandbox, so the
document keeps its real `pages.elijah.run` origin.
**This is a deliberate, explicit downgrade of the §2.1 mitigation for that
one page**, not a separate isolation mechanism: a trusted page runs
same-origin with the management app and therefore *can* act as any visitor
of `pages.elijah.run`, including a logged-in admin — it can issue
credentialed `fetch("/api/…")` calls (delete pages, change permissions,
grant/revoke trust on other pages) using whatever session cookie happens to
be present in the browser that loads it. Trusting a page is therefore
equivalent to giving its author the same access as anyone who ever visits
that page while signed in. **Only content authored by trusted humans should
ever be marked trusted** — never grant it to satisfy a feature request from
an unknown or unvetted uploader. Marking a *user* trusted (rather than a
single page) extends this to every page they upload, present and future,
so it should be granted even more sparingly than a single-page grant.
### 2.2 Malicious archives
- **Path traversal:** zip entry names like `../../x`, absolute paths (`/x`,
`C:\x`), backslash separators, or `..` segments anywhere → reject upload.
- **Zip bombs:** enforce **before/while extracting**: max compressed upload
10 MiB (checked via `Content-Length` and while buffering), max total
uncompressed 25 MiB, max 5 MiB per file, max 300 entries, max path length
180 bytes, max depth 10. Counting is done on decompressed bytes as they
stream out, aborting on breach — never trust zip headers alone.
- **Weird entries:** directories are skipped, symlinks don't exist for us
(we never touch a filesystem — entries go straight to R2), duplicate entry
names → reject, nested zips are stored as opaque files, never recursed.
### 2.3 Slug (page name) abuse
- Strict slug grammar: `^[a-z0-9][a-z0-9-]{0,62}$` (lowercase alphanumerics
and hyphens, no leading hyphen, ≤ 63 chars). Uploaded names are
lower-cased and validated, never "cleaned up" silently beyond that.
- **Reserved names** rejected to avoid route collisions and confusion:
`api`, `auth`, `admin`, `assets`, `static`, `login`, `logout`, `oauth`,
`favicon.ico`, `robots.txt`, `sitemap.xml`, `index.html`, `www`, `health`,
`status`, `well-known`, `cdn-cgi` (Cloudflare-internal path prefix).
- Uniqueness enforced by the D1 primary key; duplicate → HTTP 409 with a
JSON error so the UI can prompt for a new name.
### 2.4 Authentication & sessions
- **GitHub OAuth (authorization-code flow).** `state` is 32 random bytes,
stored in a short-lived (10 min) `HttpOnly` cookie and compared at the
callback — CSRF protection for the OAuth flow itself.
- **Sessions:** 32 random bytes (hex) issued as `__Host-session` cookie with
`HttpOnly; Secure; SameSite=Lax; Path=/`; only the **SHA-256 hash** is
stored in D1 (a D1 read leak doesn't yield usable tokens). 30-day expiry,
checked server-side; logout deletes the row.
- **CSRF on the API:** `SameSite=Lax` (cross-site POSTs don't carry the
cookie) + the Origin check from §2.1.
- **Authorization model:** `users.can_upload` gates uploads (default *off*
new OAuth users can log in but not publish until an admin approves);
`users.is_admin` gates delete-any-page and user management; page owners
can delete their own pages. Admin bootstrap: the `ADMIN_GITHUB_LOGIN`
Worker var — a user logging in with that GitHub login is auto-promoted.
### 2.5 Resource abuse / denial-of-wallet
- Upload size caps (§2.2) and a **per-user page quota** (default 50).
- Cloudflare rate-limiting rule on `POST /api/*` and `/auth/*` (infra).
- R2/D1 are pay-per-use with generous free tiers; caps above keep worst-case
storage bounded (50 pages × 25 MiB per user, and uploads are gated by an
admin-approved allowlist anyway).
### 2.6 Content-type handling
- MIME types derived **only from file extension** via an allowlist map;
unknown extensions are served as `application/octet-stream`.
- `X-Content-Type-Options: nosniff` on every response.
- Only `.html`/`.htm` are ever served as `text/html`.
### 2.7 Secrets
- `GITHUB_CLIENT_SECRET`, `SESSION_SECRET` (reserved for future signed
values) via `wrangler secret put`; never in the repo. Local dev uses
`.dev.vars` / `.env` (git-ignored).
## 3. Implementation issues identified
| Issue | Resolution |
|---|---|
| **SQLx doesn't run on Workers** (repo default data pattern) | Use `worker` crate D1 bindings directly; documented deviation in `pages/CLAUDE.md`. |
| **workers-rs JS types are `!Send`**, axum wants `Send` handlers | Follow the proven `quotesdb` pattern: build the axum `Router` per-request inside `#[event(fetch)]`, share state via `Arc` with `unsafe impl Send + Sync` (sound: Workers wasm is single-threaded). |
| **Multipart parsing in wasm is fiddly** | Upload is a raw request body: `POST /api/pages?name=<slug>` with `Content-Type: application/zip` or `text/html`. The browser `fetch(file)` does this natively. |
| **Workers free tier: 10 ms CPU/request** | Deflate of a 10 MiB zip can exceed this. Limits are conservative; if uploads hit CPU kills, the $5/mo paid tier (30 s CPU) is the fallback. Serving is I/O-bound and unaffected. |
| **Entropy on wasm32** | `getrandom` with the `wasm_js` feature (same versions/pattern as `quotesdb`, which builds without extra RUSTFLAGS). |
| **Repo default frontend is Yew/Trunk** | v0 ships a single static `index.html` (inline CSS/JS) embedded with `include_str!` — one file, no extra build pipeline. Yew rewrite is possible later without API changes. Documented deviation. |
| **Native testability of a wasm-only worker** | Crate is `cdylib + rlib`; all policy logic (slugs, zip validation, MIME, cookies/auth helpers) lives in pure modules compiled and unit-tested natively; the wasm-only glue (`cfg(target_arch = "wasm32")`) stays thin. |
| **Deploying module Workers via Terraform provider** | Same as quotesdb: `wrangler deploy` owns the script + routes; OpenTofu owns R2 bucket, D1 database, DNS, rate limits. |
## 4. Design
### 4.1 HTTP surface
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | `/` | | Management UI (embedded static HTML) |
| GET | `/auth/login` | | Redirect to GitHub OAuth (sets state cookie) |
| GET | `/auth/callback` | | Exchange code, upsert user, create session |
| POST | `/auth/logout` | session | Delete session, clear cookie |
| GET | `/api/me` | session | Current user (login, can_upload, is_admin) |
| GET | `/api/pages` | | List pages (slug, owner login, created_at, size) |
| POST | `/api/pages?name=<slug>` | can_upload | Raw body upload (html or zip). 201 / 400 / 401 / 403 / 409 / 413 |
| DELETE | `/api/pages/:slug` | owner or admin | Delete page (D1 row + R2 objects) |
| GET | `/api/users` | admin | List users |
| PATCH | `/api/users/:id` | admin | Set `can_upload` and/or `trusted` (JSON body `{"can_upload"?: bool, "trusted"?: bool}`, at least one required — pages-shqc) |
| PATCH | `/api/pages/:slug` | admin | Set a page's own `trusted` flag (JSON body `{"trusted": bool}` — pages-shqc) |
| GET | `/:slug` | | 301 → `/:slug/` |
| GET | `/:slug/` | | Serve `index.html` of the page |
| GET | `/:slug/*path` | | Serve asset; directory-style paths fall back to `<path>/index.html`; 404 JSON-less page otherwise |
Single-file `.html` uploads are stored as `<slug>/index.html`.
### 4.2 D1 schema
```sql
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
github_id INTEGER NOT NULL UNIQUE,
login TEXT NOT NULL,
avatar_url TEXT,
can_upload INTEGER NOT NULL DEFAULT 0,
is_admin INTEGER NOT NULL DEFAULT 0,
trusted INTEGER NOT NULL DEFAULT 0, -- pages-shqc, §2.1 addendum
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE sessions (
token_hash TEXT PRIMARY KEY, -- SHA-256 hex of the cookie token
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE pages (
slug TEXT PRIMARY KEY,
owner_id INTEGER NOT NULL REFERENCES users(id),
file_count INTEGER NOT NULL,
total_bytes INTEGER NOT NULL,
trusted INTEGER NOT NULL DEFAULT 0, -- pages-shqc, §2.1 addendum
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
```
`trusted` on both tables was added by pages-shqc (§2.1 addendum) after v0
shipped — `migrations/0002-trust.sql` is the incremental `ALTER TABLE` for
the already-live production database; `migrations/schema.sql` above already
has both columns for fresh installs.
### 4.3 R2 layout
`<slug>/<relative-path>` (e.g. `my-project/index.html`,
`my-project/assets/app.js`). Deleting a page lists by prefix `<slug>/` and
deletes all objects.
### 4.4 Bindings & configuration (wrangler.toml)
- D1 binding `DB`, R2 binding `PAGES`.
- Vars: `BASE_URL` (`https://pages.elijah.run`), `GITHUB_CLIENT_ID`,
`ADMIN_GITHUB_LOGIN`.
- Secrets: `GITHUB_CLIENT_SECRET`.
### 4.5 Crate layout (single crate `pages`, cdylib + rlib)
```
src/
├── lib.rs # module wiring + #[event(fetch)] (wasm-only)
├── core/
│ ├── mod.rs
│ ├── slug.rs # slug validation + reserved names [native tests]
│ ├── mime.rs # extension → MIME allowlist [native tests]
│ ├── upload.rs # zip/html validation & manifest building [native tests]
│ └── headers.rs # security header constants (CSP sandbox…)[native tests]
├── auth.rs # OAuth URLs, state/session token & cookie helpers
│ # (pure parts native-tested; GitHub calls wasm-only)
├── db.rs # D1 queries (wasm-only)
├── routes/ # axum handlers (wasm-only)
│ ├── mod.rs # router construction
│ ├── auth.rs
│ ├── api.rs
│ └── serve.rs # page serving from R2
└── tests.rs # unit test module wiring
static/index.html # management UI (embedded via include_str!)
migrations/schema.sql
infra/*.tf
```
### 4.6 Upload pipeline
1. Auth middleware resolves session → user; reject 401/403 (`can_upload`).
2. Origin check (mutating request).
3. Validate `?name=` slug (grammar + reserved + quota) → 400/409.
4. Enforce `Content-Length` ≤ 10 MiB, buffer body with a hard cap → 413.
5. `Content-Type: text/html` → manifest of one file `index.html`.
`application/zip` → extract with the §2.2 limits → manifest of
`(path, bytes)`; must contain a root `index.html` (else 400).
6. Insert D1 `pages` row (PK conflict → 409, nothing written to R2 yet).
7. `put` each file to R2 under `<slug>/…`; on failure, best-effort cleanup
of written objects and the D1 row → 500.
### 4.7 Serving pipeline
`GET /:slug[/*path]` → reserved/invalid slug → 404. Otherwise a D1
`get_page` lookup happens **before** any R2 access (pages-shqc): unknown
slug → 404 without touching R2; a known page's `effective_trust` (§2.1
addendum) is read once and used for every response built from that point on.
Then R2 `get` on `<slug>/<path or index.html>`; on miss try
`<…>/index.html`. Response headers: correct MIME,
`X-Content-Type-Options: nosniff`, `Referrer-Policy: no-referrer`,
`Cache-Control: public, max-age=300`, and `Access-Control-Allow-Origin: *`
(assets must load for opaque-origin documents) on **every** page response;
the §2.1 CSP sandbox is added on top of those four **unless** the page is
effectively trusted (§2.1 addendum), in which case it's omitted entirely.
The redirect (`/:slug` → `/:slug/`) and the shared 404 body always carry the
sandbox unconditionally — no D1 lookup happens before either of those two
short-circuit paths. See `src/core/headers.rs::user_content_headers` (pure,
natively-tested) and `src/routes/serve.rs` (the wasm-only caller).
## 5. v0 scope cuts (explicit)
- No page overwrite/update: delete + re-upload.
- No per-page subdomains (see §2.1 residual risk).
- No OpenAPI spec generation (repo TODO is unresolved; endpoints documented
here and in README).
- No Yew frontend; single embedded HTML file.
- Admin UI is minimal (user list with checkbox, delete buttons on pages).

@ -0,0 +1,5 @@
.terraform/
*.tfstate
*.tfstate.backup
.terraform.lock.hcl
*.tfvars

@ -0,0 +1,212 @@
# pages Infrastructure
OpenTofu configuration for the Cloudflare resources backing
`pages.elijah.run`. Mirrors the style of `../../quotesdb/infra`.
**Scope split** (see `../CLAUDE.md` "Deviations from repo defaults" and
`docs/plans/2026-07-12-pages-design.md` §3): the Cloudflare Terraform
provider (v4) cannot upload ES module Workers that import wasm files, so
`wrangler deploy` owns the Worker script and its route. OpenTofu (this
directory) owns everything else: the R2 bucket, the D1 database, the DNS
record, and the rate limiting rule.
## Resources provisioned
| Resource | Description |
|---|---|
| `cloudflare_r2_bucket.pages` | R2 bucket storing hosted page content (`<slug>/<path>` keys) |
| `cloudflare_d1_database.pages` | D1 database for users/sessions/page metadata |
| `cloudflare_record.pages` | Proxied placeholder DNS record so the Worker route can intercept `pages.elijah.run/*` |
| `cloudflare_ruleset.pages_rate_limits` | Per-IP rate limits on `/api/pages` (uploads) and `/auth/` |
## Required credentials
| Variable | Description |
|---|---|
| `cloudflare_api_token` | Cloudflare API token with Workers, D1, R2, and DNS edit permissions |
| `cloudflare_account_id` | Cloudflare account ID |
| `cloudflare_zone_id` | Zone ID for `elijah.run` |
Set these in `terraform.tfvars` (gitignored) or as `TF_VAR_*` environment
variables:
```sh
cloudflare_api_token = "..."
cloudflare_account_id = "..."
cloudflare_zone_id = "..."
```
## 1. Prerequisites
### Cloudflare account
- An active Cloudflare account with the `elijah.run` zone.
- An API token (User Profile → API Tokens → Create Token) scoped to:
- **Account** → Workers R2 Storage → Edit
- **Account** → D1 → Edit
- **Zone** → DNS → Edit (for `elijah.run`)
- **Zone** → WAF (or "Ruleset") → Edit (for the rate limiting ruleset)
- Note the account ID (right sidebar of the Cloudflare dashboard) and the
zone ID (`elijah.run` overview page, right sidebar).
### GitHub OAuth App
`pages` authenticates users via GitHub OAuth (design §2.4). Create the app
**before** deploying the Worker, since its client ID/secret are Worker
config:
1. GitHub → Settings → Developer settings → OAuth Apps → **New OAuth App**.
2. **Application name**: `pages.elijah.run` (or similar).
3. **Homepage URL**: `https://pages.elijah.run`.
4. **Authorization callback URL**: `https://pages.elijah.run/auth/callback`
— must match `BASE_URL` + `/auth/callback` exactly, since the Worker
validates GitHub's redirect against this.
5. Click **Register application**.
6. Copy the generated **Client ID** — this becomes `GITHUB_CLIENT_ID` in
`../wrangler.toml`.
7. Click **Generate a new client secret** and copy it immediately (it's
only shown once) — this becomes the `GITHUB_CLIENT_SECRET` Worker
secret (§5 below), never committed to the repo.
If you also want a local dev flow (`wrangler dev` on
`http://localhost:8787`), create a second OAuth App (GitHub doesn't allow
multiple callback URLs per app in the classic OAuth App flow) with callback
`http://localhost:8787/auth/callback`, and put its client ID/secret in
`.dev.vars` (copy `.dev.vars.example`, git-ignored).
## 2. tofu init / plan / apply
D1 must exist before the Worker script binds to it (chicken-and-egg), same
as quotesdb:
```sh
cd infra/
tofu init
tofu apply -target=cloudflare_d1_database.pages \
-var cloudflare_api_token="..." \
-var cloudflare_account_id="..." \
-var cloudflare_zone_id="..."
```
Then apply everything else (R2 bucket, DNS record, rate limits):
```sh
tofu plan \
-var cloudflare_api_token="..." \
-var cloudflare_account_id="..." \
-var cloudflare_zone_id="..."
tofu apply \
-var cloudflare_api_token="..." \
-var cloudflare_account_id="..." \
-var cloudflare_zone_id="..."
```
(Omit the `-var` flags if you've set the equivalent `TF_VAR_*` environment
variables or a `terraform.tfvars` file instead.)
**Zone ruleset caveat**: Cloudflare allows only one zone-level
`http_ratelimit` ruleset per zone, on every plan. `quotesdb` already
declares one on the shared `elijah.run` zone — if both projects' rulesets
are applied, the later apply clobbers the earlier one. If quotesdb's rate
limits are live, merge this file's two `rules` blocks into that single
shared ruleset instead of applying `cloudflare_ruleset.pages_rate_limits`
standalone (a paid plan raises the rule count allowed inside the one
ruleset; the Free plan caps it at 1 rule). See the comment in
`rate-limits.tf`.
After apply, note the `d1_database_id` output:
```sh
tofu output d1_database_id
```
## 3. Update wrangler.toml
From the `pages/` project root (not `infra/`):
1. Replace the placeholder `database_id` in `[[d1_databases]]` with the
real ID from `tofu output d1_database_id`.
2. Uncomment the `[[routes]]` block now that the DNS record exists:
```toml
[[routes]]
pattern = "pages.elijah.run/*"
zone_name = "elijah.run"
```
## 4. Apply the D1 schema
From `pages/` (schema lives at `migrations/schema.sql`):
```sh
# Production (remote) database
wrangler d1 execute pages --remote --file migrations/schema.sql
# Local dev database (used by `wrangler dev`)
wrangler d1 execute pages --local --file migrations/schema.sql
```
The schema uses `CREATE TABLE IF NOT EXISTS` / `CREATE INDEX IF NOT EXISTS`
throughout, so re-running either command against an already-migrated
database is safe.
## 5. Secrets and vars
```sh
# From pages/ — prompts for the value, never stored in the repo.
wrangler secret put GITHUB_CLIENT_SECRET
```
In `../wrangler.toml`, set the two plaintext vars under `[vars]`:
```toml
[vars]
BASE_URL = "https://pages.elijah.run"
GITHUB_CLIENT_ID = "<client id from the GitHub OAuth App above>"
ADMIN_GITHUB_LOGIN = "<your GitHub login auto-promoted to admin on first login>"
```
## 6. Deploy
From `pages/`:
```sh
worker-build --release # or let [build] in wrangler.toml run it
wrangler deploy
```
## 7. Verify
- [ ] `https://pages.elijah.run/` loads the management UI.
- [ ] "Log in with GitHub" completes the OAuth round trip and sets a
session cookie (`__Host-session`).
- [ ] The account matching `ADMIN_GITHUB_LOGIN` shows `is_admin` /
`can_upload` true via `GET /api/me`.
- [ ] Uploading a small `.html` file (`POST /api/pages?name=<slug>`) as an
approved user returns `201` and the page is listed at `GET /api/pages`.
- [ ] `GET /<slug>/` serves the uploaded content with
`Content-Security-Policy: sandbox …` and
`X-Content-Type-Options: nosniff` response headers.
- [ ] Deleting the page (`DELETE /api/pages/:slug`) as the owner or an
admin removes it from both the page list and R2 (a subsequent
`GET /<slug>/` 404s).
- [ ] Repeating an upload or auth request past the configured rate limit
gets blocked (HTTP 429-equivalent Managed Challenge/block page).
## State
State is stored locally in `terraform.tfstate` (gitignored). For a team
setup, migrate to a remote backend (S3-compatible bucket, Terraform Cloud,
etc.).
## Files
| File | Purpose |
|---|---|
| `providers.tf` | Terraform block (provider version pin) + Cloudflare provider configuration |
| `variables.tf` | Input variable declarations |
| `r2.tf` | Cloudflare R2 bucket for hosted page content |
| `d1.tf` | Cloudflare D1 database + `d1_database_id` output |
| `dns.tf` | Placeholder proxied DNS record enabling the Worker route |
| `rate-limits.tf` | Per-IP rate limiting ruleset for uploads and auth |
| `.gitignore` | Ignores state, lock, and credential files |

@ -0,0 +1,16 @@
# Cloudflare D1 database backing the pages users/sessions/page-metadata
# tables (schema in ../migrations/schema.sql). SQLite-compatible; bound to
# the Worker via the "DB" binding name in wrangler.toml.
# NOTE: D1 must be created before the Worker script can bind to it
# (chicken-and-egg). On first apply: tofu apply -target=cloudflare_d1_database.pages
resource "cloudflare_d1_database" "pages" {
account_id = var.cloudflare_account_id
name = "pages"
}
# Export the D1 database ID so wrangler.toml's [[d1_databases]] placeholder
# can be replaced with the real ID, and for use in wrangler migration commands.
output "d1_database_id" {
description = "D1 database ID — use to update wrangler.toml [[d1_databases]] database_id, and with: wrangler d1 execute pages --file ../migrations/schema.sql --remote"
value = cloudflare_d1_database.pages.id
}

@ -0,0 +1,19 @@
# DNS record for pages.elijah.run.
#
# Unlike ../quotesdb (a Cloudflare Pages project fronted by a CNAME to its
# *.pages.dev subdomain), pages.elijah.run is served by a Worker route
# declared in ../wrangler.toml ([[routes]] pattern = "pages.elijah.run/*",
# see pages/CLAUDE.md "Deviations from repo defaults"). A Worker route only
# intercepts traffic for a hostname that already resolves through Cloudflare's
# proxy it does not need a real origin IP, since the Worker itself
# generates the response. The conventional placeholder is a proxied AAAA
# record pointing at the documented "black hole" address 100::/64
# (RFC 6666 discard prefix); Cloudflare never actually connects to it because
# the orange-clouded record is only there to make the hostname proxied.
resource "cloudflare_record" "pages" {
zone_id = var.cloudflare_zone_id
name = "pages"
type = "AAAA"
content = "100::"
proxied = true
}

@ -0,0 +1,20 @@
# Terraform block: local state (terraform.tfstate is gitignored, no remote
# backend needed for this project) and the Cloudflare provider version pin.
# Pinned to the same major version as ../quotesdb/infra for consistency
# across the mono-repo.
terraform {
required_providers {
# Cloudflare provider for R2, D1, DNS, and rate limiting.
cloudflare = {
source = "registry.terraform.io/cloudflare/cloudflare"
version = "~> 4"
}
}
}
# Cloudflare provider configuration.
# Authentication uses an API token passed via var.cloudflare_api_token.
# Never hardcode credentials here use TF_VAR_* env vars or a gitignored .tfvars file.
provider "cloudflare" {
api_token = var.cloudflare_api_token
}

@ -0,0 +1,8 @@
# R2 bucket that stores all hosted page content.
# Objects are keyed "<slug>/<relative-path>" (e.g. "my-project/index.html",
# "my-project/assets/app.js") see docs/plans/2026-07-12-pages-design.md §4.3.
# Bound to the Worker via the "PAGES" binding name in wrangler.toml.
resource "cloudflare_r2_bucket" "pages" {
account_id = var.cloudflare_account_id
name = "pages"
}

@ -0,0 +1,69 @@
# Cloudflare WAF rate limiting rules for the pages Worker, per
# docs/plans/2026-07-12-pages-design.md §2.5 (denial-of-wallet mitigation).
# Uses the http_ratelimit phase of cloudflare_ruleset, mirroring
# ../quotesdb/infra/rate-limits.tf.
#
# NOTE on thresholds: the design doc's targets (uploads ~10/min, auth
# ~20/min per IP) are minute-based, but the Cloudflare Free plan only allows
# a 10-second window ("period" must be 10) for http_ratelimit rules, so the
# figures below are converted to 10s buckets and rounded to the nearest
# sensible integer rather than being an exact /min conversion.
#
# NOTE on the zone entrypoint limit: Cloudflare allows only ONE zone-level
# ruleset per phase, on every plan the http_ratelimit entrypoint for
# elijah.run is a single resource. quotesdb already declares such a ruleset
# (../quotesdb/infra/rate-limits.tf) on this shared zone, so applying this
# file's ruleset alongside quotesdb's will clobber it (last apply wins),
# regardless of plan. A paid plan only raises the number of rules allowed
# INSIDE the one ruleset (Free additionally caps it at 1 rule with 10s
# periods). Before applying both projects' limits, merge the two rules
# blocks below into the zone's single shared cloudflare_ruleset (wherever
# it ends up living); this file is written standalone so it works verbatim
# when quotesdb's ruleset is not applied. See infra/README.md.
resource "cloudflare_ruleset" "pages_rate_limits" {
# Scoped to the elijah.run zone that hosts pages.elijah.run.
zone_id = var.cloudflare_zone_id
name = "pages rate limits"
description = "Per-IP rate limiting for mutating pages endpoints (uploads, auth)"
kind = "zone"
phase = "http_ratelimit"
rules {
# Uploads: POST /api/pages?name=<slug>. Free plan restrictions apply
# (see quotesdb's note): expression fields are limited to Path and
# Verified Bot only (no Method, no regex "contains" instead of a
# path prefix match), characteristics are IP + colo only, and period
# must be 10. This also catches GET /api/pages (list) since Method
# isn't available to filter on Free acceptable since list is cheap
# and idempotent; the design's 10/min target for the abusive path
# (upload) still bounds worst-case write volume.
# ~10/min => ~1.7/10s, rounded up to 2/10s.
description = "Limit /api/pages requests to 2 per IP per 10 seconds"
expression = "http.request.uri.path contains \"/api/pages\""
action = "block"
ratelimit {
# cf.colo.id is required alongside ip.src (rate limiting is processed
# per-colocation).
characteristics = ["ip.src", "cf.colo.id"]
period = 10
requests_per_period = 2
mitigation_timeout = 10
}
}
rules {
# Auth: GET /auth/login, GET /auth/callback, POST /auth/logout.
# ~20/min => ~3.3/10s, rounded up to 4/10s (slightly more permissive
# than uploads since login/logout round-trips are more frequent under
# normal use than page uploads).
description = "Limit /auth/ requests to 4 per IP per 10 seconds"
expression = "http.request.uri.path contains \"/auth/\""
action = "block"
ratelimit {
characteristics = ["ip.src", "cf.colo.id"]
period = 10
requests_per_period = 4
mitigation_timeout = 10
}
}
}

@ -0,0 +1,23 @@
# Cloudflare API token required for all provider operations.
# Set via: export TF_VAR_cloudflare_api_token="..."
variable "cloudflare_api_token" {
description = "Cloudflare API token with Workers, D1, R2, and DNS edit permissions."
type = string
sensitive = true
}
# Cloudflare account ID required for R2 and D1 resources.
# Set via: export TF_VAR_cloudflare_account_id="..."
variable "cloudflare_account_id" {
description = "Cloudflare account ID where pages resources are provisioned."
type = string
}
# Cloudflare zone ID for elijah.run required for the DNS record and rate
# limiting ruleset.
# Set via: export TF_VAR_cloudflare_zone_id="..."
variable "cloudflare_zone_id" {
description = "Cloudflare zone ID for the elijah.run domain."
type = string
}

@ -0,0 +1,30 @@
# Provision Cloudflare resources (R2 bucket, D1 database, DNS record,
# rate limits) from infra/. Reads credentials from infra/terraform.tfvars.
tofu-apply:
cd infra && tofu init -input=false && tofu apply
# Apply schema migrations to the remote (production) D1 database.
# schema.sql is idempotent (CREATE ... IF NOT EXISTS), safe to re-run.
migrate:
wrangler d1 execute pages --remote --file migrations/schema.sql -y
# Apply schema migrations to the local D1 database used by `wrangler dev`.
migrate-local:
wrangler d1 execute pages --local --file migrations/schema.sql -y
# Build (via worker-build, configured in wrangler.toml) and deploy the Worker.
deploy:
wrangler deploy
# Run the Worker locally (uses .dev.vars for vars/secrets).
dev:
wrangler dev
# Full validation suite (see CLAUDE.md).
validate:
cargo fmt
cargo check --target wasm32-unknown-unknown
cargo check
cargo clippy --all-targets -- -D warnings
cargo clippy --target wasm32-unknown-unknown -- -D warnings
cargo test

@ -0,0 +1,29 @@
-- pages.elijah.run — incremental migration: users.trusted + pages.trusted
--
-- pages-shqc: adds the admin-granted "trust" escape hatch (design §2.1
-- addendum) — trusted pages/users are served WITHOUT the CSP sandbox so
-- local-first apps (e.g. a habit tracker) can use localStorage. Effective
-- trust for a page is `pages.trusted OR <owner's> users.trusted`, computed
-- at serve time (src/db.rs::PageRow::effective_trust).
--
-- `migrations/schema.sql` already has both columns for fresh installs
-- (`CREATE TABLE IF NOT EXISTS` there is a no-op against a live DB that
-- already has the tables). This file is for the *existing* production D1
-- database, which needs the columns added in place.
--
-- SQLite has no `ALTER TABLE ... ADD COLUMN IF NOT EXISTS`, so re-running
-- this file against a database that has already applied it will fail with
-- an error like `duplicate column name: trusted` on each statement below —
-- that is expected and safe to ignore (same precedent as quotesdb's
-- `ALTER_QUOTES_ADD_HIDDEN` migration, src/db/migrations.rs). Do NOT run
-- this more than once per database; check first if unsure
-- (`PRAGMA table_info(users)` / `PRAGMA table_info(pages)`).
--
-- Apply with (NOT run automatically by this ticket — remote apply is a
-- separate, manual, human-triggered step):
-- wrangler d1 execute <DB_NAME> --file=migrations/0002-trust.sql (remote)
-- wrangler d1 execute <DB_NAME> --local --file=migrations/0002-trust.sql (local dev)
ALTER TABLE users ADD COLUMN trusted INTEGER NOT NULL DEFAULT 0;
ALTER TABLE pages ADD COLUMN trusted INTEGER NOT NULL DEFAULT 0;

@ -0,0 +1,82 @@
-- pages.elijah.run — D1 schema
--
-- Mirrors docs/plans/2026-07-12-pages-design.md §4.2 exactly. All statements
-- use `IF NOT EXISTS` so this file can be re-applied safely (e.g. re-running
-- `wrangler d1 execute` against a database that already has these objects).
--
-- Apply with:
-- wrangler d1 execute <DB_NAME> --file=migrations/schema.sql (remote)
-- wrangler d1 execute <DB_NAME> --local --file=migrations/schema.sql (local dev)
-- Registered users, upserted on every successful GitHub OAuth login
-- (src/db.rs::Db::upsert_user_on_login). `github_id` is the stable GitHub
-- account id (not the login, which can change) and is the natural
-- conflict key for the upsert. `can_upload` and `is_admin` are booleans
-- stored as INTEGER 0/1 (SQLite/D1 has no native boolean type) — see the
-- `UserRow` -> `User` conversion in src/db.rs for how these are turned
-- into real `bool`s on read. Both default to 0: new OAuth users can log
-- in but cannot publish until an admin grants `can_upload` (design §2.4).
-- `ADMIN_GITHUB_LOGIN` logins are auto-promoted (both flags forced to 1)
-- on every login by the upsert, independent of this default. `trusted` is a
-- separate, admin-only-granted flag (pages-shqc): it is never touched by
-- admin auto-promotion or by any login upsert — see the "Trust escape
-- hatch" addendum in docs/plans/2026-07-12-pages-design.md §2.1. A user's
-- `trusted` flag contributes to a page's *effective* trust (alongside the
-- page's own `trusted` flag below) via `p.trusted OR u.trusted`.
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
github_id INTEGER NOT NULL UNIQUE,
login TEXT NOT NULL,
avatar_url TEXT,
can_upload INTEGER NOT NULL DEFAULT 0,
is_admin INTEGER NOT NULL DEFAULT 0,
trusted INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- Server-side session records. The cookie sent to the browser holds the raw
-- session token; only its SHA-256 hex hash is ever persisted here, so a D1
-- read leak does not yield a usable session token (design §2.4).
-- `ON DELETE CASCADE` means deleting a user row (none currently does this in
-- v0, but future admin tooling might) also removes their sessions.
CREATE TABLE IF NOT EXISTS sessions (
token_hash TEXT PRIMARY KEY, -- SHA-256 hex of the cookie token
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- One row per published page. `slug` is the primary key, so a duplicate
-- upload name surfaces as a UNIQUE/PK constraint violation, which src/db.rs
-- maps to a typed `DbError::NameTaken` (HTTP 409 at the routes layer,
-- design §2.3). `file_count`/`total_bytes` are denormalized summaries of the
-- R2 objects stored under the `<slug>/` prefix (design §4.3), populated at
-- upload time so the page list and per-user quota check (design §2.5) don't
-- need to touch R2. `trusted` is an admin-only-granted flag (pages-shqc,
-- design §2.1 addendum): a page's *effective* trust is
-- `pages.trusted OR <owner's> users.trusted`, computed at serve time —
-- see src/db.rs's `PageRow::effective_trust`.
CREATE TABLE IF NOT EXISTS pages (
slug TEXT PRIMARY KEY,
owner_id INTEGER NOT NULL REFERENCES users(id),
file_count INTEGER NOT NULL,
total_bytes INTEGER NOT NULL,
trusted INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- Per-user page quota (design §2.5) is enforced by counting
-- `pages` rows WHERE owner_id = ? before an upload; without this index that
-- scan is a full table scan on every upload.
CREATE INDEX IF NOT EXISTS idx_pages_owner_id ON pages(owner_id);
-- get_user_by_session filters `WHERE expires_at > datetime('now')` on every
-- authenticated request; this index keeps that lookup (and any future
-- expired-session cleanup job) from scanning the whole table.
CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at);
-- Not required by any current query pattern (session lookups go through
-- `token_hash`, the primary key) but cheap to maintain and speeds up the
-- `ON DELETE CASCADE` scan when a user row is deleted, plus any future
-- "list my sessions" / "log out everywhere" feature keyed on user_id.
CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);

@ -0,0 +1,636 @@
//! Pure OAuth/session helpers — token generation, hashing, cookie
//! building/parsing, the GitHub authorize URL, and epoch→UTC formatting for
//! session expiry.
//!
//! Everything in this module is plain Rust with no dependency on `worker` or
//! `axum`, so it compiles for both the native host target and
//! `wasm32-unknown-unknown` and is unit-tested natively (`cargo test`). See
//! `docs/plans/2026-07-12-pages-design.md` §2.4 (auth design) and §2.1 (why
//! the session cookie is `HttpOnly`). The wasm-only glue that actually talks
//! to GitHub and D1 lives in `routes::auth`.
// ── Cookie names & lifetimes ─────────────────────────────────────────────
/// Name of the session cookie.
///
/// The `__Host-` prefix is a browser-enforced guarantee: a cookie with this
/// prefix is only accepted if it also sets `Secure`, has no `Domain`
/// attribute, and `Path=/` — i.e. it can only ever have been set by this
/// exact origin. That closes off a class of subdomain/cross-origin cookie
/// injection attacks (design §2.4).
pub const SESSION_COOKIE_NAME: &str = "__Host-session";
/// Name of the short-lived OAuth CSRF-protection cookie (design §2.4).
pub const STATE_COOKIE_NAME: &str = "__Host-oauth-state";
/// Session cookie lifetime: 30 days, in seconds (design §2.4).
pub const SESSION_MAX_AGE_SECS: u64 = 30 * 24 * 60 * 60;
/// OAuth `state` cookie lifetime: 10 minutes, in seconds (design §2.4) — long
/// enough to complete the GitHub authorize redirect round trip, short enough
/// that a stale, unused state cookie is not a long-lived CSRF token.
pub const STATE_MAX_AGE_SECS: u64 = 10 * 60;
// ── Token generation & hashing ───────────────────────────────────────────
/// Generates a fresh random token: 32 bytes of entropy, hex-encoded to a
/// 64-character lowercase string.
///
/// Used for both the OAuth `state` value and the raw session token (design
/// §2.4). Backed by [`getrandom::fill`], which draws from the OS CSPRNG on
/// native targets and `crypto.getRandomValues()` (via the `wasm_js` feature)
/// on `wasm32-unknown-unknown` — the same entropy source used throughout the
/// Workers ecosystem (see `Cargo.toml`).
///
/// # Panics
///
/// Panics if the platform's entropy source is unavailable. This is treated
/// as an unrecoverable environment failure (there is no safe fallback for
/// session/CSRF token generation) rather than a `Result` callers might be
/// tempted to paper over.
///
/// # Examples
///
/// ```
/// use pages::auth::generate_token;
/// let token = generate_token();
/// assert_eq!(token.len(), 64);
/// assert!(token.chars().all(|c| c.is_ascii_hexdigit()));
/// ```
pub fn generate_token() -> String {
let mut buf = [0u8; 32];
getrandom::fill(&mut buf).expect("getrandom: no secure entropy source available");
hex_encode(&buf)
}
/// Hex-encodes a byte slice as a lowercase string (two characters per byte).
fn hex_encode(bytes: &[u8]) -> String {
use std::fmt::Write as _;
let mut out = String::with_capacity(bytes.len() * 2);
for b in bytes {
// `write!` to a `String` never fails.
let _ = write!(out, "{b:02x}");
}
out
}
/// Computes the SHA-256 digest of `input`, hex-encoded (lowercase, 64
/// characters).
///
/// Used to store only a hash of the session token in D1 (design §2.4) — a
/// database read leak doesn't yield a usable session, since sessions are
/// looked up by the hash and the raw token never touches storage.
///
/// # Examples
///
/// ```
/// use pages::auth::sha256_hex;
/// assert_eq!(
/// sha256_hex(""),
/// "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
/// );
/// ```
pub fn sha256_hex(input: &str) -> String {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(input.as_bytes());
hex_encode(&hasher.finalize())
}
// ── Cookie building ───────────────────────────────────────────────────────
/// Common cookie attribute suffix shared by every cookie this module issues:
/// site-wide path, HTTPS-only, inaccessible to JS, and not sent on
/// cross-site requests (design §2.1, §2.4).
fn attrs(max_age_secs: u64) -> String {
format!("Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age={max_age_secs}")
}
/// Builds the `Set-Cookie` value for a new session, valid for `max_age_secs`
/// seconds.
///
/// Callers pass [`SESSION_MAX_AGE_SECS`] in production; the parameter exists
/// so tests can use short-lived values without waiting.
///
/// # Examples
///
/// ```
/// use pages::auth::session_cookie;
/// let c = session_cookie("abc123", 2592000);
/// assert!(c.starts_with("__Host-session=abc123;"));
/// assert!(c.contains("HttpOnly"));
/// assert!(c.contains("Secure"));
/// assert!(c.contains("SameSite=Lax"));
/// assert!(c.contains("Path=/"));
/// assert!(c.contains("Max-Age=2592000"));
/// ```
pub fn session_cookie(token: &str, max_age_secs: u64) -> String {
format!("{SESSION_COOKIE_NAME}={token}; {}", attrs(max_age_secs))
}
/// Builds the `Set-Cookie` value that deletes the session cookie
/// (`Max-Age=0`), for logout.
///
/// # Examples
///
/// ```
/// use pages::auth::clear_session_cookie;
/// assert!(clear_session_cookie().contains("Max-Age=0"));
/// ```
pub fn clear_session_cookie() -> String {
format!("{SESSION_COOKIE_NAME}=; {}", attrs(0))
}
/// Builds the `Set-Cookie` value for the short-lived OAuth `state` cookie
/// (design §2.4), fixed at [`STATE_MAX_AGE_SECS`].
///
/// # Examples
///
/// ```
/// use pages::auth::state_cookie;
/// let c = state_cookie("xyz");
/// assert!(c.starts_with("__Host-oauth-state=xyz;"));
/// assert!(c.contains("Max-Age=600"));
/// ```
pub fn state_cookie(state: &str) -> String {
format!("{STATE_COOKIE_NAME}={state}; {}", attrs(STATE_MAX_AGE_SECS))
}
/// Builds the `Set-Cookie` value that deletes the OAuth `state` cookie
/// (`Max-Age=0`) — sent on every `/auth/callback` response, success or
/// failure, so a state cookie never outlives a single OAuth round trip.
///
/// # Examples
///
/// ```
/// use pages::auth::clear_state_cookie;
/// assert!(clear_state_cookie().contains("Max-Age=0"));
/// ```
pub fn clear_state_cookie() -> String {
format!("{STATE_COOKIE_NAME}=; {}", attrs(0))
}
// ── Cookie parsing ────────────────────────────────────────────────────────
/// Looks up `name` in a raw `Cookie` request header value and returns its
/// value, or `None` if not present.
///
/// Robust to the spacing variations real browsers send (`Cookie: a=1;
/// b=2` vs. `a=1;b=2` vs. extra spaces), and to `name` being a prefix of a
/// different cookie's name (e.g. looking up `session` does not match a
/// cookie literally named `session2`). Returns a borrow of the input — no
/// allocation.
///
/// # Examples
///
/// ```
/// use pages::auth::cookie_value;
/// assert_eq!(cookie_value("a=1; b=2", "b"), Some("2"));
/// assert_eq!(cookie_value("a=1;b=2", "b"), Some("2"));
/// assert_eq!(cookie_value("a=1; b=2", "c"), None);
/// assert_eq!(cookie_value("session=tok; session2=other", "session"), Some("tok"));
/// ```
pub fn cookie_value<'a>(cookie_header: &'a str, name: &str) -> Option<&'a str> {
for part in cookie_header.split(';') {
let part = part.trim();
if let Some(rest) = part.strip_prefix(name) {
if let Some(value) = rest.strip_prefix('=') {
return Some(value);
}
}
}
None
}
// ── GitHub authorize URL ─────────────────────────────────────────────────
/// Percent-encodes `input` for safe inclusion in a URL query component.
///
/// Unreserved characters (`ALPHA` / `DIGIT` / `-` `.` `_` `~`, RFC 3986
/// §2.3) pass through unchanged; every other byte is encoded as `%XX`
/// (uppercase hex). Hand-rolled rather than pulling in the `url` crate — the
/// only values ever encoded here are a handful of query parameters
/// (`client_id`, a `pages.elijah.run/...` redirect URI, and a hex token),
/// so a full URL-parsing crate is unnecessary weight for a wasm binary.
fn percent_encode(input: &str) -> String {
let mut out = String::with_capacity(input.len());
for b in input.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
out.push(b as char);
}
_ => {
out.push('%');
out.push(hex_digit(b >> 4));
out.push(hex_digit(b & 0x0f));
}
}
}
out
}
/// Uppercase hex digit for a nibble (`0..=15`), for [`percent_encode`].
fn hex_digit(nibble: u8) -> char {
match nibble {
0..=9 => (b'0' + nibble) as char,
_ => (b'A' + (nibble - 10)) as char,
}
}
/// Builds the GitHub OAuth authorization URL to redirect the browser to for
/// `GET /auth/login` (design §4.1).
///
/// `client_id` and `redirect_uri` come from Worker configuration
/// (`GITHUB_CLIENT_ID` var, `BASE_URL` + `/auth/callback` — design §4.4);
/// `state` is a freshly generated [`generate_token`] value, also stored in
/// the `state` cookie ([`state_cookie`]) for the callback to verify.
///
/// # Examples
///
/// ```
/// use pages::auth::github_authorize_url;
/// let url = github_authorize_url(
/// "abc123",
/// "https://pages.elijah.run/auth/callback",
/// "deadbeef",
/// );
/// assert!(url.starts_with("https://github.com/login/oauth/authorize?"));
/// assert!(url.contains("client_id=abc123"));
/// assert!(url.contains("redirect_uri=https%3A%2F%2Fpages.elijah.run%2Fauth%2Fcallback"));
/// assert!(url.contains("state=deadbeef"));
/// ```
pub fn github_authorize_url(client_id: &str, redirect_uri: &str, state: &str) -> String {
format!(
"https://github.com/login/oauth/authorize?client_id={}&redirect_uri={}&state={}",
percent_encode(client_id),
percent_encode(redirect_uri),
percent_encode(state),
)
}
// ── Epoch → UTC formatting ────────────────────────────────────────────────
/// Formats `epoch_secs` (seconds since the Unix epoch, UTC) as
/// `YYYY-MM-DD HH:MM:SS` — a format SQLite's `datetime()` function parses
/// directly (see `db::Db::create_session`).
///
/// The date part is computed by the well-known Howard Hinnant
/// `civil_from_days` algorithm (proleptic Gregorian calendar, correct for
/// all years including leap years, valid for the full `i64` day range) —
/// hand-rolled rather than pulling in `chrono`/`time`, which are heavier
/// than this crate wants on a wasm binary for a single conversion (design
/// says "no chrono/time" — see `Cargo.toml` comments and `docs/plans/…`
/// §4.5).
///
/// # Examples
///
/// ```
/// use pages::auth::format_expiry;
/// assert_eq!(format_expiry(0), "1970-01-01 00:00:00");
/// assert_eq!(format_expiry(1752300000), "2025-07-12 06:00:00");
/// assert_eq!(format_expiry(951782400), "2000-02-29 00:00:00"); // leap year
/// ```
pub fn format_expiry(epoch_secs: i64) -> String {
let days = epoch_secs.div_euclid(86_400);
let secs_of_day = epoch_secs.rem_euclid(86_400);
let (year, month, day) = civil_from_days(days);
let hour = secs_of_day / 3600;
let minute = (secs_of_day % 3600) / 60;
let second = secs_of_day % 60;
format!("{year:04}-{month:02}-{day:02} {hour:02}:{minute:02}:{second:02}")
}
/// Convenience wrapper combining "now" (as an epoch-seconds value the caller
/// obtains from `worker::Date::now()` on the wasm side, kept out of this
/// pure module) with [`SESSION_MAX_AGE_SECS`] to produce the `expires_at`
/// string for [`db::Db::create_session`](crate::db::Db::create_session).
///
/// # Examples
///
/// ```
/// use pages::auth::session_expiry_from_now;
/// let expiry = session_expiry_from_now(0);
/// assert_eq!(expiry, "1970-01-31 00:00:00"); // 0 + 30 days
/// ```
pub fn session_expiry_from_now(now_epoch_secs: i64) -> String {
format_expiry(now_epoch_secs + SESSION_MAX_AGE_SECS as i64)
}
/// Converts a day count since the Unix epoch (1970-01-01 = day `0`) to a
/// proleptic-Gregorian `(year, month, day)` triple.
///
/// This is Howard Hinnant's `civil_from_days` algorithm
/// (<https://howardhinnant.github.io/date_algorithms.html#civil_from_days>),
/// reproduced here rather than pulled from a dependency — it is ~15 lines,
/// well-known, and the only date computation this crate needs.
fn civil_from_days(z: i64) -> (i64, u32, u32) {
let z = z + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = (z - era * 146_097) as u64; // [0, 146096]
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399]
let y = yoe as i64 + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
let mp = (5 * doy + 2) / 153; // [0, 11]
let d = (doy - (153 * mp + 2) / 5 + 1) as u32; // [1, 31]
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; // [1, 12]
let y = if m <= 2 { y + 1 } else { y };
(y, m, d)
}
// ── Tests ─────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashSet;
// ── generate_token ───────────────────────────────────────────────
#[test]
fn token_is_64_hex_chars() {
let t = generate_token();
assert_eq!(t.len(), 64);
assert!(t.chars().all(|c| c.is_ascii_hexdigit()));
assert!(t.chars().all(|c| !c.is_ascii_uppercase()));
}
#[test]
fn tokens_are_unique() {
let tokens: HashSet<String> = (0..50).map(|_| generate_token()).collect();
assert_eq!(tokens.len(), 50, "expected 50 unique tokens in 50 draws");
}
// ── sha256_hex ───────────────────────────────────────────────────
#[test]
fn sha256_known_vector_empty() {
assert_eq!(
sha256_hex(""),
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
);
}
#[test]
fn sha256_known_vector_abc() {
assert_eq!(
sha256_hex("abc"),
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
);
}
#[test]
fn sha256_is_deterministic() {
assert_eq!(sha256_hex("hello world"), sha256_hex("hello world"));
}
#[test]
fn sha256_differs_for_different_input() {
assert_ne!(sha256_hex("a"), sha256_hex("b"));
}
// ── session_cookie / clear_session_cookie ───────────────────────
#[test]
fn session_cookie_has_host_prefix_and_token() {
let c = session_cookie("mytoken", SESSION_MAX_AGE_SECS);
assert!(c.starts_with("__Host-session=mytoken;"));
}
#[test]
fn session_cookie_has_required_attributes() {
let c = session_cookie("t", 123);
assert!(c.contains("Path=/"));
assert!(c.contains("Secure"));
assert!(c.contains("HttpOnly"));
assert!(c.contains("SameSite=Lax"));
assert!(c.contains("Max-Age=123"));
}
#[test]
fn session_cookie_max_age_is_30_days() {
assert_eq!(SESSION_MAX_AGE_SECS, 30 * 24 * 60 * 60);
}
#[test]
fn clear_session_cookie_zeroes_max_age_and_keeps_attrs() {
let c = clear_session_cookie();
assert!(c.starts_with("__Host-session=;"));
assert!(c.contains("Max-Age=0"));
assert!(c.contains("HttpOnly"));
assert!(c.contains("Secure"));
}
// ── state_cookie / clear_state_cookie ───────────────────────────
#[test]
fn state_cookie_has_host_prefix_and_value() {
let c = state_cookie("abc");
assert!(c.starts_with("__Host-oauth-state=abc;"));
}
#[test]
fn state_cookie_max_age_is_600() {
assert_eq!(STATE_MAX_AGE_SECS, 600);
assert!(state_cookie("x").contains("Max-Age=600"));
}
#[test]
fn state_cookie_has_required_attributes() {
let c = state_cookie("x");
assert!(c.contains("Path=/"));
assert!(c.contains("Secure"));
assert!(c.contains("HttpOnly"));
assert!(c.contains("SameSite=Lax"));
}
#[test]
fn clear_state_cookie_zeroes_max_age() {
let c = clear_state_cookie();
assert!(c.starts_with("__Host-oauth-state=;"));
assert!(c.contains("Max-Age=0"));
}
// ── cookie_value ─────────────────────────────────────────────────
#[test]
fn cookie_value_finds_middle_cookie() {
assert_eq!(cookie_value("a=1; b=2; c=3", "b"), Some("2"));
}
#[test]
fn cookie_value_finds_first_and_last() {
assert_eq!(cookie_value("a=1; b=2; c=3", "a"), Some("1"));
assert_eq!(cookie_value("a=1; b=2; c=3", "c"), Some("3"));
}
#[test]
fn cookie_value_missing_returns_none() {
assert_eq!(cookie_value("a=1; b=2", "z"), None);
}
#[test]
fn cookie_value_empty_header_returns_none() {
assert_eq!(cookie_value("", "a"), None);
}
#[test]
fn cookie_value_no_extra_spacing() {
assert_eq!(cookie_value("a=1;b=2;c=3", "b"), Some("2"));
}
#[test]
fn cookie_value_extra_spacing() {
assert_eq!(cookie_value("a=1; b=2 ; c=3", "b"), Some("2"));
}
#[test]
fn cookie_value_does_not_match_name_prefix() {
// Looking up "session" must not match a cookie literally named
// "session2".
assert_eq!(cookie_value("session2=nope", "session"), None);
}
#[test]
fn cookie_value_prefers_exact_match_over_prefix_cookie() {
assert_eq!(
cookie_value("session=tok; session2=other", "session"),
Some("tok")
);
}
#[test]
fn cookie_value_real_host_prefixed_cookie() {
let header = format!("{SESSION_COOKIE_NAME}=abcdef123456; other=x");
assert_eq!(
cookie_value(&header, SESSION_COOKIE_NAME),
Some("abcdef123456")
);
}
#[test]
fn cookie_value_handles_value_containing_equals() {
// Cookie values can legally contain '=' (e.g. base64); only the
// first '=' after the name should split name from value.
assert_eq!(cookie_value("a=b=c", "a"), Some("b=c"));
}
// ── github_authorize_url ─────────────────────────────────────────
#[test]
fn authorize_url_has_expected_base_and_params() {
let url = github_authorize_url(
"client123",
"https://pages.elijah.run/auth/callback",
"statetoken",
);
assert!(url.starts_with("https://github.com/login/oauth/authorize?"));
assert!(url.contains("client_id=client123"));
assert!(url.contains("state=statetoken"));
}
#[test]
fn authorize_url_percent_encodes_redirect_uri() {
let url = github_authorize_url("id", "https://pages.elijah.run/auth/callback", "s");
assert!(url.contains("redirect_uri=https%3A%2F%2Fpages.elijah.run%2Fauth%2Fcallback"));
// The raw, un-encoded redirect_uri must not appear verbatim as a
// query value (it does appear as a substring of the encoded form's
// decoded pieces, so check the literal unescaped "://" is absent).
assert!(!url.contains("redirect_uri=https://"));
}
#[test]
fn authorize_url_encodes_reserved_characters() {
let url = github_authorize_url("a b", "http://x/y?z=1", "s t&u");
assert!(!url.contains(' '), "spaces must be percent-encoded");
assert!(url.contains("a%20b"));
assert!(url.contains("s%20t%26u"));
}
#[test]
fn authorize_url_leaves_unreserved_chars_unescaped() {
let url = github_authorize_url("abc-123_ABC.~", "https://x/y", "s");
assert!(url.contains("client_id=abc-123_ABC.~"));
}
// ── format_expiry ────────────────────────────────────────────────
#[test]
fn format_expiry_unix_epoch() {
assert_eq!(format_expiry(0), "1970-01-01 00:00:00");
}
#[test]
fn format_expiry_known_2025_date() {
assert_eq!(format_expiry(1_752_300_000), "2025-07-12 06:00:00");
}
#[test]
fn format_expiry_y2k_start() {
assert_eq!(format_expiry(946_684_800), "2000-01-01 00:00:00");
}
#[test]
fn format_expiry_second_before_y2k() {
assert_eq!(format_expiry(946_684_799), "1999-12-31 23:59:59");
}
#[test]
fn format_expiry_leap_year_feb_29_2000() {
// 2000 is divisible by 400, so it *is* a leap year despite being a
// century year (the case the naive "divisible by 4" rule gets
// wrong) — a good check that civil_from_days handles the
// Gregorian century-leap-year exception correctly.
assert_eq!(format_expiry(951_782_400), "2000-02-29 00:00:00");
}
#[test]
fn format_expiry_non_leap_century_year_1900_would_skip_feb_29() {
// 1900 is divisible by 4 but not by 400 — not a leap year. Going
// from 1900-02-28 forward one day must land on 1900-03-01, not
// 1900-02-29. Compute epoch seconds for 1900-02-28 00:00:00 UTC
// relative to the 2000-01-01 anchor: there are 36524 days between
// 1900-01-01 and 2000-01-01 (Gregorian, excluding the 1900 leap
// day), so use the delta directly via format_expiry's own days-only
// math by comparing two consecutive-day outputs instead of a
// hardcoded absolute epoch (kept simple and self-checking).
let day_len = 86_400;
// 2000-01-01 00:00:00 UTC epoch (known good from format_expiry_y2k_start).
let y2k = 946_684_800i64;
// 1900-01-01 is exactly 36524 days before 2000-01-01 in the proleptic
// Gregorian calendar (no Feb 29 1900).
let epoch_1900_01_01 = y2k - 36_524 * day_len;
let epoch_1900_02_28 = epoch_1900_01_01 + 58 * day_len; // Jan has 31 days: 31 + 27 = 58 days to Feb 28
assert_eq!(format_expiry(epoch_1900_02_28), "1900-02-28 00:00:00");
assert_eq!(
format_expiry(epoch_1900_02_28 + day_len),
"1900-03-01 00:00:00"
);
}
#[test]
fn format_expiry_leap_day_rolls_to_march_1() {
// 2000-02-29 00:00:00 + 1 day = 2000-03-01 00:00:00.
assert_eq!(format_expiry(951_782_400 + 86_400), "2000-03-01 00:00:00");
}
#[test]
fn format_expiry_mid_year_known_date() {
assert_eq!(format_expiry(1_577_836_800), "2020-01-01 00:00:00");
}
// ── session_expiry_from_now ──────────────────────────────────────
#[test]
fn session_expiry_adds_30_days() {
assert_eq!(session_expiry_from_now(0), "1970-01-31 00:00:00");
}
#[test]
fn session_expiry_matches_format_expiry_plus_constant() {
let now = 1_752_300_000i64;
assert_eq!(
session_expiry_from_now(now),
format_expiry(now + SESSION_MAX_AGE_SECS as i64)
);
}
}

@ -0,0 +1,261 @@
//! Security header constants applied to every hosted-page response.
//!
//! See `docs/plans/2026-07-12-pages-design.md` §2.1 and §4.7. These values
//! are plain `&str` constants rather than `http::HeaderValue` so this module
//! stays dependency-free and usable from both native tests and the wasm-only
//! response-building code in `routes::serve`.
/// `Content-Security-Policy` value applied to **every** response that
/// serves user-uploaded content.
///
/// This is the primary defense described in design §2.1: hosted pages are
/// served from the *same origin* as the management app
/// (`pages.elijah.run/<slug>/` vs. `pages.elijah.run/`), so without
/// isolation, malicious JS in an uploaded page could issue credentialed
/// `fetch("/api/…")` calls — deleting pages, or worse, using the viewer's
/// own session cookie.
///
/// The `sandbox` directive forces the document into an **opaque origin**:
/// the browser treats it as if it came from a unique, unguessable origin
/// unrelated to `pages.elijah.run`. An opaque origin cannot read
/// `document.cookie`, has no access to `localStorage`/`IndexedDB`, and —
/// critically — the browser does not attach `pages.elijah.run` cookies
/// (including the `HttpOnly` session cookie) to any request it initiates,
/// because opaque origins are cross-origin with respect to the real
/// `pages.elijah.run` origin for cookie-sending purposes. So even a direct
/// `fetch("https://pages.elijah.run/api/pages/evil", { method: "DELETE" })`
/// from inside the hosted page goes out unauthenticated and is rejected by
/// session auth.
///
/// `allow-same-origin` is deliberately **excluded** — including it would
/// give the document back its real origin identity (and therefore its
/// cookies), defeating the whole mitigation. The other tokens
/// (`allow-scripts`, `allow-forms`, `allow-modals`, `allow-popups`,
/// `allow-pointer-lock`) restore the interactivity a static site legitimately
/// needs (JS execution, forms, `alert`/`confirm`, `window.open`, pointer
/// lock) without restoring origin identity.
pub const CSP_SANDBOX: &str =
"sandbox allow-scripts allow-forms allow-modals allow-popups allow-pointer-lock";
/// `X-Content-Type-Options` value applied to every response.
///
/// Prevents browsers from MIME-sniffing a response body into a different
/// content type than the one we declare — pairs with [`mime_for_path`](super::mime::mime_for_path)
/// serving strictly extension-derived, allowlisted MIME types.
pub const NOSNIFF: &str = "nosniff";
/// `Referrer-Policy` value applied to every hosted-page response.
///
/// Hosted pages can contain arbitrary/untrusted content; never leak the
/// referring `pages.elijah.run/<slug>/…` URL (which could itself be
/// sensitive, e.g. reveal an unlisted page slug) to third-party
/// destinations a hosted page links out to.
pub const REFERRER_POLICY: &str = "no-referrer";
/// `Cache-Control` value applied to served page content (HTML and assets).
///
/// Short, public cache: content changes only on delete+re-upload (v0 has no
/// in-place update, per design §5), so a five-minute edge/browser cache is
/// safe and reduces R2 read volume without risking long-lived staleness.
pub const USER_CONTENT_CACHE_CONTROL: &str = "public, max-age=300";
/// HTTP header name for [`CSP_SANDBOX`].
pub const HEADER_CONTENT_SECURITY_POLICY: &str = "Content-Security-Policy";
/// HTTP header name for [`NOSNIFF`].
pub const HEADER_X_CONTENT_TYPE_OPTIONS: &str = "X-Content-Type-Options";
/// HTTP header name for [`REFERRER_POLICY`].
pub const HEADER_REFERRER_POLICY: &str = "Referrer-Policy";
/// HTTP header name for [`USER_CONTENT_CACHE_CONTROL`].
pub const HEADER_CACHE_CONTROL: &str = "Cache-Control";
/// `Access-Control-Allow-Origin` value applied to every hosted-page response
/// (design §4.7).
///
/// The CSP `sandbox` directive ([`CSP_SANDBOX`]) gives hosted documents an
/// opaque origin, so a page's own assets (e.g. `fetch()`ing a sibling JSON
/// file, or a `<script src>` load in some browsers' fetch-metadata paths)
/// are technically cross-origin from the document's own perspective. A
/// wide-open `*` is safe here specifically *because* of the sandbox: opaque
/// origins never carry credentials, and the served content is public static
/// data anyway (design §4.1: `GET /api/pages` already lists every slug
/// publicly).
pub const ACCESS_CONTROL_ALLOW_ORIGIN: &str = "*";
/// HTTP header name for [`ACCESS_CONTROL_ALLOW_ORIGIN`].
pub const HEADER_ACCESS_CONTROL_ALLOW_ORIGIN: &str = "Access-Control-Allow-Origin";
/// Builds the `(name, value)` security header pairs a hosted-page *hit*
/// response should carry (pages-shqc, design §2.1 addendum), conditioned on
/// whether the page's [effective trust](crate::db::PageRow::effective_trust)
/// is `true`.
///
/// - `trusted == false` (the default for every page): exactly today's
/// header set — [`CSP_SANDBOX`] plus the other four.
/// - `trusted == true` (admin-designated, via `pages.trusted` or the owning
/// user's `users.trusted`): the **same** four headers, with the
/// `Content-Security-Policy` sandbox omitted entirely, so the document
/// keeps its real `pages.elijah.run` origin — and therefore
/// `localStorage`/`IndexedDB` — for local-first apps (e.g. a habit
/// tracker) that need them.
///
/// `X-Content-Type-Options`, `Referrer-Policy`, `Cache-Control`, and
/// `Access-Control-Allow-Origin` are applied **unconditionally** either way:
/// none of them grant origin identity or credentialed access the way the CSP
/// sandbox's absence does, so there is no trust-based reason to vary them.
///
/// This is deliberately a pure function returning header name/value pairs
/// (not touching `axum`/`http` types) so it compiles and is unit-tested on
/// the native host target; `routes::serve` (wasm-only) is the sole caller,
/// applying whatever this returns to a real response builder.
///
/// Callers that don't yet know a page's trust (an unknown/invalid slug, or
/// the redirect from `/{slug}` to `/{slug}/`) should *not* call this with a
/// guessed value — `routes::serve` keeps those paths on the unconditional
/// [`CSP_SANDBOX`] constant directly instead (see its module docs): no D1
/// lookup has happened yet, so there is no trust value to condition on.
///
/// # Examples
///
/// ```
/// use pages::core::headers::{user_content_headers, HEADER_CONTENT_SECURITY_POLICY};
///
/// let untrusted = user_content_headers(false);
/// assert!(untrusted.iter().any(|(k, _)| *k == HEADER_CONTENT_SECURITY_POLICY));
///
/// let trusted = user_content_headers(true);
/// assert!(!trusted.iter().any(|(k, _)| *k == HEADER_CONTENT_SECURITY_POLICY));
/// ```
pub fn user_content_headers(trusted: bool) -> Vec<(&'static str, &'static str)> {
let mut headers = vec![
(HEADER_X_CONTENT_TYPE_OPTIONS, NOSNIFF),
(HEADER_REFERRER_POLICY, REFERRER_POLICY),
(HEADER_CACHE_CONTROL, USER_CONTENT_CACHE_CONTROL),
(
HEADER_ACCESS_CONTROL_ALLOW_ORIGIN,
ACCESS_CONTROL_ALLOW_ORIGIN,
),
];
if !trusted {
headers.push((HEADER_CONTENT_SECURITY_POLICY, CSP_SANDBOX));
}
headers
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn csp_sandbox_excludes_allow_same_origin() {
assert!(!CSP_SANDBOX.contains("allow-same-origin"));
}
#[test]
fn csp_sandbox_is_exact_design_value() {
assert_eq!(
CSP_SANDBOX,
"sandbox allow-scripts allow-forms allow-modals allow-popups allow-pointer-lock"
);
}
#[test]
fn csp_sandbox_starts_with_sandbox_directive() {
assert!(CSP_SANDBOX.starts_with("sandbox "));
}
#[test]
fn nosniff_value() {
assert_eq!(NOSNIFF, "nosniff");
}
#[test]
fn referrer_policy_value() {
assert_eq!(REFERRER_POLICY, "no-referrer");
}
#[test]
fn cache_control_value() {
assert_eq!(USER_CONTENT_CACHE_CONTROL, "public, max-age=300");
}
#[test]
fn header_name_constants_are_well_formed() {
for name in [
HEADER_CONTENT_SECURITY_POLICY,
HEADER_X_CONTENT_TYPE_OPTIONS,
HEADER_REFERRER_POLICY,
HEADER_CACHE_CONTROL,
HEADER_ACCESS_CONTROL_ALLOW_ORIGIN,
] {
assert!(!name.is_empty());
assert!(name.is_ascii());
assert!(!name.contains(' '));
}
}
#[test]
fn access_control_allow_origin_is_wildcard() {
assert_eq!(ACCESS_CONTROL_ALLOW_ORIGIN, "*");
}
// ── user_content_headers ────────────────────────────────────────────
#[test]
fn untrusted_carries_sandbox_csp() {
let headers = user_content_headers(false);
assert!(headers.contains(&(HEADER_CONTENT_SECURITY_POLICY, CSP_SANDBOX)));
}
#[test]
fn trusted_omits_csp_entirely() {
let headers = user_content_headers(true);
assert!(!headers
.iter()
.any(|(k, _)| *k == HEADER_CONTENT_SECURITY_POLICY));
}
#[test]
fn sandbox_present_iff_untrusted() {
for trusted in [false, true] {
let has_csp = user_content_headers(trusted)
.iter()
.any(|(k, _)| *k == HEADER_CONTENT_SECURITY_POLICY);
assert_eq!(has_csp, !trusted);
}
}
#[test]
fn other_four_headers_always_present_untrusted() {
let headers = user_content_headers(false);
assert!(headers.contains(&(HEADER_X_CONTENT_TYPE_OPTIONS, NOSNIFF)));
assert!(headers.contains(&(HEADER_REFERRER_POLICY, REFERRER_POLICY)));
assert!(headers.contains(&(HEADER_CACHE_CONTROL, USER_CONTENT_CACHE_CONTROL)));
assert!(headers.contains(&(
HEADER_ACCESS_CONTROL_ALLOW_ORIGIN,
ACCESS_CONTROL_ALLOW_ORIGIN
)));
}
#[test]
fn other_four_headers_always_present_trusted() {
let headers = user_content_headers(true);
assert!(headers.contains(&(HEADER_X_CONTENT_TYPE_OPTIONS, NOSNIFF)));
assert!(headers.contains(&(HEADER_REFERRER_POLICY, REFERRER_POLICY)));
assert!(headers.contains(&(HEADER_CACHE_CONTROL, USER_CONTENT_CACHE_CONTROL)));
assert!(headers.contains(&(
HEADER_ACCESS_CONTROL_ALLOW_ORIGIN,
ACCESS_CONTROL_ALLOW_ORIGIN
)));
}
#[test]
fn trusted_header_count_is_one_fewer_than_untrusted() {
assert_eq!(
user_content_headers(true).len() + 1,
user_content_headers(false).len()
);
}
}

@ -0,0 +1,171 @@
//! Extension → MIME type allowlist.
//!
//! See `docs/plans/2026-07-12-pages-design.md` §2.6: MIME types are derived
//! **only** from the file extension via a fixed allowlist, never sniffed
//! from content. Unknown or missing extensions serve as
//! `application/octet-stream`. `.html`/`.htm` are the only extensions that
//! ever map to `text/html` — this matters because `text/html` is the only
//! MIME type a browser will render as a navigable document, so keeping the
//! set that maps to it minimal limits the surface for content-type
//! confusion attacks against `X-Content-Type-Options: nosniff` responses.
/// MIME type served when an extension is missing or not in [`MIME_TABLE`].
const DEFAULT_MIME: &str = "application/octet-stream";
/// Extension (lowercase, no leading dot) → MIME type allowlist.
///
/// Taken from the bean body for pages-saom. Comparison against this table
/// is case-insensitive (see [`mime_for_path`]).
const MIME_TABLE: &[(&str, &str)] = &[
("html", "text/html"),
("htm", "text/html"),
("css", "text/css"),
("js", "text/javascript"),
("mjs", "text/javascript"),
("json", "application/json"),
("txt", "text/plain"),
("md", "text/markdown"),
("png", "image/png"),
("jpg", "image/jpeg"),
("jpeg", "image/jpeg"),
("gif", "image/gif"),
("svg", "image/svg+xml"),
("webp", "image/webp"),
("avif", "image/avif"),
("ico", "image/x-icon"),
("woff", "font/woff"),
("woff2", "font/woff2"),
("ttf", "font/ttf"),
("otf", "font/otf"),
("wasm", "application/wasm"),
("map", "application/json"),
("xml", "application/xml"),
("webmanifest", "application/manifest+json"),
("mp3", "audio/mpeg"),
("mp4", "video/mp4"),
("webm", "video/webm"),
("pdf", "application/pdf"),
];
/// Looks up the MIME type to serve for `path` based on its extension.
///
/// `path` may be a bare filename or a `/`-separated relative path (only the
/// final segment is considered). The extension is matched case-insensitively
/// against [`MIME_TABLE`]; a missing or unrecognized extension returns
/// `"application/octet-stream"`. A leading dot (dotfile with no further
/// extension, e.g. `.gitignore`) is never treated as an extension.
///
/// # Examples
///
/// ```
/// use pages::core::mime::mime_for_path;
///
/// assert_eq!(mime_for_path("index.html"), "text/html");
/// assert_eq!(mime_for_path("assets/app.JS"), "text/javascript");
/// assert_eq!(mime_for_path("data.bin"), "application/octet-stream");
/// assert_eq!(mime_for_path("no-extension"), "application/octet-stream");
/// ```
pub fn mime_for_path(path: &str) -> &'static str {
let filename = path.rsplit('/').next().unwrap_or(path);
let ext = match filename.rfind('.') {
Some(idx) if idx > 0 && idx + 1 < filename.len() => &filename[idx + 1..],
_ => return DEFAULT_MIME,
};
MIME_TABLE
.iter()
.find(|(candidate, _)| candidate.eq_ignore_ascii_case(ext))
.map(|(_, mime)| *mime)
.unwrap_or(DEFAULT_MIME)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn html_variants() {
assert_eq!(mime_for_path("index.html"), "text/html");
assert_eq!(mime_for_path("page.htm"), "text/html");
}
#[test]
fn only_html_and_htm_map_to_text_html() {
for (ext, mime) in MIME_TABLE {
if *mime == "text/html" {
assert!(
*ext == "html" || *ext == "htm",
"unexpected extension {ext:?} mapped to text/html"
);
}
}
}
#[test]
fn spot_check_majors() {
assert_eq!(mime_for_path("style.css"), "text/css");
assert_eq!(mime_for_path("app.js"), "text/javascript");
assert_eq!(mime_for_path("module.mjs"), "text/javascript");
assert_eq!(mime_for_path("data.json"), "application/json");
assert_eq!(mime_for_path("readme.txt"), "text/plain");
assert_eq!(mime_for_path("readme.md"), "text/markdown");
assert_eq!(mime_for_path("logo.png"), "image/png");
assert_eq!(mime_for_path("photo.jpg"), "image/jpeg");
assert_eq!(mime_for_path("photo.jpeg"), "image/jpeg");
assert_eq!(mime_for_path("anim.gif"), "image/gif");
assert_eq!(mime_for_path("icon.svg"), "image/svg+xml");
assert_eq!(mime_for_path("pic.webp"), "image/webp");
assert_eq!(mime_for_path("pic.avif"), "image/avif");
assert_eq!(mime_for_path("favicon.ico"), "image/x-icon");
assert_eq!(mime_for_path("font.woff"), "font/woff");
assert_eq!(mime_for_path("font.woff2"), "font/woff2");
assert_eq!(mime_for_path("font.ttf"), "font/ttf");
assert_eq!(mime_for_path("font.otf"), "font/otf");
assert_eq!(mime_for_path("app.wasm"), "application/wasm");
assert_eq!(mime_for_path("app.js.map"), "application/json");
assert_eq!(mime_for_path("feed.xml"), "application/xml");
assert_eq!(
mime_for_path("app.webmanifest"),
"application/manifest+json"
);
assert_eq!(mime_for_path("track.mp3"), "audio/mpeg");
assert_eq!(mime_for_path("clip.mp4"), "video/mp4");
assert_eq!(mime_for_path("clip.webm"), "video/webm");
assert_eq!(mime_for_path("doc.pdf"), "application/pdf");
}
#[test]
fn unknown_extension_is_octet_stream() {
assert_eq!(mime_for_path("archive.rar"), DEFAULT_MIME);
assert_eq!(mime_for_path("binary.exe"), DEFAULT_MIME);
}
#[test]
fn no_extension_is_octet_stream() {
assert_eq!(mime_for_path("README"), DEFAULT_MIME);
assert_eq!(mime_for_path("Makefile"), DEFAULT_MIME);
}
#[test]
fn dotfile_is_octet_stream() {
assert_eq!(mime_for_path(".gitignore"), DEFAULT_MIME);
}
#[test]
fn trailing_dot_is_octet_stream() {
assert_eq!(mime_for_path("weird."), DEFAULT_MIME);
}
#[test]
fn case_insensitive() {
assert_eq!(mime_for_path("IMAGE.PNG"), "image/png");
assert_eq!(mime_for_path("Style.Css"), "text/css");
}
#[test]
fn uses_final_path_segment() {
assert_eq!(mime_for_path("assets/sub/dir/app.js"), "text/javascript");
assert_eq!(mime_for_path("weird.dir/index.html"), "text/html");
}
}

@ -0,0 +1,22 @@
//! Core policy modules for `pages`.
//!
//! Everything under `core` is pure Rust with no dependency on `worker` or
//! `axum` — it compiles for both the native host target and
//! `wasm32-unknown-unknown`, and is unit-tested natively (`cargo test`).
//! This is the security-critical surface of the crate: slug grammar and
//! reserved-name enforcement ([`slug`]), extension→MIME mapping ([`mime`]),
//! upload/zip-bomb/path-traversal validation ([`upload`]), the security
//! header constants served with every hosted-page response ([`headers`]),
//! the `Origin`-header / page-ownership checks for mutating API routes
//! ([`origin`]), and the R2 key candidate resolution for serving hosted
//! pages ([`serve_path`]).
//!
//! See `docs/plans/2026-07-12-pages-design.md` §2.1, §2.2, §2.3, §2.6, §4.5,
//! §4.7 for the design rationale behind each module.
pub mod headers;
pub mod mime;
pub mod origin;
pub mod serve_path;
pub mod slug;
pub mod upload;

@ -0,0 +1,144 @@
//! Pure authorization helpers for the mutating API routes: the `Origin`
//! header check (design §2.1) and the page-delete ownership check.
//!
//! Both are simple boolean predicates kept here — rather than inline in
//! `routes::api` — so they compile natively and are unit-tested without any
//! `worker`/`axum` dependency, per the crate's "pure policy lives in `core`"
//! convention (design §4.5).
/// Returns `true` only if `origin_header` is present and exactly equals
/// `base_url`.
///
/// The `Origin` header is **required** on every mutating API request
/// (`POST`/`PATCH`/`DELETE`) — a missing header is treated as *not*
/// allowed, never as an implicit pass (design §2.1). This closes the
/// residual same-origin CSRF risk from hosted pages: a document served
/// under the §2.1 CSP `sandbox` (deliberately without `allow-same-origin`)
/// has an opaque origin, so any `fetch()` it issues either omits `Origin`
/// entirely or sends the literal string `"null"` — neither of which can
/// ever equal a real `https://...` `base_url`.
///
/// No normalization is performed: a trailing slash, different case, or
/// different scheme on an otherwise-matching origin fails the check —
/// `base_url` (from Worker config) and the `Origin` header (browser-set,
/// always scheme+host+port, never a path) are expected to already be in
/// directly-comparable form.
///
/// # Examples
///
/// ```
/// use pages::core::origin::origin_allowed;
///
/// assert!(origin_allowed(Some("https://pages.elijah.run"), "https://pages.elijah.run"));
/// assert!(!origin_allowed(None, "https://pages.elijah.run"));
/// assert!(!origin_allowed(Some("null"), "https://pages.elijah.run"));
/// assert!(!origin_allowed(Some("https://evil.example"), "https://pages.elijah.run"));
/// ```
pub fn origin_allowed(origin_header: Option<&str>, base_url: &str) -> bool {
origin_header == Some(base_url)
}
/// Returns `true` if `user_id` may delete a page owned by `owner_id` —
/// either they own it, or they are an admin (design §2.4: "page owners can
/// delete their own pages"; admins can delete any page).
///
/// # Examples
///
/// ```
/// use pages::core::origin::can_delete;
///
/// assert!(can_delete(1, false, 1)); // owner
/// assert!(can_delete(2, true, 1)); // admin, not owner
/// assert!(!can_delete(2, false, 1)); // neither
/// ```
pub fn can_delete(user_id: i64, is_admin: bool, owner_id: i64) -> bool {
is_admin || user_id == owner_id
}
#[cfg(test)]
mod tests {
use super::*;
// ── origin_allowed ───────────────────────────────────────────────────
#[test]
fn exact_match_allowed() {
assert!(origin_allowed(
Some("https://pages.elijah.run"),
"https://pages.elijah.run"
));
}
#[test]
fn missing_header_rejected() {
assert!(!origin_allowed(None, "https://pages.elijah.run"));
}
#[test]
fn null_origin_rejected() {
// Sandboxed opaque-origin documents send this literal value.
assert!(!origin_allowed(Some("null"), "https://pages.elijah.run"));
}
#[test]
fn mismatched_origin_rejected() {
assert!(!origin_allowed(
Some("https://evil.example"),
"https://pages.elijah.run"
));
}
#[test]
fn trailing_slash_mismatch_rejected() {
assert!(!origin_allowed(
Some("https://pages.elijah.run/"),
"https://pages.elijah.run"
));
}
#[test]
fn case_sensitive_mismatch_rejected() {
assert!(!origin_allowed(
Some("https://Pages.elijah.run"),
"https://pages.elijah.run"
));
}
#[test]
fn different_scheme_rejected() {
assert!(!origin_allowed(
Some("http://pages.elijah.run"),
"https://pages.elijah.run"
));
}
#[test]
fn empty_base_url_requires_matching_empty_origin() {
// Defensive: pins down literal-equality semantics rather than any
// implicit "empty means wildcard" behavior.
assert!(origin_allowed(Some(""), ""));
assert!(!origin_allowed(None, ""));
}
// ── can_delete ───────────────────────────────────────────────────────
#[test]
fn owner_can_delete_own_page() {
assert!(can_delete(1, false, 1));
}
#[test]
fn admin_can_delete_others_page() {
assert!(can_delete(2, true, 1));
}
#[test]
fn non_owner_non_admin_cannot_delete() {
assert!(!can_delete(2, false, 1));
}
#[test]
fn admin_can_delete_own_page_too() {
assert!(can_delete(1, true, 1));
}
}

@ -0,0 +1,308 @@
//! Pure path-resolution helper for hosted-page serving from R2 (design
//! §4.7, pages-adl9).
//!
//! [`resolve_candidates`] turns `(slug, rest)` — the page's slug and
//! whatever came after it in the request path — into an ordered list of R2
//! object keys to try, without ever touching R2 itself. Keeping this pure
//! (no `worker`/`axum` dependency) means it compiles natively and is
//! unit-tested by `cargo test` rather than only exercisable in a deployed
//! Worker.
//!
//! # What this function assumes about `rest`
//!
//! `routes::serve` calls this with `rest` taken from axum's `{*rest}`
//! wildcard capture (via the [`Path`](axum::extract::Path) extractor).
//! Axum's `Path` extractor **percent-decodes** captured segments and
//! rejects (with an automatic `400`, before the handler even runs) any
//! capture that isn't valid UTF-8 after decoding — see
//! `axum::extract::path`'s module docs ("Any percent encoded parameters
//! will be automatically decoded"). So by the time a value reaches
//! [`resolve_candidates`]:
//!
//! - it is already percent-decoded (a request for `%2e%2e` arrives here as
//! the literal two characters `..`, which the traversal check below
//! catches directly — there is no separate percent-decoding step to
//! perform or bypass here);
//! - it is guaranteed valid UTF-8;
//! - per the `matchit` router `axum` uses, a capture from the route
//! `/{slug}/{*rest}` never itself carries a leading `/` (matchit strips
//! the separator before the wildcard) — the leading-`/` rejection below
//! is defense-in-depth for any other caller of this pure function, not a
//! case the wired-up route can actually produce.
//!
//! This function performs no percent-decoding of its own and must not be
//! fed raw (still-encoded) request paths.
/// Maximum accepted length, in bytes, of `rest` — anything longer is
/// rejected outright (returns no candidates) rather than attempting key
/// construction. Generous for any legitimate static-asset path while
/// bounding the cost of the safety checks below.
pub const MAX_REST_LEN: usize = 512;
/// Resolves the ordered list of R2 object keys to try for a
/// `GET /<slug>[/<rest>]` request (design §4.7).
///
/// `slug` is assumed already validated (see
/// [`validate_slug`](super::slug::validate_slug)) — this function does not
/// re-check slug grammar or reserved names, only joins it with `rest`.
///
/// Candidate rules, in order:
///
/// - `rest` empty or exactly `"/"` (i.e. `GET /<slug>` normalized to a
/// trailing slash, or `GET /<slug>/` itself) → the page's root index:
/// `["<slug>/index.html"]`.
/// - `rest` ending in `/` (a directory-style path) → that directory's
/// index only: `["<slug>/<dir>/index.html"]`.
/// - `rest`'s final segment has a file extension (a `.` that is neither
/// the first character of the segment nor its last) → an exact match
/// only: `["<slug>/<rest>"]`.
/// - otherwise (no trailing slash, no extension on the final segment) →
/// try the exact key first, then fall back to that path treated as a
/// directory: `["<slug>/<rest>", "<slug>/<rest>/index.html"]`.
///
/// Before any of the above, `rest` is rejected outright — returning an
/// empty `Vec` rather than any candidate — if it:
///
/// - exceeds [`MAX_REST_LEN`] bytes;
/// - contains `..` (path traversal), `//` (empty segment / ambiguous
/// join), `\` (Windows-style separator, meaningless in an R2 key but
/// rejected as a defensive measure), or a NUL byte;
/// - starts with `/` (other than the bare `"/"` case handled above) — see
/// the module docs for why the wired-up route never actually produces
/// this, but a direct caller of this pure function might.
///
/// An empty return value is the caller's signal to serve a 404 without
/// ever calling R2.
///
/// # Examples
///
/// ```
/// use pages::core::serve_path::resolve_candidates;
///
/// assert_eq!(resolve_candidates("my-page", ""), vec!["my-page/index.html"]);
/// assert_eq!(resolve_candidates("my-page", "/"), vec!["my-page/index.html"]);
/// assert_eq!(
/// resolve_candidates("my-page", "foo/bar.js"),
/// vec!["my-page/foo/bar.js"]
/// );
/// assert_eq!(
/// resolve_candidates("my-page", "foo/"),
/// vec!["my-page/foo/index.html"]
/// );
/// assert_eq!(
/// resolve_candidates("my-page", "foo"),
/// vec!["my-page/foo", "my-page/foo/index.html"]
/// );
/// assert!(resolve_candidates("my-page", "../secret").is_empty());
/// ```
pub fn resolve_candidates(slug: &str, rest: &str) -> Vec<String> {
if !is_rest_safe(rest) {
return Vec::new();
}
if rest.is_empty() || rest == "/" {
return vec![format!("{slug}/index.html")];
}
if let Some(dir) = rest.strip_suffix('/') {
return vec![format!("{slug}/{dir}/index.html")];
}
let filename = rest.rsplit('/').next().unwrap_or(rest);
if has_extension(filename) {
vec![format!("{slug}/{rest}")]
} else {
vec![
format!("{slug}/{rest}"),
format!("{slug}/{rest}/index.html"),
]
}
}
/// `true` if `rest` passes all traversal/length/shape checks documented on
/// [`resolve_candidates`].
fn is_rest_safe(rest: &str) -> bool {
if rest.len() > MAX_REST_LEN {
return false;
}
// The `..` check is intentionally substring-broad: it also rejects
// legitimate names like `v1..2.css`. Failing closed is preferred over
// segment-precise parsing here.
if rest.contains("..") || rest.contains("//") || rest.contains('\\') || rest.contains('\0') {
return false;
}
if rest.starts_with('/') && rest != "/" {
return false;
}
true
}
/// `true` if `filename` (a single path segment, no `/`) has a file
/// extension: a `.` that is neither its first nor its last character.
/// Mirrors [`mime_for_path`](super::mime::mime_for_path)'s definition of
/// "extension" so a dotfile like `.gitignore` (no extension by this rule)
/// is treated the same way by both modules.
fn has_extension(filename: &str) -> bool {
matches!(filename.rfind('.'), Some(idx) if idx > 0 && idx + 1 < filename.len())
}
#[cfg(test)]
mod tests {
use super::*;
// ── happy paths from the ticket spec ────────────────────────────────
#[test]
fn empty_rest_is_root_index() {
assert_eq!(
resolve_candidates("my-page", ""),
vec!["my-page/index.html"]
);
}
#[test]
fn bare_slash_rest_is_root_index() {
assert_eq!(
resolve_candidates("my-page", "/"),
vec!["my-page/index.html"]
);
}
#[test]
fn extensioned_rest_is_exact_only() {
assert_eq!(
resolve_candidates("my-page", "foo/bar.js"),
vec!["my-page/foo/bar.js"]
);
}
#[test]
fn top_level_extensioned_rest_is_exact_only() {
assert_eq!(
resolve_candidates("my-page", "app.css"),
vec!["my-page/app.css"]
);
}
#[test]
fn trailing_slash_rest_is_directory_index() {
assert_eq!(
resolve_candidates("my-page", "foo/"),
vec!["my-page/foo/index.html"]
);
}
#[test]
fn nested_trailing_slash_rest_is_directory_index() {
assert_eq!(
resolve_candidates("my-page", "foo/bar/"),
vec!["my-page/foo/bar/index.html"]
);
}
#[test]
fn extensionless_rest_tries_exact_then_directory_index() {
assert_eq!(
resolve_candidates("my-page", "foo"),
vec!["my-page/foo", "my-page/foo/index.html"]
);
}
#[test]
fn nested_extensionless_rest_tries_exact_then_directory_index() {
assert_eq!(
resolve_candidates("my-page", "foo/bar"),
vec!["my-page/foo/bar", "my-page/foo/bar/index.html"]
);
}
#[test]
fn dotfile_final_segment_is_treated_as_extensionless() {
// Leading dot with nothing after is not an "extension" by this
// module's rule (matches core::mime's convention) — so it still
// gets the two-candidate exact+directory-index treatment.
assert_eq!(
resolve_candidates("my-page", ".well-known"),
vec!["my-page/.well-known", "my-page/.well-known/index.html"]
);
}
// ── traversal / malformed rejection ─────────────────────────────────
#[test]
fn rejects_dot_dot_traversal() {
assert!(resolve_candidates("my-page", "../secret").is_empty());
assert!(resolve_candidates("my-page", "foo/../../etc/passwd").is_empty());
assert!(resolve_candidates("my-page", "foo/..").is_empty());
assert!(resolve_candidates("my-page", "..").is_empty());
}
#[test]
fn rejects_double_slash() {
assert!(resolve_candidates("my-page", "foo//bar").is_empty());
assert!(resolve_candidates("my-page", "//etc/passwd").is_empty());
}
#[test]
fn rejects_backslash() {
assert!(resolve_candidates("my-page", "foo\\bar").is_empty());
assert!(resolve_candidates("my-page", "..\\windows").is_empty());
}
#[test]
fn rejects_nul_byte() {
assert!(resolve_candidates("my-page", "foo\0bar").is_empty());
}
#[test]
fn rejects_leading_slash_other_than_bare_slash() {
assert!(resolve_candidates("my-page", "/etc/passwd").is_empty());
assert!(resolve_candidates("my-page", "/foo").is_empty());
}
#[test]
fn bare_slash_is_not_rejected_by_leading_slash_check() {
// Distinguished from the general "leading slash" rejection above —
// this exact single-character case is the normalized "no rest"
// case and must resolve, not reject.
assert_eq!(
resolve_candidates("my-page", "/"),
vec!["my-page/index.html"]
);
}
#[test]
fn rejects_overlong_rest() {
let too_long = "a".repeat(MAX_REST_LEN + 1);
assert!(resolve_candidates("my-page", &too_long).is_empty());
}
#[test]
fn accepts_max_length_rest() {
// Exactly at the limit, with an extension so it resolves to a
// single exact-match candidate.
let filename = format!("{}.txt", "a".repeat(MAX_REST_LEN - 4));
assert_eq!(filename.len(), MAX_REST_LEN);
let candidates = resolve_candidates("my-page", &filename);
assert_eq!(candidates, vec![format!("my-page/{filename}")]);
}
#[test]
fn percent_encoded_traversal_after_decoding_is_rejected() {
// Simulates what `resolve_candidates` sees *after* axum's `Path`
// extractor has already percent-decoded `%2e%2e%2fsecret` into
// `../secret` — see the module docs for why decoding itself is not
// this function's job.
assert!(resolve_candidates("my-page", "../secret").is_empty());
}
// ── slug is just joined, not re-validated ───────────────────────────
#[test]
fn slug_is_joined_verbatim() {
assert_eq!(
resolve_candidates("abc-123", "index.html"),
vec!["abc-123/index.html"]
);
}
}

@ -0,0 +1,390 @@
//! Slug (page name) grammar and reserved-name enforcement.
//!
//! See `docs/plans/2026-07-12-pages-design.md` §2.3. Slugs identify a page
//! at `pages.elijah.run/<slug>/`, so the grammar is intentionally strict:
//! lowercase alphanumerics and hyphens only, no leading hyphen, at most 63
//! characters. Uploaded names are validated as-is — [`validate_slug`] never
//! "cleans up" or lowercases its input; the caller is expected to already
//! have a lowercase candidate (e.g. one produced by
//! [`default_slug_from_filename`], or a value that has itself been
//! lower-cased before validation).
/// Reserved slugs that would collide with a fixed route or a
/// Cloudflare-internal path prefix.
///
/// Taken verbatim from design §2.3. A few entries (`favicon.ico`,
/// `robots.txt`, `sitemap.xml`, `index.html`) contain a `.`, which
/// [`validate_slug`]'s character grammar already rejects on its own — they
/// are kept in this list anyway as defense in depth in case the grammar
/// ever changes to allow dots. `well-known` is reserved as written in the
/// design doc; the "real" collision risk is the `.well-known` HTTP
/// convention, but since dots are invalid slug characters that distinction
/// is moot.
const RESERVED: &[&str] = &[
"api",
"auth",
"admin",
"assets",
"static",
"login",
"logout",
"oauth",
"favicon.ico",
"robots.txt",
"sitemap.xml",
"index.html",
"www",
"health",
"status",
"well-known",
"cdn-cgi",
];
/// A default slug substituted when a derived candidate is empty or
/// collides with a reserved name.
const FALLBACK_SLUG: &str = "my-page";
/// Errors returned by [`validate_slug`].
///
/// Each variant carries a user-facing message suitable for surfacing
/// directly in an API error response.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum SlugError {
/// The slug was the empty string.
#[error("page name must not be empty")]
Empty,
/// The slug was longer than 63 characters.
#[error("page name must be at most 63 characters")]
TooLong,
/// The first character was not `[a-z0-9]` (e.g. a leading hyphen).
#[error("page name must start with a lowercase letter or digit")]
InvalidStart,
/// A character other than `[a-z0-9-]` was found.
#[error(
"page name contains invalid character '{0}': only lowercase letters, digits, and hyphens are allowed"
)]
InvalidChar(char),
/// The slug exactly matches a reserved name.
#[error("'{0}' is a reserved name and cannot be used as a page name")]
Reserved(String),
}
/// Validates `slug` against the grammar `^[a-z0-9][a-z0-9-]{0,62}$` and the
/// [`RESERVED`] name list.
///
/// This function performs no normalization: the input must already be
/// lowercase and otherwise well-formed, or validation fails. Callers that
/// want a friendly suggested slug from a filename should use
/// [`default_slug_from_filename`] first — but note the derived value must
/// still be passed through `validate_slug` before use, since a caller could
/// also supply their own slug directly.
///
/// # Examples
///
/// ```
/// use pages::core::slug::{validate_slug, SlugError};
///
/// assert!(validate_slug("my-project").is_ok());
/// assert_eq!(validate_slug(""), Err(SlugError::Empty));
/// assert_eq!(validate_slug("-leading"), Err(SlugError::InvalidStart));
/// assert_eq!(validate_slug("has-Caps"), Err(SlugError::InvalidChar('C')));
/// assert_eq!(validate_slug("api"), Err(SlugError::Reserved("api".to_string())));
/// ```
pub fn validate_slug(slug: &str) -> Result<(), SlugError> {
if slug.is_empty() {
return Err(SlugError::Empty);
}
if slug.chars().count() > 63 {
return Err(SlugError::TooLong);
}
let first = slug.chars().next().expect("checked non-empty above");
if !is_slug_start_char(first) {
return Err(SlugError::InvalidStart);
}
for c in slug.chars() {
if !is_slug_char(c) {
return Err(SlugError::InvalidChar(c));
}
}
if RESERVED.contains(&slug) {
return Err(SlugError::Reserved(slug.to_string()));
}
Ok(())
}
/// `true` for `[a-z0-9]` — valid as the first character of a slug.
fn is_slug_start_char(c: char) -> bool {
c.is_ascii_lowercase() || c.is_ascii_digit()
}
/// `true` for `[a-z0-9-]` — valid anywhere in a slug.
fn is_slug_char(c: char) -> bool {
is_slug_start_char(c) || c == '-'
}
/// Derives a suggested slug from an uploaded filename.
///
/// Strips the final extension (the substring after the last `.`, provided
/// that `.` is not the first character — so dotfiles like `.hidden` are not
/// treated as extension-less-name plus empty extension), lowercases the
/// remainder, maps every character outside `[a-z0-9]` to `-`, collapses
/// consecutive `-`, trims leading/trailing `-`, and truncates to 63
/// characters (re-trimming any trailing `-` exposed by truncation). If the
/// result is empty or collides with a [reserved name](RESERVED), the
/// fallback `"my-page"` is returned instead.
///
/// This produces a *suggestion* only — the result is not guaranteed unique
/// (the caller must still check for a slug collision) and, while it is
/// constructed to satisfy the slug grammar, callers must still run it
/// through [`validate_slug`] before treating it as valid input.
///
/// # Examples
///
/// ```
/// use pages::core::slug::default_slug_from_filename;
///
/// assert_eq!(default_slug_from_filename("My Project.ZIP"), "my-project");
/// assert_eq!(default_slug_from_filename("archive.tar.gz"), "archive-tar");
/// assert_eq!(default_slug_from_filename(""), "my-page");
/// assert_eq!(default_slug_from_filename("API.zip"), "my-page");
/// ```
pub fn default_slug_from_filename(filename: &str) -> String {
let stem = match filename.rfind('.') {
Some(idx) if idx > 0 => &filename[..idx],
_ => filename,
};
let mut mapped = String::with_capacity(stem.len());
let mut last_was_hyphen = false;
for c in stem.to_lowercase().chars() {
let out = if c.is_ascii_lowercase() || c.is_ascii_digit() {
c
} else {
'-'
};
if out == '-' {
if last_was_hyphen {
continue;
}
last_was_hyphen = true;
} else {
last_was_hyphen = false;
}
mapped.push(out);
}
let trimmed = mapped.trim_matches('-');
let mut truncated: String = trimmed.chars().take(63).collect();
while truncated.ends_with('-') {
truncated.pop();
}
if truncated.is_empty() || RESERVED.contains(&truncated.as_str()) {
FALLBACK_SLUG.to_string()
} else {
truncated
}
}
#[cfg(test)]
mod tests {
use super::*;
// ── validate_slug: valid cases ──────────────────────────────────────
#[test]
fn valid_simple() {
assert!(validate_slug("myproject").is_ok());
}
#[test]
fn valid_with_hyphens_and_digits() {
assert!(validate_slug("my-project-123").is_ok());
}
#[test]
fn valid_single_char() {
assert!(validate_slug("a").is_ok());
assert!(validate_slug("9").is_ok());
}
#[test]
fn valid_max_length_63() {
let slug = "a".repeat(63);
assert!(validate_slug(&slug).is_ok());
}
#[test]
fn valid_trailing_hyphen_allowed() {
// Grammar only forbids a *leading* hyphen.
assert!(validate_slug("trailing-").is_ok());
}
// ── validate_slug: rejection classes ────────────────────────────────
#[test]
fn rejects_empty() {
assert_eq!(validate_slug(""), Err(SlugError::Empty));
}
#[test]
fn rejects_uppercase_mid_string() {
assert_eq!(validate_slug("myProject"), Err(SlugError::InvalidChar('P')));
}
#[test]
fn rejects_leading_uppercase() {
assert_eq!(validate_slug("Project"), Err(SlugError::InvalidStart));
}
#[test]
fn rejects_leading_hyphen() {
assert_eq!(validate_slug("-abc"), Err(SlugError::InvalidStart));
}
#[test]
fn rejects_bad_chars_underscore() {
assert_eq!(
validate_slug("my_project"),
Err(SlugError::InvalidChar('_'))
);
}
#[test]
fn rejects_bad_chars_dot() {
assert_eq!(
validate_slug("my.project"),
Err(SlugError::InvalidChar('.'))
);
}
#[test]
fn rejects_bad_chars_space() {
assert_eq!(
validate_slug("my project"),
Err(SlugError::InvalidChar(' '))
);
}
#[test]
fn rejects_bad_chars_unicode() {
assert_eq!(validate_slug("café"), Err(SlugError::InvalidChar('é')));
}
#[test]
fn rejects_too_long() {
let slug = "a".repeat(64);
assert_eq!(validate_slug(&slug), Err(SlugError::TooLong));
}
#[test]
fn rejects_all_reserved_names() {
// Every reserved name must be rejected — but entries containing a
// `.` (favicon.ico, robots.txt, sitemap.xml, index.html) fail the
// character grammar first (InvalidChar), since dots are never
// valid slug characters. That's fine: they're still unusable as a
// slug, just via a different, equally-correct error variant. The
// remaining (dot-free) reserved names must surface as
// `SlugError::Reserved` specifically.
for name in RESERVED {
let result = validate_slug(name);
assert!(
result.is_err(),
"expected {name:?} to be rejected as a slug"
);
if name.contains('.') {
assert!(
matches!(result, Err(SlugError::InvalidChar('.'))),
"expected {name:?} to fail on the invalid '.' character, got {result:?}"
);
} else {
assert_eq!(
result,
Err(SlugError::Reserved(name.to_string())),
"expected {name:?} to be rejected as reserved"
);
}
}
}
// ── default_slug_from_filename ──────────────────────────────────────
#[test]
fn default_slug_basic() {
assert_eq!(default_slug_from_filename("My Project.ZIP"), "my-project");
}
#[test]
fn default_slug_double_extension() {
assert_eq!(default_slug_from_filename("archive.tar.gz"), "archive-tar");
}
#[test]
fn default_slug_empty_input() {
assert_eq!(default_slug_from_filename(""), "my-page");
}
#[test]
fn default_slug_reserved_result_falls_back() {
assert_eq!(default_slug_from_filename("API.zip"), "my-page");
assert_eq!(default_slug_from_filename("WWW.html"), "my-page");
}
#[test]
fn default_slug_dotfile() {
// Leading dot is not treated as an extension separator.
assert_eq!(default_slug_from_filename(".hidden"), "hidden");
}
#[test]
fn default_slug_unicode() {
assert_eq!(default_slug_from_filename("Café Résumé.pdf"), "caf-r-sum");
}
#[test]
fn default_slug_collapses_consecutive_separators() {
assert_eq!(default_slug_from_filename("a___b---c.txt"), "a-b-c");
}
#[test]
fn default_slug_trims_leading_and_trailing_hyphens() {
assert_eq!(default_slug_from_filename("--weird--.txt"), "weird");
}
#[test]
fn default_slug_truncates_to_63_and_retrims() {
let long_name = format!("{}--rest.txt", "a".repeat(70));
let result = default_slug_from_filename(&long_name);
assert!(result.chars().count() <= 63);
assert!(!result.ends_with('-'));
assert!(!result.is_empty());
}
#[test]
fn default_slug_no_extension() {
assert_eq!(default_slug_from_filename("README"), "readme");
}
#[test]
fn default_slug_result_passes_validate_slug() {
for input in [
"My Project.ZIP",
"archive.tar.gz",
"",
"API.zip",
".hidden",
"Café Résumé.pdf",
"README",
] {
let slug = default_slug_from_filename(input);
assert!(
validate_slug(&slug).is_ok(),
"derived slug {slug:?} from {input:?} failed validate_slug"
);
}
}
}

@ -0,0 +1,864 @@
//! Upload validation: turns raw uploaded bytes into a manifest of
//! `(path, bytes)` files ready to be written to R2, while defending against
//! malicious archives.
//!
//! See `docs/plans/2026-07-12-pages-design.md` §2.2. The zip-bomb defense in
//! particular is deliberately paranoid: entry sizes declared in a zip's
//! central directory are **never trusted**. Every byte is counted as it
//! streams out of the decompressor, and extraction aborts the instant a
//! per-file or total limit is crossed — so a small compressed file that
//! claims (or would produce) a huge uncompressed size is rejected long
//! before that size is ever reached, let alone materialized in memory.
use std::collections::HashSet;
use std::io::{Cursor, Read};
/// Configurable limits enforced by [`build_manifest`].
///
/// Fields are public so callers (tests, and eventually `routes::api`) can
/// construct scaled-down variants for testing or future configurability;
/// [`Default`] provides the production values from design §2.2.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct UploadLimits {
/// Maximum size of the raw uploaded bytes (compressed, for zip; as-is,
/// for html) in bytes. Checked against the buffered body before any
/// parsing happens.
pub max_compressed: usize,
/// Maximum total *uncompressed* size across all files in the archive,
/// in bytes. Enforced while streaming decompressed bytes, not from the
/// zip header.
pub max_uncompressed_total: u64,
/// Maximum *uncompressed* size of any single file, in bytes. Enforced
/// while streaming decompressed bytes, not from the zip header.
pub max_file: u64,
/// Maximum number of entries (including directories) a zip archive may
/// contain.
pub max_entries: usize,
/// Maximum length, in bytes, of a single entry path.
pub max_path_len: usize,
/// Maximum number of `/`-separated path segments (nesting depth,
/// including the filename) a single entry path may contain.
pub max_depth: usize,
}
impl Default for UploadLimits {
/// Production limits from design §2.2: 10 MiB max compressed upload,
/// 25 MiB max total uncompressed, 5 MiB max per file, 300 max entries,
/// 180-byte max path length, depth 10.
fn default() -> Self {
const MIB: u64 = 1024 * 1024;
Self {
max_compressed: (10 * MIB) as usize,
max_uncompressed_total: 25 * MIB,
max_file: 5 * MIB,
max_entries: 300,
max_path_len: 180,
max_depth: 10,
}
}
}
/// The kind of upload being validated — determines how `bytes` is
/// interpreted by [`build_manifest`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UploadKind {
/// A single raw HTML file, stored as the page's `index.html`.
Html,
/// A zip archive of static assets, extracted per design §2.2.
Zip,
}
/// One file destined for R2 storage: `path` is the R2 object key suffix
/// (relative to `<slug>/`), `bytes` is its full content.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ManifestEntry {
/// Path relative to the page root, e.g. `"index.html"` or
/// `"assets/app.js"`. Always `/`-separated, never starts with `/`,
/// never contains a `..` segment (enforced by [`build_manifest`]).
pub path: String,
/// The file's full (decompressed, for zip) content.
pub bytes: Vec<u8>,
}
/// Errors returned by [`build_manifest`].
///
/// Each variant carries a precise, user-facing `Display` message suitable
/// for a `400 Bad Request` API response body.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum UploadError {
/// The raw uploaded bytes exceeded `max_compressed`.
#[error("upload exceeds the maximum allowed size of {max} bytes")]
CompressedTooLarge {
/// The configured limit that was exceeded.
max: usize,
},
/// A zip entry name attempted to escape the page root.
#[error("archive entry has an unsafe path: '{path}'")]
PathTraversal {
/// The offending raw entry name.
path: String,
},
/// A zip entry name was longer than `max_path_len`.
#[error("archive entry path '{path}' exceeds the maximum length of {max} characters")]
PathTooLong {
/// The offending raw entry name.
path: String,
/// The configured limit that was exceeded.
max: usize,
},
/// A zip entry name had more `/`-separated segments than `max_depth`.
#[error("archive entry path '{path}' exceeds the maximum nesting depth of {max}")]
PathTooDeep {
/// The offending raw entry name.
path: String,
/// The configured limit that was exceeded.
max: usize,
},
/// The archive contained more entries than `max_entries`.
#[error("archive contains too many entries (maximum {max})")]
TooManyEntries {
/// The configured limit that was exceeded.
max: usize,
},
/// A single file's decompressed size exceeded `max_file`.
#[error("archive entry '{path}' exceeds the maximum per-file size of {max} bytes")]
FileTooLarge {
/// The offending entry name.
path: String,
/// The configured limit that was exceeded.
max: u64,
},
/// The archive's total decompressed size exceeded `max_uncompressed_total`.
#[error("archive's total uncompressed size exceeds the maximum allowed of {max} bytes")]
TotalTooLarge {
/// The configured limit that was exceeded.
max: u64,
},
/// The archive did not contain a root-level `index.html`.
#[error("archive is missing a required root-level index.html")]
MissingIndexHtml,
/// The archive contained the same normalized path twice.
///
/// With the `zip` crate version this crate currently depends on,
/// parsed archives cannot actually expose two entries sharing a
/// literal name (see the `seen` check in `build_zip_manifest` for
/// details), so this variant is presently unreachable in practice —
/// it is kept as defense-in-depth against that internal representation
/// changing in a future crate upgrade.
#[error("archive contains a duplicate entry: '{path}'")]
DuplicateEntry {
/// The duplicated path.
path: String,
},
/// The bytes could not be parsed as a zip archive at all.
#[error("upload could not be read as a valid zip archive")]
InvalidZip,
/// The archive (or html upload) contained no files.
#[error("upload contains no files")]
Empty,
}
/// Builds the file manifest for an upload, enforcing every limit in
/// `limits` and the structural rules from design §2.2.
///
/// - [`UploadKind::Html`]: `bytes` is stored verbatim as a single
/// `"index.html"` entry (still checked against `max_compressed` and
/// `max_file`/`max_uncompressed_total`).
/// - [`UploadKind::Zip`]: `bytes` is parsed as a zip archive. Directories
/// are skipped. Every file entry's name is checked for path traversal
/// (`..` segments, leading `/`, backslashes, drive letters, empty
/// segments, NUL bytes) and length/depth limits, duplicates are
/// rejected, the entry count is capped, and decompressed bytes are
/// counted as they stream out — extraction aborts the moment a per-file
/// or running total limit is crossed, never trusting the zip header's
/// declared size. The archive must contain a root-level `index.html` and
/// at least one file.
///
/// # Examples
///
/// ```
/// use pages::core::upload::{build_manifest, UploadKind, UploadLimits};
///
/// let limits = UploadLimits::default();
/// let manifest = build_manifest(
/// UploadKind::Html,
/// b"<html>hello</html>".to_vec(),
/// &limits,
/// )
/// .unwrap();
/// assert_eq!(manifest.len(), 1);
/// assert_eq!(manifest[0].path, "index.html");
/// ```
pub fn build_manifest(
kind: UploadKind,
bytes: Vec<u8>,
limits: &UploadLimits,
) -> Result<Vec<ManifestEntry>, UploadError> {
if bytes.len() > limits.max_compressed {
return Err(UploadError::CompressedTooLarge {
max: limits.max_compressed,
});
}
match kind {
UploadKind::Html => build_html_manifest(bytes, limits),
UploadKind::Zip => build_zip_manifest(bytes, limits),
}
}
/// Builds the single-entry manifest for an [`UploadKind::Html`] upload.
fn build_html_manifest(
bytes: Vec<u8>,
limits: &UploadLimits,
) -> Result<Vec<ManifestEntry>, UploadError> {
if bytes.is_empty() {
return Err(UploadError::Empty);
}
let len = bytes.len() as u64;
if len > limits.max_file {
return Err(UploadError::FileTooLarge {
path: "index.html".to_string(),
max: limits.max_file,
});
}
if len > limits.max_uncompressed_total {
return Err(UploadError::TotalTooLarge {
max: limits.max_uncompressed_total,
});
}
Ok(vec![ManifestEntry {
path: "index.html".to_string(),
bytes,
}])
}
/// Builds the manifest for an [`UploadKind::Zip`] upload.
fn build_zip_manifest(
bytes: Vec<u8>,
limits: &UploadLimits,
) -> Result<Vec<ManifestEntry>, UploadError> {
let mut archive =
zip::ZipArchive::new(Cursor::new(bytes)).map_err(|_| UploadError::InvalidZip)?;
if archive.is_empty() {
return Err(UploadError::Empty);
}
if archive.len() > limits.max_entries {
return Err(UploadError::TooManyEntries {
max: limits.max_entries,
});
}
let mut manifest = Vec::new();
// Defense-in-depth against duplicate entry names (design §2.2). Note:
// `zip::ZipArchive` stores parsed entries in a name-keyed map
// internally, so `archive.by_index` can never actually yield two
// entries with the same literal name against the crate version this
// module depends on — see
// `zip_duplicate_named_entries_are_deduplicated_by_the_zip_crate_itself`
// in this module's tests for the full explanation. This check stays
// as a cheap guard in case that changes.
let mut seen: HashSet<String> = HashSet::new();
let mut total_used: u64 = 0;
for i in 0..archive.len() {
let mut entry = archive.by_index(i).map_err(|_| UploadError::InvalidZip)?;
if entry.is_dir() {
continue;
}
let name = entry.name().to_string();
check_entry_path(&name, limits)?;
if !seen.insert(name.clone()) {
return Err(UploadError::DuplicateEntry { path: name });
}
let data = read_entry_limited(&mut entry, &name, limits, &mut total_used)?;
manifest.push(ManifestEntry {
path: name,
bytes: data,
});
}
if manifest.is_empty() {
return Err(UploadError::Empty);
}
if !manifest.iter().any(|e| e.path == "index.html") {
return Err(UploadError::MissingIndexHtml);
}
Ok(manifest)
}
/// Validates a raw zip entry name against the path-traversal and
/// length/depth rules from design §2.2.
///
/// Rejects: empty names, names over `max_path_len`, names containing a
/// backslash or NUL byte, names starting with `/` (absolute), names
/// starting with a drive letter (`C:...`), and any `.` or `..` or empty
/// path segment. Also enforces `max_depth` on the segment count.
fn check_entry_path(name: &str, limits: &UploadLimits) -> Result<(), UploadError> {
if name.is_empty() {
return Err(UploadError::PathTraversal {
path: name.to_string(),
});
}
if name.len() > limits.max_path_len {
return Err(UploadError::PathTooLong {
path: name.to_string(),
max: limits.max_path_len,
});
}
if name.contains('\\') || name.contains('\0') {
return Err(UploadError::PathTraversal {
path: name.to_string(),
});
}
if name.starts_with('/') {
return Err(UploadError::PathTraversal {
path: name.to_string(),
});
}
// Windows drive letter, e.g. "C:foo" or "c:/foo".
let bytes = name.as_bytes();
if bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' {
return Err(UploadError::PathTraversal {
path: name.to_string(),
});
}
let segments: Vec<&str> = name.split('/').collect();
if segments.len() > limits.max_depth {
return Err(UploadError::PathTooDeep {
path: name.to_string(),
max: limits.max_depth,
});
}
for seg in &segments {
if seg.is_empty() || *seg == "." || *seg == ".." {
return Err(UploadError::PathTraversal {
path: name.to_string(),
});
}
}
Ok(())
}
/// Chunk size used by [`read_entry_limited`]'s streaming read loop.
const READ_CHUNK: usize = 64 * 1024;
/// Reads a zip entry's decompressed content, enforcing `limits.max_file`
/// and `limits.max_uncompressed_total` **during** the read rather than
/// after — the read loop aborts as soon as either limit is crossed, so a
/// high-compression-ratio "zip bomb" entry is rejected after at most
/// `READ_CHUNK` extra bytes are decompressed, never after the full
/// (potentially huge) claimed uncompressed size.
fn read_entry_limited<R: Read>(
reader: &mut R,
path: &str,
limits: &UploadLimits,
total_used: &mut u64,
) -> Result<Vec<u8>, UploadError> {
let mut buf = Vec::new();
let mut chunk = [0u8; READ_CHUNK];
loop {
let n = reader
.read(&mut chunk)
.map_err(|_| UploadError::InvalidZip)?;
if n == 0 {
break;
}
buf.extend_from_slice(&chunk[..n]);
if buf.len() as u64 > limits.max_file {
return Err(UploadError::FileTooLarge {
path: path.to_string(),
max: limits.max_file,
});
}
*total_used += n as u64;
if *total_used > limits.max_uncompressed_total {
return Err(UploadError::TotalTooLarge {
max: limits.max_uncompressed_total,
});
}
}
Ok(buf)
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use zip::write::SimpleFileOptions;
use zip::ZipWriter;
/// Builds a zip archive in memory from `(name, content)` pairs, using
/// deflate compression (the only method compiled in, matching
/// production's `Cargo.toml`).
fn make_zip(entries: &[(&str, &[u8])]) -> Vec<u8> {
let mut buf = Cursor::new(Vec::new());
{
let mut writer = ZipWriter::new(&mut buf);
let options =
SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
for (name, content) in entries {
writer.start_file(*name, options).unwrap();
writer.write_all(content).unwrap();
}
writer.finish().unwrap();
}
buf.into_inner()
}
/// Builds a zip archive containing a highly-compressible single file
/// (all zero bytes) of `uncompressed_len` bytes — a small "zip bomb"
/// fixture used to test the streaming size guard.
fn make_bomb_zip(name: &str, uncompressed_len: usize) -> Vec<u8> {
make_zip(&[(name, &vec![0u8; uncompressed_len])])
}
fn limits() -> UploadLimits {
UploadLimits::default()
}
/// Minimal, hand-rolled CRC-32 (the standard zip/PNG polynomial) — used
/// only by [`make_zip_raw`], which builds a zip archive by hand rather
/// than through `ZipWriter`.
fn crc32(data: &[u8]) -> u32 {
let mut crc: u32 = 0xFFFF_FFFF;
for &byte in data {
crc ^= byte as u32;
for _ in 0..8 {
let mask = (crc & 1).wrapping_neg();
crc = (crc >> 1) ^ (0xEDB8_8320 & mask);
}
}
!crc
}
/// Hand-builds a raw (stored/uncompressed) zip archive from arbitrary
/// `(name, content)` entries, assembling the zip byte format directly
/// instead of going through `ZipWriter`.
///
/// Needed because `zip::write::ZipWriter` enforces filename uniqueness
/// itself (`start_file` returns `InvalidArchive("Duplicate filename")`
/// for a repeated name), so it cannot be used to build a fixture with
/// two entries sharing a name — see
/// `zip_duplicate_named_entries_are_deduplicated_by_the_zip_crate_itself`
/// below for why that fixture is still useful.
fn make_zip_raw(entries: &[(&str, &[u8])]) -> Vec<u8> {
let mut out = Vec::new();
let mut central = Vec::new();
for (name, content) in entries {
let name_bytes = name.as_bytes();
let offset = out.len() as u32;
let crc = crc32(content);
let len = content.len() as u32;
// Local file header (stored, no data descriptor).
out.extend_from_slice(&0x0403_4b50u32.to_le_bytes());
out.extend_from_slice(&20u16.to_le_bytes()); // version needed
out.extend_from_slice(&0u16.to_le_bytes()); // flags
out.extend_from_slice(&0u16.to_le_bytes()); // compression: stored
out.extend_from_slice(&0u16.to_le_bytes()); // mod time
out.extend_from_slice(&0u16.to_le_bytes()); // mod date
out.extend_from_slice(&crc.to_le_bytes());
out.extend_from_slice(&len.to_le_bytes()); // compressed size
out.extend_from_slice(&len.to_le_bytes()); // uncompressed size
out.extend_from_slice(&(name_bytes.len() as u16).to_le_bytes());
out.extend_from_slice(&0u16.to_le_bytes()); // extra field len
out.extend_from_slice(name_bytes);
out.extend_from_slice(content);
// Matching central directory header.
central.extend_from_slice(&0x0201_4b50u32.to_le_bytes());
central.extend_from_slice(&20u16.to_le_bytes()); // version made by
central.extend_from_slice(&20u16.to_le_bytes()); // version needed
central.extend_from_slice(&0u16.to_le_bytes()); // flags
central.extend_from_slice(&0u16.to_le_bytes()); // compression: stored
central.extend_from_slice(&0u16.to_le_bytes()); // mod time
central.extend_from_slice(&0u16.to_le_bytes()); // mod date
central.extend_from_slice(&crc.to_le_bytes());
central.extend_from_slice(&len.to_le_bytes());
central.extend_from_slice(&len.to_le_bytes());
central.extend_from_slice(&(name_bytes.len() as u16).to_le_bytes());
central.extend_from_slice(&0u16.to_le_bytes()); // extra field len
central.extend_from_slice(&0u16.to_le_bytes()); // comment len
central.extend_from_slice(&0u16.to_le_bytes()); // disk number start
central.extend_from_slice(&0u16.to_le_bytes()); // internal attrs
central.extend_from_slice(&0u32.to_le_bytes()); // external attrs
central.extend_from_slice(&offset.to_le_bytes());
central.extend_from_slice(name_bytes);
}
let cd_offset = out.len() as u32;
let cd_size = central.len() as u32;
out.extend_from_slice(&central);
// End of central directory record.
out.extend_from_slice(&0x0605_4b50u32.to_le_bytes());
out.extend_from_slice(&0u16.to_le_bytes()); // this disk
out.extend_from_slice(&0u16.to_le_bytes()); // disk with cd start
out.extend_from_slice(&(entries.len() as u16).to_le_bytes());
out.extend_from_slice(&(entries.len() as u16).to_le_bytes());
out.extend_from_slice(&cd_size.to_le_bytes());
out.extend_from_slice(&cd_offset.to_le_bytes());
out.extend_from_slice(&0u16.to_le_bytes()); // comment len
out
}
// ── html happy path ─────────────────────────────────────────────────
#[test]
fn html_happy_path() {
let manifest =
build_manifest(UploadKind::Html, b"<html></html>".to_vec(), &limits()).unwrap();
assert_eq!(manifest.len(), 1);
assert_eq!(manifest[0].path, "index.html");
assert_eq!(manifest[0].bytes, b"<html></html>");
}
#[test]
fn html_empty_is_rejected() {
let err = build_manifest(UploadKind::Html, Vec::new(), &limits()).unwrap_err();
assert_eq!(err, UploadError::Empty);
}
#[test]
fn html_over_per_file_limit_rejected() {
let small_limits = UploadLimits {
max_file: 10,
..limits()
};
let err = build_manifest(UploadKind::Html, vec![b'a'; 11], &small_limits).unwrap_err();
assert!(matches!(err, UploadError::FileTooLarge { .. }));
}
#[test]
fn oversize_compressed_input_rejected_for_html() {
let small_limits = UploadLimits {
max_compressed: 10,
..limits()
};
let err = build_manifest(UploadKind::Html, vec![b'a'; 11], &small_limits).unwrap_err();
assert_eq!(err, UploadError::CompressedTooLarge { max: 10 });
}
// ── zip happy path ───────────────────────────────────────────────────
#[test]
fn zip_happy_path_multi_file_with_subdirs() {
let zip = make_zip(&[
("index.html", b"<html></html>"),
("assets/app.js", b"console.log(1)"),
("assets/img/logo.png", b"\x89PNG"),
]);
let manifest = build_manifest(UploadKind::Zip, zip, &limits()).unwrap();
let mut paths: Vec<&str> = manifest.iter().map(|e| e.path.as_str()).collect();
paths.sort();
assert_eq!(
paths,
["assets/app.js", "assets/img/logo.png", "index.html"]
);
}
#[test]
fn zip_directory_entries_are_skipped() {
// ZipWriter::add_directory creates an explicit directory entry.
let mut buf = Cursor::new(Vec::new());
{
let mut writer = ZipWriter::new(&mut buf);
let options = SimpleFileOptions::default();
writer.add_directory("assets/", options).unwrap();
writer.start_file("index.html", options).unwrap();
writer.write_all(b"hi").unwrap();
writer.start_file("assets/app.js", options).unwrap();
writer.write_all(b"x").unwrap();
writer.finish().unwrap();
}
let manifest = build_manifest(UploadKind::Zip, buf.into_inner(), &limits()).unwrap();
assert_eq!(manifest.len(), 2);
assert!(manifest.iter().all(|e| !e.path.ends_with('/')));
}
// ── path traversal ──────────────────────────────────────────────────
#[test]
fn zip_rejects_dotdot_traversal() {
let zip = make_zip(&[("index.html", b"hi"), ("../evil.txt", b"pwn")]);
let err = build_manifest(UploadKind::Zip, zip, &limits()).unwrap_err();
assert!(matches!(err, UploadError::PathTraversal { .. }));
}
#[test]
fn zip_rejects_dotdot_mid_path_traversal() {
let zip = make_zip(&[("index.html", b"hi"), ("assets/../../evil.txt", b"pwn")]);
let err = build_manifest(UploadKind::Zip, zip, &limits()).unwrap_err();
assert!(matches!(err, UploadError::PathTraversal { .. }));
}
#[test]
fn zip_rejects_absolute_path() {
let zip = make_zip(&[("index.html", b"hi"), ("/etc/passwd", b"pwn")]);
let err = build_manifest(UploadKind::Zip, zip, &limits()).unwrap_err();
assert!(matches!(err, UploadError::PathTraversal { .. }));
}
#[test]
fn zip_rejects_backslash_separator() {
let zip = make_zip(&[("index.html", b"hi"), ("assets\\evil.js", b"pwn")]);
let err = build_manifest(UploadKind::Zip, zip, &limits()).unwrap_err();
assert!(matches!(err, UploadError::PathTraversal { .. }));
}
#[test]
fn zip_rejects_drive_letter() {
let zip = make_zip(&[("index.html", b"hi"), ("C:evil.txt", b"pwn")]);
let err = build_manifest(UploadKind::Zip, zip, &limits()).unwrap_err();
assert!(matches!(err, UploadError::PathTraversal { .. }));
}
#[test]
fn zip_rejects_empty_path_segment() {
let zip = make_zip(&[("index.html", b"hi"), ("assets//evil.js", b"pwn")]);
let err = build_manifest(UploadKind::Zip, zip, &limits()).unwrap_err();
assert!(matches!(err, UploadError::PathTraversal { .. }));
}
#[test]
fn zip_rejects_nul_byte() {
let zip = make_zip(&[("index.html", b"hi"), ("evil\0.js", b"pwn")]);
let err = build_manifest(UploadKind::Zip, zip, &limits()).unwrap_err();
assert!(matches!(err, UploadError::PathTraversal { .. }));
}
#[test]
fn zip_rejects_path_too_long() {
let long_name = format!("{}.txt", "a".repeat(200));
let zip = make_zip(&[("index.html", b"hi"), (&long_name, b"x")]);
let err = build_manifest(UploadKind::Zip, zip, &limits()).unwrap_err();
assert!(matches!(err, UploadError::PathTooLong { .. }));
}
#[test]
fn zip_accepts_path_at_exact_max_length() {
// A path of exactly max_path_len (180) bytes must be accepted —
// guards against a future off-by-one tightening (`>=` vs `>`).
let max = limits().max_path_len;
let name = format!("{}.txt", "a".repeat(max - 4));
assert_eq!(name.len(), max);
let zip = make_zip(&[("index.html", b"hi"), (&name, b"x")]);
let manifest = build_manifest(UploadKind::Zip, zip, &limits()).unwrap();
assert!(manifest.iter().any(|e| e.path == name));
}
#[test]
fn zip_rejects_path_too_deep() {
let deep_name = format!("{}x.txt", "a/".repeat(11));
let zip = make_zip(&[("index.html", b"hi"), (&deep_name, b"x")]);
let err = build_manifest(UploadKind::Zip, zip, &limits()).unwrap_err();
assert!(matches!(err, UploadError::PathTooDeep { .. }));
}
#[test]
fn zip_accepts_path_at_exact_max_depth() {
// A path with exactly max_depth (10) `/`-separated segments
// (including the filename) must be accepted — guards against a
// future off-by-one tightening (`>=` vs `>`).
let max = limits().max_depth;
let name = format!("{}x.txt", "a/".repeat(max - 1));
assert_eq!(name.split('/').count(), max);
let zip = make_zip(&[("index.html", b"hi"), (&name, b"x")]);
let manifest = build_manifest(UploadKind::Zip, zip, &limits()).unwrap();
assert!(manifest.iter().any(|e| e.path == name));
}
// ── duplicates / structural ─────────────────────────────────────────
#[test]
fn zip_duplicate_named_entries_are_deduplicated_by_the_zip_crate_itself() {
// `zip` 2.x parses central-directory entries into an
// `IndexMap<Box<str>, ZipFileData>` keyed by name (see
// `zip::read::Shared` in the vendored crate source), so it is
// structurally impossible for `ZipArchive::by_index` to expose two
// entries with the exact same literal name — a repeated name just
// overwrites the earlier entry's metadata (last write wins),
// collapsing the archive to one entry per unique name.
//
// `build_manifest`'s own `seen`/`DuplicateEntry` check (see
// `build_zip_manifest` and `UploadError::DuplicateEntry`) is
// consequently dead code against this crate version: it can never
// observe two entries sharing a name through a real `ZipArchive`.
// It is kept anyway as free defense-in-depth in case a future
// `zip` upgrade changes that internal representation. This test
// pins down the crate's current dedup/last-write-wins behavior so
// such a change doesn't silently reopen the gap without a failing
// test to flag it — if this test starts failing, `DuplicateEntry`
// has become reachable and deserves its own direct test.
let zip = make_zip_raw(&[
("index.html", b"hi"),
("dup.txt", b"one"),
("dup.txt", b"two"),
]);
let archive = zip::ZipArchive::new(Cursor::new(zip.clone())).unwrap();
assert_eq!(
archive.len(),
2,
"expected the two dup.txt entries to collapse into one"
);
let manifest = build_manifest(UploadKind::Zip, zip, &limits()).unwrap();
assert_eq!(manifest.len(), 2);
let dup = manifest.iter().find(|e| e.path == "dup.txt").unwrap();
assert_eq!(
dup.bytes, b"two",
"last write should win for a deduplicated name"
);
}
#[test]
fn zip_rejects_missing_index_html() {
let zip = make_zip(&[("assets/app.js", b"x")]);
let err = build_manifest(UploadKind::Zip, zip, &limits()).unwrap_err();
assert_eq!(err, UploadError::MissingIndexHtml);
}
#[test]
fn zip_rejects_nested_index_html_only() {
// A nested index.html doesn't satisfy the root-level requirement.
let zip = make_zip(&[("sub/index.html", b"hi")]);
let err = build_manifest(UploadKind::Zip, zip, &limits()).unwrap_err();
assert_eq!(err, UploadError::MissingIndexHtml);
}
#[test]
fn zip_rejects_empty_archive() {
let zip = make_zip(&[]);
let err = build_manifest(UploadKind::Zip, zip, &limits()).unwrap_err();
assert_eq!(err, UploadError::Empty);
}
#[test]
fn zip_rejects_archive_of_only_directories() {
let mut buf = Cursor::new(Vec::new());
{
let mut writer = ZipWriter::new(&mut buf);
writer
.add_directory("empty/", SimpleFileOptions::default())
.unwrap();
writer.finish().unwrap();
}
let err = build_manifest(UploadKind::Zip, buf.into_inner(), &limits()).unwrap_err();
assert_eq!(err, UploadError::Empty);
}
#[test]
fn invalid_zip_bytes_rejected() {
let err =
build_manifest(UploadKind::Zip, b"not a zip file".to_vec(), &limits()).unwrap_err();
assert_eq!(err, UploadError::InvalidZip);
}
// ── limits ───────────────────────────────────────────────────────────
#[test]
fn zip_rejects_too_many_entries() {
let small_limits = UploadLimits {
max_entries: 3,
..limits()
};
let zip = make_zip(&[
("index.html", b"hi"),
("a.txt", b"1"),
("b.txt", b"2"),
("c.txt", b"3"),
]);
let err = build_manifest(UploadKind::Zip, zip, &small_limits).unwrap_err();
assert_eq!(err, UploadError::TooManyEntries { max: 3 });
}
#[test]
fn zip_rejects_oversize_compressed_input() {
let zip = make_zip(&[("index.html", b"hi")]);
let small_limits = UploadLimits {
max_compressed: 4,
..limits()
};
let err = build_manifest(UploadKind::Zip, zip, &small_limits).unwrap_err();
assert_eq!(err, UploadError::CompressedTooLarge { max: 4 });
}
#[test]
fn zip_rejects_file_over_per_file_limit() {
let zip = make_zip(&[("index.html", b"hi"), ("big.bin", &[1u8; 100])]);
let small_limits = UploadLimits {
max_file: 50,
..limits()
};
let err = build_manifest(UploadKind::Zip, zip, &small_limits).unwrap_err();
assert!(matches!(err, UploadError::FileTooLarge { .. }));
}
#[test]
fn zip_rejects_total_over_limit_across_multiple_files() {
let zip = make_zip(&[
("index.html", &[1u8; 40]),
("a.bin", &[1u8; 40]),
("b.bin", &[1u8; 40]),
]);
let small_limits = UploadLimits {
max_file: 1000,
max_uncompressed_total: 100,
..limits()
};
let err = build_manifest(UploadKind::Zip, zip, &small_limits).unwrap_err();
assert!(matches!(err, UploadError::TotalTooLarge { .. }));
}
/// The headline zip-bomb test: a single entry whose *declared*
/// uncompressed size (10 MiB of zeros) is well within the default
/// 25 MiB total / 5 MiB per-file limits on paper, but a much smaller
/// per-file limit here proves the streaming guard aborts extraction
/// before the whole entry is decompressed — deflate compresses runs of
/// zeros to a tiny fraction of their size, so if we trusted the zip
/// header instead of counting streamed bytes, this would sail through.
#[test]
fn zip_bomb_rejected_by_streaming_guard_before_full_materialization() {
let bomb = make_bomb_zip("index.html", 10 * 1024 * 1024);
// Compressed size should be tiny relative to the claimed uncompressed size.
assert!(
bomb.len() < 100 * 1024,
"fixture not actually a high compression ratio: {} bytes",
bomb.len()
);
let tight_limits = UploadLimits {
max_file: 1024, // far smaller than the 10 MiB decompressed content
..limits()
};
let err = build_manifest(UploadKind::Zip, bomb, &tight_limits).unwrap_err();
assert!(matches!(err, UploadError::FileTooLarge { .. }));
}
#[test]
fn zip_bomb_rejected_against_default_limits() {
// 100 MiB of zeros compresses to a few KiB with deflate, but
// decompressing it fully would blow the default 25 MiB total /
// 5 MiB per-file limits — the streaming guard must catch this.
let bomb = make_bomb_zip("index.html", 100 * 1024 * 1024);
assert!(bomb.len() < 1024 * 1024);
let err = build_manifest(UploadKind::Zip, bomb, &limits()).unwrap_err();
assert!(matches!(
err,
UploadError::FileTooLarge { .. } | UploadError::TotalTooLarge { .. }
));
}
}

@ -0,0 +1,669 @@
//! Typed query layer over Cloudflare D1, per
//! `docs/plans/2026-07-12-pages-design.md` §4.2 (schema) and §4.1 (which
//! queries the HTTP API needs).
//!
//! This whole module is gated `wasm32`-only via the `#![cfg(...)]` below —
//! `worker::d1::D1Database` only exists on that target. The gate is applied
//! at the top of this file (rather than on the `mod db;` declaration in
//! `lib.rs`) so the module is unconditionally declared in `lib.rs` and the
//! wasm-only-ness is documented and enforced in exactly one place. On a
//! native build the entire file's contents are stripped, leaving an empty
//! (but present and non-broken) module — `cargo check`/`cargo test` from the
//! host target are unaffected.
//!
//! # D1's `INTEGER 0/1` booleans
//!
//! SQLite (and therefore D1) has no native boolean column type; `pages`'s
//! `can_upload`/`is_admin`/`trusted` (`users`) and `trusted` (`pages`)
//! columns are declared `INTEGER NOT NULL DEFAULT 0` (see
//! `migrations/schema.sql`) and `serde_wasm_bindgen` deserializes those as
//! `i64`, not `bool` — a `#[derive(Deserialize)]` row struct with a `bool`
//! field for any of them fails to deserialize. The approach taken here: a
//! private `UserRow` (and, for the `pages` JOIN `users` queries, a private
//! `PageRowRaw`) struct mirrors the exact column types D1 returns (`i64` for
//! every flag), and `From<UserRow> for User` / `From<PageRowRaw> for
//! PageRow` convert `0`/`1` to `bool` once, at the D1 boundary — everywhere
//! else in the crate (including the public [`User`]/[`PageRow`] types
//! returned by every method below) just sees a normal `bool`. This is the
//! same pattern `quotesdb` uses for its
//! `hidden` column.
//!
//! # `NameTaken` detection
//!
//! D1 (like SQLite) reports constraint violations as a runtime error with a
//! message, not a distinct typed error variant reachable from `worker-rs`.
//! [`Db::insert_page`] detects a slug collision (`pages.slug` is the primary
//! key — see `migrations/schema.sql`) by checking whether the lowercased
//! error string returned by `D1PreparedStatement::run` contains `"unique"`
//! (SQLite's PK/unique violations read `UNIQUE constraint failed: ...`).
//! Matching on the broader `"constraint"` would be wrong: `pages.owner_id`
//! has a real `FOREIGN KEY` to `users(id)`, and an FK violation message
//! (`FOREIGN KEY constraint failed`) contains `"constraint"` but not
//! `"unique"` — that failure must surface as `DbError::Worker`, not a
//! spurious 409 `NameTaken`. **This is still fragile**: it depends on
//! D1/SQLite's error message wording rather than a structured error code,
//! and a wording change upstream could silently misclassify. It is
//! deliberately narrow (checked only in `insert_page`, only mapped to
//! `NameTaken`, everything else falls through to `DbError::Worker`) to
//! bound the blast radius of that fragility. If `worker-rs` ever exposes a
//! structured SQLite error code, prefer that instead.
#![cfg(target_arch = "wasm32")]
use serde::{Deserialize, Serialize};
use worker::d1::D1Database;
// `pages` doesn't depend on `wasm-bindgen` directly (unlike `quotesdb`) —
// reuse the copy re-exported by `worker` rather than adding a new
// dependency just for this one type.
use worker::wasm_bindgen::JsValue;
// ── Row / result types ───────────────────────────────────────────────────
/// A registered user, upserted on GitHub OAuth login.
///
/// Returned by [`Db::upsert_user_on_login`], [`Db::get_user_by_session`],
/// and [`Db::list_users`]. See the module docs for how `can_upload` and
/// `is_admin` are converted from D1's `INTEGER 0/1` storage.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct User {
/// Internal auto-increment primary key (`users.id`).
pub id: i64,
/// Stable GitHub account id — the OAuth upsert conflict key. Distinct
/// from `login`, which a GitHub user can change.
pub github_id: i64,
/// Current GitHub login (username) at last successful login.
pub login: String,
/// GitHub avatar URL, if GitHub returned one.
pub avatar_url: Option<String>,
/// Whether this user is allowed to upload/publish pages. Defaults to
/// `false` for new users (design §2.4) until an admin flips it via
/// [`Db::set_can_upload`], or the user logs in as the configured
/// `ADMIN_GITHUB_LOGIN` (auto-promoted by [`Db::upsert_user_on_login`]).
pub can_upload: bool,
/// Whether this user has admin privileges (delete-any-page, manage
/// users). Only ever set by the `ADMIN_GITHUB_LOGIN` auto-promotion —
/// there is no admin-granting-admin flow in v0.
pub is_admin: bool,
/// Whether this user is admin-designated "trusted" (pages-shqc, design
/// §2.1 addendum): every page they own is served without the CSP
/// `sandbox` (see [`PageRow::effective_trust`]). Admin-granted only via
/// [`Db::set_user_trusted`] — never implied by `is_admin` and never
/// touched by [`Db::upsert_user_on_login`], including the
/// `ADMIN_GITHUB_LOGIN` auto-promotion path (admin status does not imply
/// trust; they are deliberately independent flags).
pub trusted: bool,
/// ISO-ish timestamp string (`datetime('now')` format) of first login.
pub created_at: String,
}
/// Raw row shape returned directly by D1 for `users` SELECT queries.
///
/// Mirrors the exact column types D1 returns (`can_upload`/`is_admin` as
/// `i64`, not `bool`). Never exposed outside this module — always converted
/// to [`User`] via [`From`] immediately after deserializing. See the module
/// docs for why this indirection exists.
#[derive(Debug, Deserialize)]
struct UserRow {
id: i64,
github_id: i64,
login: String,
avatar_url: Option<String>,
can_upload: i64,
is_admin: i64,
trusted: i64,
created_at: String,
}
impl From<UserRow> for User {
fn from(r: UserRow) -> Self {
User {
id: r.id,
github_id: r.github_id,
login: r.login,
avatar_url: r.avatar_url,
can_upload: r.can_upload != 0,
is_admin: r.is_admin != 0,
trusted: r.trusted != 0,
created_at: r.created_at,
}
}
}
/// A published page, with its owner's login joined in.
///
/// Returned by [`Db::list_pages`] and [`Db::get_page`].
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PageRow {
/// The page's slug (primary key, and the path segment it's served at).
pub slug: String,
/// `users.id` of the owner.
pub owner_id: i64,
/// The owner's GitHub login, joined from `users.login` for display —
/// the `/api/pages` response needs this without a second round trip
/// (design §4.1).
pub owner_login: String,
/// Number of files stored under this page's `<slug>/` R2 prefix.
pub file_count: i64,
/// Total uncompressed bytes stored under this page's R2 prefix.
pub total_bytes: i64,
/// The page's own admin-granted trust flag (pages-shqc, design §2.1
/// addendum). Set via [`Db::set_page_trusted`]. See
/// [`PageRow::effective_trust`] for how this combines with the owner's
/// [`User::trusted`] flag.
pub trusted: bool,
/// Whether this page's **owner** is admin-designated trusted
/// ([`User::trusted`]), joined in from `users.trusted` (pages-shqc). Not
/// itself the page's trust flag — see [`PageRow::effective_trust`].
pub owner_trusted: bool,
/// ISO-ish timestamp string of when the page was published.
pub created_at: String,
}
impl PageRow {
/// Whether this page should be served **without** the CSP `sandbox`
/// (pages-shqc, design §2.1 addendum).
///
/// Effective trust is `pages.trusted OR <owner's> users.trusted`: an
/// admin can grant trust either to a specific page or to a user (which
/// then covers every page that user owns, present and future). See
/// [`crate::core::headers::user_content_headers`] for the pure,
/// natively-tested function `routes::serve` calls with this value to
/// pick the response headers.
///
/// This method itself has no dedicated unit test: `db.rs` is
/// `wasm32`-only (see the module docs), so nothing in this file compiles
/// — or can be tested — on the native `cargo test` host target. The
/// two-flag OR here is intentionally trivial for exactly that reason;
/// [`crate::core::headers::user_content_headers`] carries the
/// behavior-under-test (sandbox present iff untrusted) as a pure,
/// natively-testable function instead.
pub fn effective_trust(&self) -> bool {
self.trusted || self.owner_trusted
}
}
/// Raw row shape returned directly by D1 for the `pages` JOIN `users` query
/// in [`Db::list_pages`]/[`Db::get_page`].
///
/// Mirrors [`UserRow`]'s role for [`User`]: `trusted`/`owner_trusted` come
/// back from D1 as `i64` (SQLite `INTEGER 0/1`, see the module docs), not
/// `bool` — converted once here via [`From`], immediately after
/// deserializing, so [`PageRow`] (returned everywhere else in the crate)
/// only ever exposes real `bool`s.
#[derive(Debug, Deserialize)]
struct PageRowRaw {
slug: String,
owner_id: i64,
owner_login: String,
file_count: i64,
total_bytes: i64,
trusted: i64,
owner_trusted: i64,
created_at: String,
}
impl From<PageRowRaw> for PageRow {
fn from(r: PageRowRaw) -> Self {
PageRow {
slug: r.slug,
owner_id: r.owner_id,
owner_login: r.owner_login,
file_count: r.file_count,
total_bytes: r.total_bytes,
trusted: r.trusted != 0,
owner_trusted: r.owner_trusted != 0,
created_at: r.created_at,
}
}
}
// ── Errors ────────────────────────────────────────────────────────────────
/// Errors returned by [`Db`] methods.
#[derive(Debug, thiserror::Error)]
pub enum DbError {
/// A `worker`/D1 operation failed for a reason other than the specific
/// cases below (JS-side error, malformed query, connectivity, etc).
#[error("database error: {0}")]
Worker(#[from] worker::Error),
/// The requested row does not exist (e.g. `set_can_upload` for an
/// unknown `user_id`).
#[error("not found")]
NotFound,
/// [`Db::insert_page`] was called with a `slug` that already has a
/// `pages` row — surfaced as HTTP 409 by the routes layer (design
/// §2.3). See the module docs for how this is detected.
#[error("a page with this name already exists")]
NameTaken,
}
/// Maps a raw `worker::Error` from a D1 operation to a [`DbError::Worker`].
///
/// Small helper to keep the `.map_err(classify)` call sites in this module
/// short; see [`Db::insert_page`] for the one call site that inspects the
/// error message instead of using this directly.
fn classify(e: worker::Error) -> DbError {
DbError::Worker(e)
}
// ── Bind-value helpers ───────────────────────────────────────────────────
/// Binds an `Option<&str>` as either a JS string or JS `null`.
fn bind_opt_str(v: Option<&str>) -> JsValue {
v.map(JsValue::from_str).unwrap_or(JsValue::NULL)
}
/// Binds a `bool` as the D1/SQLite `INTEGER 0/1` convention (see module
/// docs). D1 bind values are JS numbers (`f64`), so `1.0`/`0.0` rather than
/// a JS boolean — matching how the column is declared and read back.
fn bind_bool(b: bool) -> JsValue {
JsValue::from_f64(if b { 1.0 } else { 0.0 })
}
// ── Repository ────────────────────────────────────────────────────────────
/// Typed wrapper over a Cloudflare D1 database handle.
///
/// All queries use prepared statements with positionally-bound parameters
/// (`?1`, `?2`, ...) — no SQL string interpolation of caller-supplied data
/// anywhere in this module.
pub struct Db(D1Database);
// SAFETY: wasm32-unknown-unknown is single-threaded; the wrapped JS value is
// never actually sent across threads. Same accepted pattern as quotesdb's
// `D1Repository` — required so `Db` can live behind `Arc<Db>` shared state
// satisfying Axum's `Send + Sync` handler bounds.
unsafe impl Send for Db {}
unsafe impl Sync for Db {}
impl Db {
/// Wrap a D1 database handle (typically the `DB` binding from
/// `worker::Env`, per `docs/plans/2026-07-12-pages-design.md` §4.4).
pub fn new(db: D1Database) -> Self {
Self(db)
}
/// Upsert a user on a successful GitHub OAuth login and return the
/// resulting row.
///
/// Conflicts on `github_id` (the stable identifier — `login` can
/// change, so it's always refreshed on conflict, not used as the key).
/// `avatar_url` is likewise refreshed on every login.
///
/// When `is_admin_login` is `true` (the login matches the configured
/// `ADMIN_GITHUB_LOGIN` Worker var — design §2.4), `can_upload` and
/// `is_admin` are forced to `1` on both insert and update, regardless
/// of any prior value — this is the auto-promotion bootstrap mechanism
/// and intentionally overrides a previously-revoked `can_upload`.
/// Otherwise, `can_upload`/`is_admin` are left untouched on conflict
/// (new rows still default to `0`/`0` per the schema) so a regular
/// re-login never resets admin-granted permissions.
///
/// `trusted` (pages-shqc) is **never** touched by this method, in either
/// branch — not on insert (new rows default to `0` per the schema) and
/// not on conflict, even for the `ADMIN_GITHUB_LOGIN` auto-promotion
/// path. Admin status does not imply trust: an admin's uploaded pages
/// are sandboxed like anyone else's until an admin explicitly grants
/// `trusted` via [`Db::set_user_trusted`]. This keeps the two flags
/// independent by construction rather than by caller discipline.
///
/// The row is read back with a follow-up `SELECT` after the write
/// (rather than `RETURNING`) so the server-computed `created_at`
/// default is reflected reliably, matching the pattern used in
/// `quotesdb`.
pub async fn upsert_user_on_login(
&self,
github_id: i64,
login: &str,
avatar_url: Option<&str>,
is_admin_login: bool,
) -> Result<User, DbError> {
let flag = if is_admin_login { 1.0 } else { 0.0 };
let admin_set_clause = if is_admin_login {
", can_upload = 1, is_admin = 1"
} else {
""
};
let sql = format!(
"INSERT INTO users (github_id, login, avatar_url, can_upload, is_admin) \
VALUES (?1, ?2, ?3, ?4, ?5) \
ON CONFLICT(github_id) DO UPDATE SET \
login = excluded.login, \
avatar_url = excluded.avatar_url{admin_set_clause}"
);
self.0
.prepare(sql)
.bind(&[
JsValue::from_f64(github_id as f64),
JsValue::from_str(login),
bind_opt_str(avatar_url),
JsValue::from_f64(flag),
JsValue::from_f64(flag),
])
.map_err(classify)?
.run()
.await
.map_err(classify)?;
let row = self
.0
.prepare("SELECT id, github_id, login, avatar_url, can_upload, is_admin, trusted, created_at FROM users WHERE github_id = ?1")
.bind(&[JsValue::from_f64(github_id as f64)])
.map_err(classify)?
.first::<UserRow>(None)
.await
.map_err(classify)?
.ok_or_else(|| DbError::Worker(worker::Error::RustError(
"upsert_user_on_login: row not found on read-back".to_string(),
)))?;
Ok(row.into())
}
/// Create a session row.
///
/// `token_hash` is the SHA-256 hex digest of the raw session token (the
/// raw token itself is never persisted — design §2.4).
///
/// `expires_at` accepts any string SQLite's `datetime()` function can
/// parse — e.g. `'YYYY-MM-DD HH:MM:SS'` or ISO-8601 with `T`/`Z`
/// (`'2026-08-11T12:00:00Z'`). [`Db::get_user_by_session`] canonicalizes
/// the stored value via `datetime()` before comparing it against
/// `datetime('now')`, so either format expires correctly. A value
/// `datetime()` cannot parse makes the expiry comparison SQL `NULL`
/// (never true), i.e. the session is stored but never valid.
pub async fn create_session(
&self,
token_hash: &str,
user_id: i64,
expires_at: &str,
) -> Result<(), DbError> {
self.0
.prepare("INSERT INTO sessions (token_hash, user_id, expires_at) VALUES (?1, ?2, ?3)")
.bind(&[
JsValue::from_str(token_hash),
JsValue::from_f64(user_id as f64),
JsValue::from_str(expires_at),
])
.map_err(classify)?
.run()
.await
.map_err(classify)?;
Ok(())
}
/// Delete a session row (logout). A no-op (not an error) if the token
/// hash does not match any row — logging out an already-expired or
/// already-deleted session is not a failure.
pub async fn delete_session(&self, token_hash: &str) -> Result<(), DbError> {
self.0
.prepare("DELETE FROM sessions WHERE token_hash = ?1")
.bind(&[JsValue::from_str(token_hash)])
.map_err(classify)?
.run()
.await
.map_err(classify)?;
Ok(())
}
/// Resolve a session token hash to its user, honoring expiry.
///
/// Returns `Ok(None)` if the token hash doesn't exist *or* its session
/// has expired (`datetime(expires_at) <= datetime('now')`) — callers
/// should treat both cases identically (unauthenticated), which is why
/// this returns `Option` rather than a separate "expired" variant.
///
/// Both operands of the expiry comparison are normalized through
/// SQLite's `datetime()` rather than compared lexicographically: a
/// stored ISO-8601 value like `'2026-08-11T12:00:00Z'` sorts *after*
/// `datetime('now')`'s `'YYYY-MM-DD HH:MM:SS'` output within the same
/// date window (`'T'` > `' '`), so a raw string compare would silently
/// keep expired sessions valid. See [`Db::create_session`] for the
/// accepted `expires_at` formats.
pub async fn get_user_by_session(&self, token_hash: &str) -> Result<Option<User>, DbError> {
let row = self
.0
.prepare(
"SELECT u.id, u.github_id, u.login, u.avatar_url, u.can_upload, u.is_admin, u.trusted, u.created_at \
FROM sessions s JOIN users u ON u.id = s.user_id \
WHERE s.token_hash = ?1 AND datetime(s.expires_at) > datetime('now')",
)
.bind(&[JsValue::from_str(token_hash)])
.map_err(classify)?
.first::<UserRow>(None)
.await
.map_err(classify)?;
Ok(row.map(User::from))
}
/// List all pages, most recently created first, with owner logins
/// joined in (design §4.1: `GET /api/pages`).
///
/// Also joins `users.trusted` as `owner_trusted` (pages-shqc) so each
/// [`PageRow::effective_trust`] can be computed without a second query
/// per row.
pub async fn list_pages(&self) -> Result<Vec<PageRow>, DbError> {
let rows = self
.0
.prepare(
"SELECT p.slug, p.owner_id, u.login AS owner_login, p.file_count, p.total_bytes, \
p.trusted AS trusted, u.trusted AS owner_trusted, p.created_at \
FROM pages p JOIN users u ON u.id = p.owner_id \
ORDER BY p.created_at DESC",
)
.all()
.await
.map_err(classify)?
.results::<PageRowRaw>()
.map_err(classify)?;
Ok(rows.into_iter().map(PageRow::from).collect())
}
/// Fetch a single page by slug, with owner login joined in.
///
/// Also joins `users.trusted` as `owner_trusted` (pages-shqc) — see
/// [`Db::list_pages`]. Returns `Ok(None)` if no page has that slug.
pub async fn get_page(&self, slug: &str) -> Result<Option<PageRow>, DbError> {
let row = self
.0
.prepare(
"SELECT p.slug, p.owner_id, u.login AS owner_login, p.file_count, p.total_bytes, \
p.trusted AS trusted, u.trusted AS owner_trusted, p.created_at \
FROM pages p JOIN users u ON u.id = p.owner_id \
WHERE p.slug = ?1",
)
.bind(&[JsValue::from_str(slug)])
.map_err(classify)?
.first::<PageRowRaw>(None)
.await
.map_err(classify)?;
Ok(row.map(PageRow::from))
}
/// Count how many pages `user_id` currently owns.
///
/// Used to enforce the per-user page quota before an upload (design
/// §2.5); backed by `idx_pages_owner_id` (`migrations/schema.sql`) so
/// this stays an index lookup rather than a full table scan.
pub async fn count_pages_for_user(&self, user_id: i64) -> Result<i64, DbError> {
#[derive(Deserialize)]
struct CountRow {
count: i64,
}
let row = self
.0
.prepare("SELECT COUNT(*) AS count FROM pages WHERE owner_id = ?1")
.bind(&[JsValue::from_f64(user_id as f64)])
.map_err(classify)?
.first::<CountRow>(None)
.await
.map_err(classify)?;
Ok(row.map(|r| r.count).unwrap_or(0))
}
/// Insert a new `pages` row.
///
/// Returns `Err(DbError::NameTaken)` if `slug` already has a row
/// (`pages.slug` is the primary key) — see the module docs for how this
/// is detected from the D1 error message, and why that's fragile.
/// Nothing is written to R2 by this call; the caller is responsible for
/// the upload pipeline ordering described in design §4.6 (D1 row first,
/// then R2 objects, so a slug collision never leaves orphaned R2 data).
pub async fn insert_page(
&self,
slug: &str,
owner_id: i64,
file_count: i64,
total_bytes: i64,
) -> Result<(), DbError> {
let result = self
.0
.prepare("INSERT INTO pages (slug, owner_id, file_count, total_bytes) VALUES (?1, ?2, ?3, ?4)")
.bind(&[
JsValue::from_str(slug),
JsValue::from_f64(owner_id as f64),
JsValue::from_f64(file_count as f64),
JsValue::from_f64(total_bytes as f64),
])
.map_err(classify)?
.run()
.await;
match result {
Ok(_) => Ok(()),
Err(e) => {
let msg = e.to_string().to_lowercase();
if msg.contains("unique") {
Err(DbError::NameTaken)
} else {
Err(DbError::Worker(e))
}
}
}
}
/// Delete a page's `pages` row by slug.
///
/// Only removes the D1 row — R2 object cleanup for the `<slug>/` prefix
/// (design §4.3) is the caller's responsibility. A no-op (not an error)
/// if `slug` doesn't exist; callers that need to distinguish "deleted"
/// from "never existed" should check with [`Db::get_page`] first (e.g.
/// to authorize owner-or-admin deletion before calling this).
pub async fn delete_page(&self, slug: &str) -> Result<(), DbError> {
self.0
.prepare("DELETE FROM pages WHERE slug = ?1")
.bind(&[JsValue::from_str(slug)])
.map_err(classify)?
.run()
.await
.map_err(classify)?;
Ok(())
}
/// List all users, ordered by login (case-insensitive) for a stable,
/// readable admin UI listing (design §4.1: `GET /api/users`).
pub async fn list_users(&self) -> Result<Vec<User>, DbError> {
let rows = self
.0
.prepare(
"SELECT id, github_id, login, avatar_url, can_upload, is_admin, trusted, created_at \
FROM users ORDER BY login COLLATE NOCASE ASC",
)
.all()
.await
.map_err(classify)?
.results::<UserRow>()
.map_err(classify)?;
Ok(rows.into_iter().map(User::from).collect())
}
/// Set `can_upload` for a user (admin action, design §4.1: `PATCH
/// /api/users/:id`).
///
/// Returns `Err(DbError::NotFound)` if `user_id` does not exist (checked
/// via the D1 result's `changes` count, since `UPDATE ... WHERE id = ?`
/// silently affects zero rows for an unknown id rather than erroring).
pub async fn set_can_upload(&self, user_id: i64, can_upload: bool) -> Result<(), DbError> {
let result = self
.0
.prepare("UPDATE users SET can_upload = ?1 WHERE id = ?2")
.bind(&[bind_bool(can_upload), JsValue::from_f64(user_id as f64)])
.map_err(classify)?
.run()
.await
.map_err(classify)?;
let changes = result
.meta()
.map_err(classify)?
.and_then(|m| m.changes)
.unwrap_or(0);
if changes == 0 {
return Err(DbError::NotFound);
}
Ok(())
}
/// Set `trusted` for a user (admin action, pages-shqc, design §2.1
/// addendum: `PATCH /api/users/:id`).
///
/// This grants/revokes trust for **every** page the user owns (present
/// and future) — see [`PageRow::effective_trust`]. Returns
/// `Err(DbError::NotFound)` if `user_id` does not exist, same convention
/// as [`Db::set_can_upload`].
pub async fn set_user_trusted(&self, user_id: i64, trusted: bool) -> Result<(), DbError> {
let result = self
.0
.prepare("UPDATE users SET trusted = ?1 WHERE id = ?2")
.bind(&[bind_bool(trusted), JsValue::from_f64(user_id as f64)])
.map_err(classify)?
.run()
.await
.map_err(classify)?;
let changes = result
.meta()
.map_err(classify)?
.and_then(|m| m.changes)
.unwrap_or(0);
if changes == 0 {
return Err(DbError::NotFound);
}
Ok(())
}
/// Set `trusted` for a single page (admin action, pages-shqc, design
/// §2.1 addendum: `PATCH /api/pages/:slug`).
///
/// This only affects this one page — it does not touch the owner's
/// [`User::trusted`] flag. See [`PageRow::effective_trust`] for how the
/// two combine. Returns `Err(DbError::NotFound)` if `slug` does not
/// exist, same convention as [`Db::set_can_upload`].
pub async fn set_page_trusted(&self, slug: &str, trusted: bool) -> Result<(), DbError> {
let result = self
.0
.prepare("UPDATE pages SET trusted = ?1 WHERE slug = ?2")
.bind(&[bind_bool(trusted), JsValue::from_str(slug)])
.map_err(classify)?
.run()
.await
.map_err(classify)?;
let changes = result
.meta()
.map_err(classify)?
.and_then(|m| m.changes)
.unwrap_or(0);
if changes == 0 {
return Err(DbError::NotFound);
}
Ok(())
}
}

@ -0,0 +1,55 @@
//! `pages` — a micro static-site host running on a single Cloudflare Worker.
//!
//! Anyone can browse `pages.elijah.run` and see all live pages. Authenticated
//! and authorized users upload a single `.html` file or a `.zip` of static
//! assets; the content is served at `pages.elijah.run/<slug>/`. See
//! `docs/plans/2026-07-12-pages-design.md` for the full design.
//!
//! # Crate layout
//!
//! Pure, natively-testable policy logic (slug validation, MIME lookup, zip
//! manifest building, security headers, ...) lives in modules that compile
//! for both the host target and `wasm32-unknown-unknown`. The Cloudflare
//! Workers glue — the `#[worker::event(fetch)]` entry point and the D1/R2
//! bindings it drives — is `cfg(target_arch = "wasm32")`-gated and stays
//! thin, delegating to the pure modules wherever possible.
//!
//! Policy modules (`core`, `auth`) are pure and compile on both targets;
//! `db` and `routes` are wasm-only Workers glue.
pub mod auth;
pub mod core;
pub mod db;
pub mod routes;
// ── Cloudflare Workers entry point (wasm-only) ───────────────────────────
/// Cloudflare Workers `fetch` event handler.
///
/// With the `http` feature enabled on `worker`, the macro converts the
/// incoming JS request into an `http::Request<worker::Body>` before passing
/// it here. The Axum router is built (see [`routes::router`]) from the
/// Worker environment — D1 binding, OAuth vars/secrets — and its response is
/// returned as `http::Response<axum::body::Body>`, which the macro converts
/// back to a JS response before returning it to the runtime.
#[cfg(target_arch = "wasm32")]
#[worker::event(fetch)]
pub async fn fetch(
req: worker::HttpRequest,
env: worker::Env,
_ctx: worker::Context,
) -> worker::Result<http::Response<axum::body::Body>> {
use tower_service::Service;
let mut router = routes::router(env)?;
router
.call(req)
.await
.map_err(|e| worker::Error::RustError(e.to_string()))
}
// ── Tests ─────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests;

@ -0,0 +1,595 @@
//! `/api/*` JSON handlers — design §4.1 (HTTP surface table) and §4.6
//! (upload pipeline).
//!
//! This module is wasm-only (see `routes::mod`'s `#![cfg(...)]`, which
//! covers this submodule too since it's declared inside that gated file).
//! Every response is JSON; errors are always `{"error": "<message>"}` with
//! the status code documented on each handler. Handlers stay thin: slug
//! grammar lives in [`crate::core::slug`], upload/zip-bomb validation in
//! [`crate::core::upload`], and the `Origin`/ownership checks in
//! [`crate::core::origin`] — this file only sequences those pure checks
//! against [`crate::db::Db`] and R2.
//!
//! `#[worker::send]` is applied to every handler here (not just the
//! "mutating" ones) because every handler awaits at least one JS-backed
//! future (a D1 query via [`current_user`], at minimum) — see
//! `routes::auth::callback` for why that wrapper is needed at all.
use axum::{
body::Bytes,
extract::{Path, Query, State},
http::{
header::{CONTENT_LENGTH, CONTENT_TYPE, ORIGIN},
HeaderMap, StatusCode,
},
response::{IntoResponse, Response},
Json,
};
use serde::Deserialize;
use serde_json::json;
use super::auth::current_user;
use super::AppState;
use crate::core::origin::{can_delete, origin_allowed};
use crate::core::slug::validate_slug;
use crate::core::upload::{build_manifest, UploadKind, UploadLimits};
use crate::db::DbError;
/// Maximum accepted raw upload size — 10 MiB (design §2.2, §4.6 step 4).
/// Mirrors [`UploadLimits::default`]'s `max_compressed`; kept as a separate
/// constant (rather than reading it off a constructed `UploadLimits`) so the
/// `Content-Length` pre-check in [`create_page`] doesn't need to allocate
/// one before the request body is available.
pub(crate) const MAX_UPLOAD_BYTES: u64 = 10 * 1024 * 1024;
/// Builds a `{"error": "<message>"}` JSON response with the given status.
fn json_error(status: StatusCode, message: impl Into<String>) -> Response {
(status, Json(json!({ "error": message.into() }))).into_response()
}
/// Extracts and parses the `Content-Length` header, if present and valid.
fn content_length(headers: &HeaderMap) -> Option<u64> {
headers
.get(CONTENT_LENGTH)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse::<u64>().ok())
}
// ── GET /api/me ───────────────────────────────────────────────────────────
/// `GET /api/me` — the caller's current session, if any (design §4.1).
///
/// Always `200`, **never** `401` — the frontend probes this endpoint
/// unconditionally on every page load to decide what UI to show, so an
/// absent/expired session is just `{"authenticated": false}`, not an error.
/// A DB failure while resolving the session is likewise folded into
/// `{"authenticated": false}` (logged via `worker::console_log!`) rather
/// than surfaced as a 500, for the same reason.
#[worker::send]
pub async fn me(State(state): State<AppState>, headers: HeaderMap) -> Response {
match current_user(&state.db, &headers).await {
Ok(Some(user)) => Json(json!({
"authenticated": true,
"user": {
"login": user.login,
"avatar_url": user.avatar_url,
"can_upload": user.can_upload,
"is_admin": user.is_admin,
}
}))
.into_response(),
Ok(None) => Json(json!({ "authenticated": false })).into_response(),
Err(e) => {
worker::console_log!("pages: /api/me session lookup failed: {e}");
Json(json!({ "authenticated": false })).into_response()
}
}
}
// ── GET /api/pages ────────────────────────────────────────────────────────
/// `GET /api/pages` — public list of all published pages (design §4.1).
#[worker::send]
pub async fn list_pages(State(state): State<AppState>) -> Response {
match state.db.list_pages().await {
Ok(rows) => {
let pages: Vec<_> = rows
.into_iter()
.map(|r| {
let effective_trust = r.effective_trust();
json!({
"slug": r.slug,
"owner_login": r.owner_login,
"file_count": r.file_count,
"total_bytes": r.total_bytes,
"trusted": r.trusted,
"owner_trusted": r.owner_trusted,
"effective_trust": effective_trust,
"created_at": r.created_at,
})
})
.collect();
Json(json!({ "pages": pages })).into_response()
}
Err(e) => {
worker::console_log!("pages: list_pages failed: {e}");
json_error(StatusCode::INTERNAL_SERVER_ERROR, "failed to list pages")
}
}
}
// ── POST /api/pages?name=<slug> ──────────────────────────────────────────
/// Query parameters for [`create_page`].
#[derive(Debug, Deserialize)]
pub struct UploadQuery {
/// The desired page slug. `None`/absent is treated the same as an
/// empty string, which [`validate_slug`] rejects as
/// [`SlugError::Empty`](crate::core::slug::SlugError::Empty) — a `400`,
/// not a distinct "missing parameter" error.
name: Option<String>,
}
/// `POST /api/pages?name=<slug>` — upload a new page (design §4.1, §4.6).
///
/// Pipeline, in the exact order design §4.6 specifies:
///
/// 1. No session → `401`.
/// 2. `!can_upload` → `403` `"not authorized to upload"`.
/// 3. `Origin` header must exactly equal the configured `BASE_URL` → else
/// `403` (design §2.1; see [`origin_allowed`]).
/// 4. `?name=` must pass [`validate_slug`] → else `400` with the
/// [`SlugError`](crate::core::slug::SlugError) message.
/// 5. Per-user page quota (50) → `403` `"page quota reached"`.
/// 6. `Content-Length`, if present, > 10 MiB → `413` early.
/// 7. Actual buffered body > 10 MiB (covers a missing/understated
/// `Content-Length`) → `413`.
/// 8. `Content-Type` → [`UploadKind`]; anything else → `400`
/// `"unsupported content type"`.
/// 9. [`build_manifest`] → `400` with the
/// [`UploadError`](crate::core::upload::UploadError) message.
/// 10. [`Db::insert_page`](crate::db::Db::insert_page) **first**, reserving
/// the slug — `DbError::NameTaken` → `409` `"name already taken"`.
/// 11. R2 `put` each manifest entry under `<slug>/<path>`; on **any**
/// failure, best-effort delete the R2 keys already written plus the D1
/// row, then `500`.
/// 12. `201` with `{slug, url, file_count, total_bytes}`.
///
/// The axum body-size limit for this route is raised to `MAX_UPLOAD_BYTES`
/// in `routes::router` (the 2 MiB default is too small); that layer bounds
/// memory while the body is collected, and steps 6/7 above re-check the same
/// cap so honest oversize requests get our JSON error shape instead of
/// axum's built-in (non-JSON) rejection body.
#[worker::send]
pub async fn create_page(
State(state): State<AppState>,
Query(query): Query<UploadQuery>,
headers: HeaderMap,
body: Bytes,
) -> Response {
// 1. Auth.
let user = match current_user(&state.db, &headers).await {
Ok(Some(u)) => u,
Ok(None) => return json_error(StatusCode::UNAUTHORIZED, "authentication required"),
Err(e) => {
worker::console_log!("pages: create_page session lookup failed: {e}");
return json_error(StatusCode::INTERNAL_SERVER_ERROR, "internal error");
}
};
// 2. can_upload.
if !user.can_upload {
return json_error(StatusCode::FORBIDDEN, "not authorized to upload");
}
// 3. Origin check.
let origin_header = headers.get(ORIGIN).and_then(|v| v.to_str().ok());
if !origin_allowed(origin_header, &state.config.base_url) {
return json_error(StatusCode::FORBIDDEN, "origin not allowed");
}
// 4. Slug validation.
let name = query.name.unwrap_or_default();
if let Err(e) = validate_slug(&name) {
return json_error(StatusCode::BAD_REQUEST, e.to_string());
}
// 5. Quota.
match state.db.count_pages_for_user(user.id).await {
Ok(count) if count >= 50 => {
return json_error(StatusCode::FORBIDDEN, "page quota reached");
}
Ok(_) => {}
Err(e) => {
worker::console_log!("pages: count_pages_for_user failed: {e}");
return json_error(StatusCode::INTERNAL_SERVER_ERROR, "internal error");
}
}
// 6. Content-Length pre-check.
if let Some(len) = content_length(&headers) {
if len > MAX_UPLOAD_BYTES {
return json_error(
StatusCode::PAYLOAD_TOO_LARGE,
"upload exceeds the maximum allowed size",
);
}
}
// 7. Hard cap on the actual buffered body.
if body.len() as u64 > MAX_UPLOAD_BYTES {
return json_error(
StatusCode::PAYLOAD_TOO_LARGE,
"upload exceeds the maximum allowed size",
);
}
// 8. Content-Type → UploadKind.
let content_type = headers
.get(CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_ascii_lowercase();
let kind = if content_type == "text/html" || content_type.starts_with("text/html;") {
UploadKind::Html
} else if content_type == "application/zip" || content_type == "application/x-zip-compressed" {
UploadKind::Zip
} else {
return json_error(StatusCode::BAD_REQUEST, "unsupported content type");
};
// 9. Build the manifest.
let manifest = match build_manifest(kind, body.to_vec(), &UploadLimits::default()) {
Ok(m) => m,
Err(e) => return json_error(StatusCode::BAD_REQUEST, e.to_string()),
};
let file_count = manifest.len() as i64;
let total_bytes: i64 = manifest.iter().map(|e| e.bytes.len() as i64).sum();
// 10. Reserve the slug in D1 *before* writing anything to R2.
if let Err(e) = state
.db
.insert_page(&name, user.id, file_count, total_bytes)
.await
{
return match e {
DbError::NameTaken => json_error(StatusCode::CONFLICT, "name already taken"),
other => {
worker::console_log!("pages: insert_page failed: {other}");
json_error(StatusCode::INTERNAL_SERVER_ERROR, "internal error")
}
};
}
// 11. Write every file to R2, rolling back on any failure.
let mut written_keys: Vec<String> = Vec::with_capacity(manifest.len());
for entry in &manifest {
let key = format!("{name}/{}", entry.path);
match state
.bucket
.0
.put(key.clone(), entry.bytes.clone())
.execute()
.await
{
Ok(_) => written_keys.push(key),
Err(e) => {
worker::console_log!("pages: R2 put failed for '{key}': {e}");
rollback_upload(&state, &written_keys, &name).await;
return json_error(StatusCode::INTERNAL_SERVER_ERROR, "upload failed");
}
}
}
// 12. Success.
let url = format!("{}/{}/", state.config.base_url, name);
(
StatusCode::CREATED,
Json(json!({
"slug": name,
"url": url,
"file_count": file_count,
"total_bytes": total_bytes,
})),
)
.into_response()
}
/// Best-effort cleanup after a failed R2 write partway through
/// [`create_page`]'s upload loop (design §4.6 step 11): deletes every R2
/// key already written and the just-inserted D1 `pages` row. Failures here
/// are only logged — the caller has already decided to return `500`
/// regardless, and there is no better recovery available.
async fn rollback_upload(state: &AppState, written_keys: &[String], slug: &str) {
for key in written_keys {
if let Err(e) = state.bucket.0.delete(key.clone()).await {
worker::console_log!("pages: rollback R2 delete failed for '{key}': {e}");
}
}
if let Err(e) = state.db.delete_page(slug).await {
worker::console_log!("pages: rollback delete_page failed for '{slug}': {e}");
}
}
// ── DELETE /api/pages/:slug ───────────────────────────────────────────────
/// `DELETE /api/pages/:slug` — delete a page (design §4.1, §4.3).
///
/// 1. No session → `401`.
/// 2. `Origin` check → `403` (design §2.1).
/// 3. Unknown slug → `404`.
/// 4. Neither the owner nor an admin → `403` (see [`can_delete`]).
/// 5. List the `<slug>/` R2 prefix (paginating via the returned cursor
/// until `truncated()` is `false`) and delete every object, then delete
/// the D1 row.
/// 6. `200 {"deleted": "<slug>"}`.
#[worker::send]
pub async fn delete_page(
State(state): State<AppState>,
Path(slug): Path<String>,
headers: HeaderMap,
) -> Response {
// 1. Auth.
let user = match current_user(&state.db, &headers).await {
Ok(Some(u)) => u,
Ok(None) => return json_error(StatusCode::UNAUTHORIZED, "authentication required"),
Err(e) => {
worker::console_log!("pages: delete_page session lookup failed: {e}");
return json_error(StatusCode::INTERNAL_SERVER_ERROR, "internal error");
}
};
// 2. Origin check (before any DB lookups, per design §4.6 — a
// cross-origin caller learns nothing about page existence).
let origin_header = headers.get(ORIGIN).and_then(|v| v.to_str().ok());
if !origin_allowed(origin_header, &state.config.base_url) {
return json_error(StatusCode::FORBIDDEN, "origin not allowed");
}
// 3. Page must exist.
let page = match state.db.get_page(&slug).await {
Ok(Some(p)) => p,
Ok(None) => return json_error(StatusCode::NOT_FOUND, "page not found"),
Err(e) => {
worker::console_log!("pages: get_page failed: {e}");
return json_error(StatusCode::INTERNAL_SERVER_ERROR, "internal error");
}
};
// 4. Owner or admin.
if !can_delete(user.id, user.is_admin, page.owner_id) {
return json_error(StatusCode::FORBIDDEN, "not authorized to delete this page");
}
// 5. Delete every R2 object under the `<slug>/` prefix, paginating via
// the cursor the list operation returns while `truncated()` is true.
let prefix = format!("{slug}/");
let mut cursor: Option<String> = None;
loop {
let mut builder = state.bucket.0.list().prefix(prefix.clone());
if let Some(c) = &cursor {
builder = builder.cursor(c.clone());
}
let listed = match builder.execute().await {
Ok(l) => l,
Err(e) => {
worker::console_log!("pages: R2 list failed for prefix '{prefix}': {e}");
return json_error(StatusCode::INTERNAL_SERVER_ERROR, "delete failed");
}
};
for obj in listed.objects() {
if let Err(e) = state.bucket.0.delete(obj.key()).await {
worker::console_log!("pages: R2 delete failed for '{}': {e}", obj.key());
return json_error(StatusCode::INTERNAL_SERVER_ERROR, "delete failed");
}
}
if !listed.truncated() {
break;
}
cursor = listed.cursor();
if cursor.is_none() {
// Truncated but no cursor to continue with — nothing more we
// can do; stop rather than loop forever.
break;
}
}
// 6. Delete the D1 row.
if let Err(e) = state.db.delete_page(&slug).await {
worker::console_log!("pages: delete_page failed: {e}");
return json_error(StatusCode::INTERNAL_SERVER_ERROR, "internal error");
}
Json(json!({ "deleted": slug })).into_response()
}
// ── GET /api/users ────────────────────────────────────────────────────────
/// `GET /api/users` — admin-only user list (design §4.1).
#[worker::send]
pub async fn list_users(State(state): State<AppState>, headers: HeaderMap) -> Response {
let user = match current_user(&state.db, &headers).await {
Ok(Some(u)) => u,
Ok(None) => return json_error(StatusCode::UNAUTHORIZED, "authentication required"),
Err(e) => {
worker::console_log!("pages: list_users session lookup failed: {e}");
return json_error(StatusCode::INTERNAL_SERVER_ERROR, "internal error");
}
};
if !user.is_admin {
return json_error(StatusCode::FORBIDDEN, "admin only");
}
match state.db.list_users().await {
Ok(users) => {
let users: Vec<_> = users
.into_iter()
.map(|u| {
json!({
"id": u.id,
"login": u.login,
"avatar_url": u.avatar_url,
"can_upload": u.can_upload,
"is_admin": u.is_admin,
"trusted": u.trusted,
"created_at": u.created_at,
})
})
.collect();
Json(json!({ "users": users })).into_response()
}
Err(e) => {
worker::console_log!("pages: list_users failed: {e}");
json_error(StatusCode::INTERNAL_SERVER_ERROR, "failed to list users")
}
}
}
// ── PATCH /api/users/:id ──────────────────────────────────────────────────
/// Request body for [`update_user`].
///
/// Both fields are optional (pages-shqc extended this from the original
/// `can_upload`-only body) so the same endpoint can toggle either flag
/// independently from the admin users panel; whichever field(s) are present
/// get applied. A body with neither field is rejected (`400`) rather than
/// silently succeeding as a no-op — see [`update_user`].
#[derive(Debug, Deserialize)]
pub struct UpdateUserBody {
/// New value for the target user's `can_upload` flag, if present.
can_upload: Option<bool>,
/// New value for the target user's `trusted` flag, if present
/// (pages-shqc, design §2.1 addendum) — grants/revokes trust for every
/// page this user owns, via [`crate::db::PageRow::effective_trust`].
trusted: Option<bool>,
}
/// `PATCH /api/users/:id` — admin-only: set a user's `can_upload` and/or
/// `trusted` flag (design §4.1; `trusted` added by pages-shqc, design §2.1
/// addendum). Also Origin-checked (design §2.1: all mutating routes).
///
/// Body is `{"can_upload"?: bool, "trusted"?: bool}` — at least one field
/// must be present (`400` if neither is), and whichever are present are
/// applied; this keeps the original `{"can_upload": bool}`-only body
/// backward compatible. No special-casing for an admin changing their own
/// `can_upload` — admins are promoted with `can_upload = 1` already, so
/// this never locks an admin out of uploading.
#[worker::send]
pub async fn update_user(
State(state): State<AppState>,
Path(id): Path<i64>,
headers: HeaderMap,
Json(body): Json<UpdateUserBody>,
) -> Response {
let user = match current_user(&state.db, &headers).await {
Ok(Some(u)) => u,
Ok(None) => return json_error(StatusCode::UNAUTHORIZED, "authentication required"),
Err(e) => {
worker::console_log!("pages: update_user session lookup failed: {e}");
return json_error(StatusCode::INTERNAL_SERVER_ERROR, "internal error");
}
};
if !user.is_admin {
return json_error(StatusCode::FORBIDDEN, "admin only");
}
let origin_header = headers.get(ORIGIN).and_then(|v| v.to_str().ok());
if !origin_allowed(origin_header, &state.config.base_url) {
return json_error(StatusCode::FORBIDDEN, "origin not allowed");
}
if body.can_upload.is_none() && body.trusted.is_none() {
return json_error(
StatusCode::BAD_REQUEST,
"request body must set at least one of 'can_upload' or 'trusted'",
);
}
if let Some(can_upload) = body.can_upload {
if let Err(e) = state.db.set_can_upload(id, can_upload).await {
return match e {
DbError::NotFound => json_error(StatusCode::NOT_FOUND, "user not found"),
other => {
worker::console_log!("pages: set_can_upload failed: {other}");
json_error(StatusCode::INTERNAL_SERVER_ERROR, "internal error")
}
};
}
}
if let Some(trusted) = body.trusted {
if let Err(e) = state.db.set_user_trusted(id, trusted).await {
return match e {
DbError::NotFound => json_error(StatusCode::NOT_FOUND, "user not found"),
other => {
worker::console_log!("pages: set_user_trusted failed: {other}");
json_error(StatusCode::INTERNAL_SERVER_ERROR, "internal error")
}
};
}
}
Json(json!({ "updated": id })).into_response()
}
// ── PATCH /api/pages/:slug ────────────────────────────────────────────────
/// Request body for [`update_page`].
#[derive(Debug, Deserialize)]
pub struct SetPageTrustedBody {
/// New value for the page's own `trusted` flag.
trusted: bool,
}
/// `PATCH /api/pages/:slug` — admin-only: set a page's own `trusted` flag
/// (pages-shqc, design §2.1 addendum). Also Origin-checked (design §2.1: all
/// mutating routes).
///
/// 1. No session → `401`.
/// 2. Not an admin → `403` `"admin only"`.
/// 3. `Origin` check → `403` (design §2.1).
/// 4. Unknown `slug` → `404`.
/// 5. `200 {"updated": "<slug>"}`.
///
/// This only sets the page's own flag — it does not touch the owner's
/// `users.trusted` (that's [`update_user`]'s job). A page's *effective*
/// trust (what `routes::serve` actually applies) is `trusted OR
/// owner_trusted`, so a page can still end up served untrusted even after
/// this returns `200` if `trusted = false` was requested and the owner also
/// isn't trusted — that's expected, not a bug.
#[worker::send]
pub async fn update_page(
State(state): State<AppState>,
Path(slug): Path<String>,
headers: HeaderMap,
Json(body): Json<SetPageTrustedBody>,
) -> Response {
let user = match current_user(&state.db, &headers).await {
Ok(Some(u)) => u,
Ok(None) => return json_error(StatusCode::UNAUTHORIZED, "authentication required"),
Err(e) => {
worker::console_log!("pages: update_page session lookup failed: {e}");
return json_error(StatusCode::INTERNAL_SERVER_ERROR, "internal error");
}
};
if !user.is_admin {
return json_error(StatusCode::FORBIDDEN, "admin only");
}
let origin_header = headers.get(ORIGIN).and_then(|v| v.to_str().ok());
if !origin_allowed(origin_header, &state.config.base_url) {
return json_error(StatusCode::FORBIDDEN, "origin not allowed");
}
match state.db.set_page_trusted(&slug, body.trusted).await {
Ok(()) => Json(json!({ "updated": slug })).into_response(),
Err(DbError::NotFound) => json_error(StatusCode::NOT_FOUND, "page not found"),
Err(e) => {
worker::console_log!("pages: set_page_trusted failed: {e}");
json_error(StatusCode::INTERNAL_SERVER_ERROR, "internal error")
}
}
}

@ -0,0 +1,358 @@
//! `GET /auth/login`, `GET /auth/callback`, `POST /auth/logout` — the GitHub
//! OAuth flow (design §4.1) — plus [`current_user`], the session-resolution
//! helper other route modules use to identify the caller.
//!
//! This module is wasm-only (see `routes::mod`'s `#![cfg(...)]`, which
//! covers this submodule too since it's declared inside that gated file).
//! All CSRF-state/session token/cookie logic itself lives in the pure
//! [`crate::auth`] module and is unit-tested there; this file is thin glue
//! that calls it, talks to GitHub over `worker::Fetch`, and calls
//! [`crate::db::Db`].
use axum::{
body::Body,
extract::{Query, State},
http::{
header::{CONTENT_TYPE, COOKIE, LOCATION, SET_COOKIE},
HeaderMap, StatusCode,
},
response::Response,
};
use serde::Deserialize;
use worker::wasm_bindgen::JsValue;
use super::AppState;
use crate::db::{Db, DbError, User};
/// User-facing message for any upstream failure during the OAuth callback
/// (token exchange, GitHub API call, or DB write). Deliberately generic —
/// never echoes the underlying error (which could, in principle, quote back
/// request details) to the client; the details go to `worker::console_log!`
/// only (design: "never echo secrets").
const UPSTREAM_ERROR_MESSAGE: &str = "authentication failed — please try again";
// ── GET /auth/login ──────────────────────────────────────────────────────
/// `GET /auth/login` — generates a fresh CSRF `state` token, stores it in
/// the short-lived state cookie, and redirects the browser to GitHub's
/// OAuth authorize endpoint (design §4.1, §2.4).
pub async fn login(State(state): State<AppState>) -> Response {
let csrf_state = crate::auth::generate_token();
let redirect_uri = format!("{}/auth/callback", state.config.base_url);
let authorize_url = crate::auth::github_authorize_url(
&state.config.github_client_id,
&redirect_uri,
&csrf_state,
);
Response::builder()
.status(StatusCode::FOUND)
.header(LOCATION, authorize_url)
.header(SET_COOKIE, crate::auth::state_cookie(&csrf_state))
.body(Body::empty())
.expect("static header values are always valid")
}
// ── GET /auth/callback ───────────────────────────────────────────────────
/// Query parameters GitHub appends to the `redirect_uri` on callback.
#[derive(Debug, Deserialize)]
pub struct CallbackParams {
/// The one-time authorization code to exchange for an access token.
code: Option<String>,
/// The CSRF state value, echoed back — must match the state cookie.
state: Option<String>,
}
/// `GET /auth/callback` — completes the OAuth flow (design §4.1):
///
/// 1. Verify `state` (query param) matches the `__Host-oauth-state` cookie —
/// CSRF protection for the flow itself (design §2.4). Mismatch or either
/// side missing → `400 text/plain`, clearing the state cookie regardless.
/// 2. Exchange `code` for a GitHub access token (server-to-server POST).
/// 3. Fetch the GitHub user's `id`/`login`/`avatar_url`.
/// 4. Upsert the user (auto-promoting to admin if `login` matches
/// `ADMIN_GITHUB_LOGIN`) and create a session.
/// 5. Set the session cookie, clear the state cookie, redirect to `/`.
///
/// Any upstream failure (GitHub HTTP call, D1 write) yields a generic `502`
/// — see [`UPSTREAM_ERROR_MESSAGE`] — with details only sent to
/// `worker::console_log!`, never to the client.
///
/// `#[worker::send]` wraps the (JS-value-holding, therefore `!Send`) future
/// so it satisfies Axum's `Handler` `Send` bound — sound because Workers
/// wasm is single-threaded. Same pattern as quotesdb's handlers.
#[worker::send]
pub async fn callback(
State(state): State<AppState>,
headers: HeaderMap,
Query(params): Query<CallbackParams>,
) -> Response {
let cookie_header = headers
.get(COOKIE)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
let cookie_state = crate::auth::cookie_value(cookie_header, crate::auth::STATE_COOKIE_NAME);
let code = match params.code.as_deref() {
Some(c) if !c.is_empty() => c,
_ => return bad_request("missing OAuth 'code' parameter"),
};
let req_state = match params.state.as_deref() {
Some(s) if !s.is_empty() => s,
_ => return bad_request("missing OAuth 'state' parameter"),
};
match cookie_state {
Some(cs) if cs == req_state => {}
_ => return bad_request("OAuth state mismatch — please try logging in again"),
}
let access_token = match exchange_code(&state.config, code).await {
Ok(token) => token,
Err(e) => {
worker::console_log!("pages: github token exchange failed: {e}");
return upstream_error();
}
};
let gh_user = match fetch_github_user(&access_token).await {
Ok(u) => u,
Err(e) => {
worker::console_log!("pages: github user fetch failed: {e}");
return upstream_error();
}
};
// GitHub logins are case-insensitive; compare ignoring ASCII case so a
// config value like "Octocat" still matches the API's canonical casing.
let is_admin_login = !state.config.admin_github_login.is_empty()
&& gh_user
.login
.eq_ignore_ascii_case(&state.config.admin_github_login);
let user = match state
.db
.upsert_user_on_login(
gh_user.id,
&gh_user.login,
gh_user.avatar_url.as_deref(),
is_admin_login,
)
.await
{
Ok(u) => u,
Err(e) => {
worker::console_log!("pages: upsert_user_on_login failed: {e}");
return upstream_error();
}
};
let token = crate::auth::generate_token();
let token_hash = crate::auth::sha256_hex(&token);
let now_epoch_secs = (worker::Date::now().as_millis() / 1000) as i64;
let expires_at = crate::auth::session_expiry_from_now(now_epoch_secs);
if let Err(e) = state
.db
.create_session(&token_hash, user.id, &expires_at)
.await
{
worker::console_log!("pages: create_session failed: {e}");
return upstream_error();
}
Response::builder()
.status(StatusCode::FOUND)
.header(LOCATION, "/")
.header(
SET_COOKIE,
crate::auth::session_cookie(&token, crate::auth::SESSION_MAX_AGE_SECS),
)
.header(SET_COOKIE, crate::auth::clear_state_cookie())
.body(Body::empty())
.expect("static header values are always valid")
}
/// Builds a `400 text/plain` response for a callback validation failure,
/// always clearing the state cookie (a failed/mismatched OAuth attempt
/// should never leave a stale state cookie behind).
fn bad_request(msg: &str) -> Response {
Response::builder()
.status(StatusCode::BAD_REQUEST)
.header(CONTENT_TYPE, "text/plain; charset=utf-8")
.header(SET_COOKIE, crate::auth::clear_state_cookie())
.body(Body::from(msg.to_string()))
.expect("static header values are always valid")
}
/// Builds a `502 text/plain` response for an upstream (GitHub/D1) failure
/// during the callback, always clearing the state cookie.
fn upstream_error() -> Response {
Response::builder()
.status(StatusCode::BAD_GATEWAY)
.header(CONTENT_TYPE, "text/plain; charset=utf-8")
.header(SET_COOKIE, crate::auth::clear_state_cookie())
.body(Body::from(UPSTREAM_ERROR_MESSAGE))
.expect("static header values are always valid")
}
/// GitHub's `POST /login/oauth/access_token` JSON response shape (only the
/// fields this module needs; GitHub also returns `scope`/`token_type` on
/// success, and `error`/`error_description`/`error_uri` on failure).
#[derive(Debug, Deserialize)]
struct AccessTokenResponse {
access_token: Option<String>,
error: Option<String>,
error_description: Option<String>,
}
/// Exchanges an OAuth `code` for a GitHub access token via a
/// server-to-server `POST https://github.com/login/oauth/access_token`
/// (design §4.1). Returns `Err` with a diagnostic string (for
/// `worker::console_log!` only — never surfaced to the client) on any
/// network, non-200, or missing-token failure.
async fn exchange_code(config: &super::Config, code: &str) -> Result<String, String> {
let redirect_uri = format!("{}/auth/callback", config.base_url);
let body = serde_json::json!({
"client_id": config.github_client_id,
"client_secret": config.github_client_secret,
"code": code,
"redirect_uri": redirect_uri,
});
let request_headers = worker::Headers::new();
request_headers
.set("Accept", "application/json")
.map_err(|e| e.to_string())?;
request_headers
.set("Content-Type", "application/json")
.map_err(|e| e.to_string())?;
request_headers
.set("User-Agent", "pages-worker")
.map_err(|e| e.to_string())?;
let mut init = worker::RequestInit::new();
init.with_method(worker::Method::Post)
.with_headers(request_headers)
.with_body(Some(JsValue::from_str(&body.to_string())));
let req = worker::Request::new_with_init("https://github.com/login/oauth/access_token", &init)
.map_err(|e| e.to_string())?;
let mut resp = worker::Fetch::Request(req)
.send()
.await
.map_err(|e| e.to_string())?;
if resp.status_code() != 200 {
return Err(format!(
"github token exchange returned status {}",
resp.status_code()
));
}
let parsed: AccessTokenResponse = resp.json().await.map_err(|e| e.to_string())?;
match parsed.access_token {
Some(t) if !t.is_empty() => Ok(t),
_ => Err(parsed
.error_description
.or(parsed.error)
.unwrap_or_else(|| "github token exchange: no access_token in response".to_string())),
}
}
/// The subset of GitHub's `GET /user` response this module needs (design
/// §4.1: `id`, `login`, `avatar_url`).
#[derive(Debug, Deserialize)]
struct GithubUser {
id: i64,
login: String,
avatar_url: Option<String>,
}
/// Fetches the authenticated GitHub user's profile via
/// `GET https://api.github.com/user` using the access token from
/// [`exchange_code`]. GitHub requires a `User-Agent` header on all API
/// requests (design §4.1).
async fn fetch_github_user(access_token: &str) -> Result<GithubUser, String> {
let request_headers = worker::Headers::new();
request_headers
.set("Accept", "application/vnd.github+json")
.map_err(|e| e.to_string())?;
request_headers
.set("Authorization", &format!("Bearer {access_token}"))
.map_err(|e| e.to_string())?;
request_headers
.set("User-Agent", "pages-worker")
.map_err(|e| e.to_string())?;
let mut init = worker::RequestInit::new();
init.with_method(worker::Method::Get)
.with_headers(request_headers);
let req = worker::Request::new_with_init("https://api.github.com/user", &init)
.map_err(|e| e.to_string())?;
let mut resp = worker::Fetch::Request(req)
.send()
.await
.map_err(|e| e.to_string())?;
if resp.status_code() != 200 {
return Err(format!(
"github user fetch returned status {}",
resp.status_code()
));
}
resp.json::<GithubUser>().await.map_err(|e| e.to_string())
}
// ── POST /auth/logout ─────────────────────────────────────────────────────
/// `POST /auth/logout` — deletes the session row backing the caller's
/// session cookie (if any) and clears the cookie, then redirects to `/`
/// (design §4.1). Deliberately a no-op, not an error, if there is no
/// session cookie or it doesn't match a live session — see
/// [`Db::delete_session`].
///
/// `#[worker::send]` — see [`callback`] for why.
#[worker::send]
pub async fn logout(State(state): State<AppState>, headers: HeaderMap) -> Response {
if let Some(cookie_header) = headers.get(COOKIE).and_then(|v| v.to_str().ok()) {
if let Some(token) =
crate::auth::cookie_value(cookie_header, crate::auth::SESSION_COOKIE_NAME)
{
let token_hash = crate::auth::sha256_hex(token);
if let Err(e) = state.db.delete_session(&token_hash).await {
worker::console_log!("pages: delete_session failed: {e}");
}
}
}
Response::builder()
.status(StatusCode::FOUND)
.header(LOCATION, "/")
.header(SET_COOKIE, crate::auth::clear_session_cookie())
.body(Body::empty())
.expect("static header values are always valid")
}
// ── Session resolution helper (used by later route tickets too) ─────────
/// Resolves the caller's session cookie (if any) to their [`User`] row.
///
/// Returns `Ok(None)` — not an error — when there is no `Cookie` header, no
/// `__Host-session` cookie within it, or the session is unknown/expired
/// ([`Db::get_user_by_session`] already collapses those cases). Intended for
/// reuse by other route modules (`api`, `serve`) added in later tickets, per
/// the ticket's "session resolution helper for later tickets" requirement.
pub async fn current_user(db: &Db, headers: &HeaderMap) -> Result<Option<User>, DbError> {
let Some(cookie_header) = headers.get(COOKIE).and_then(|v| v.to_str().ok()) else {
return Ok(None);
};
let Some(token) = crate::auth::cookie_value(cookie_header, crate::auth::SESSION_COOKIE_NAME)
else {
return Ok(None);
};
let token_hash = crate::auth::sha256_hex(token);
db.get_user_by_session(&token_hash).await
}

@ -0,0 +1,227 @@
//! Axum router construction for the Worker (design §4.5).
//!
//! This whole module is `wasm32`-only (see the `#![cfg(...)]` below) — it
//! wires together [`crate::db::Db`] (itself wasm-only) and the Workers
//! `worker::Env` bindings/vars/secrets into an [`axum::Router`]. Handlers
//! live in submodules ([`auth`], [`api`], [`serve`]); `lib.rs`'s
//! `#[worker::event(fetch)]` glue calls only [`router`].
//!
//! # Shared state
//!
//! [`AppState`] is threaded into every handler via Axum's
//! [`axum::extract::State`] extractor. It holds an `Arc<Db>` (D1 handle) and
//! an `Arc<Config>` (OAuth vars/secrets read once from `worker::Env` at
//! router-construction time — see [`Config`]). Both are plain owned data
//! (`Db` is `unsafe impl Send + Sync`, per its module docs; `Config`'s
//! fields are owned `String`s), so `AppState` is `Send + Sync` without any
//! further unsafe — the `unsafe impl Send/Sync` trick documented in
//! `db.rs`/`quotesdb` only has to be applied once, at the `Db` boundary,
//! rather than re-derived here. This mirrors the pattern `quotesdb`'s
//! `handlers::AppState` uses (an `Arc<dyn QuoteRepository>` plus a plain
//! `Option<String>` admin secret extracted from `Env` before the router is
//! built).
#![cfg(target_arch = "wasm32")]
pub mod api;
pub mod auth;
pub mod serve;
use std::sync::Arc;
use axum::{
body::Body,
extract::DefaultBodyLimit,
http::{header::CONTENT_TYPE, StatusCode},
response::Response,
routing::{delete, get, patch, post},
Router,
};
use crate::db::Db;
/// OAuth configuration read once from the Worker environment (design §4.4:
/// `BASE_URL`, `GITHUB_CLIENT_ID`, `ADMIN_GITHUB_LOGIN` vars;
/// `GITHUB_CLIENT_SECRET` secret).
///
/// Extracted eagerly in [`router`] (rather than handlers holding onto
/// `worker::Env` directly) so `AppState` is simple owned data — see the
/// module docs.
pub struct Config {
/// Public base URL of this Worker (e.g. `https://pages.elijah.run`),
/// used to build the GitHub OAuth `redirect_uri`.
pub base_url: String,
/// GitHub OAuth App client id (public, sent to the browser as part of
/// the authorize URL).
pub github_client_id: String,
/// GitHub OAuth App client secret (never sent to the browser — used
/// only in the server-to-server code exchange in `routes::auth::callback`).
pub github_client_secret: String,
/// GitHub login that is auto-promoted to admin on login (design §2.4).
pub admin_github_login: String,
}
/// Wraps a `worker::Bucket` (the `PAGES` R2 binding, design §4.4) so it can
/// live in [`AppState`].
///
/// `worker::Bucket` wraps a JS value and so is not `Send + Sync` on its own;
/// `unsafe impl Send + Sync` here is sound for the same reason [`Db`] does
/// the same (see its module docs) — Workers wasm is single-threaded, so no
/// value ever actually crosses a thread boundary.
pub struct R2(pub worker::Bucket);
// SAFETY: see the doc comment on `R2` above and `db.rs`'s module docs for
// the shared justification (Workers' wasm32 runtime is single-threaded).
unsafe impl Send for R2 {}
unsafe impl Sync for R2 {}
/// Shared application state threaded through all Axum handlers via
/// [`axum::extract::State`].
#[derive(Clone)]
pub struct AppState {
/// D1-backed query layer.
pub db: Arc<Db>,
/// OAuth configuration (see [`Config`]).
pub config: Arc<Config>,
/// R2 bucket handle for page content (design §4.3).
pub bucket: Arc<R2>,
}
/// Builds the axum `Router` for the Worker, reading the D1 `DB` binding,
/// the `PAGES` R2 bucket binding, and OAuth vars/secrets from `env` (design
/// §4.4).
///
/// Split out from `lib.rs`'s `#[worker::event(fetch)]` glue so it can grow
/// independently of the Workers entry point as routes are added by later
/// tickets (design §4.5). Returns `worker::Result` rather than a bare
/// `Router` because reading D1/R2/vars/secrets from `Env` is fallible — the
/// caller (`fetch`) already returns `worker::Result` and propagates this
/// with `?`.
///
/// `POST /api/pages` gets its own `DefaultBodyLimit::max(MAX_UPLOAD_BYTES)`
/// layer: axum's `Bytes` extractor otherwise caps request bodies at 2 MiB by
/// default, which is smaller than the 10 MiB upload limit. The layer bounds
/// memory at body-collection time (a lying/absent Content-Length can't force
/// buffering past it); `routes::api::create_page` re-checks the same limit to
/// return the JSON-shaped 413 on honest oversize requests (design §2.2, §4.6).
pub fn router(env: worker::Env) -> worker::Result<Router> {
let db = Arc::new(Db::new(env.d1("DB")?));
let bucket = Arc::new(R2(env.bucket("PAGES")?));
let config = Arc::new(Config {
base_url: env.var("BASE_URL")?.to_string(),
github_client_id: env.var("GITHUB_CLIENT_ID")?.to_string(),
github_client_secret: env.secret("GITHUB_CLIENT_SECRET")?.to_string(),
admin_github_login: env.var("ADMIN_GITHUB_LOGIN")?.to_string(),
});
let state = AppState { db, config, bucket };
Ok(Router::new()
.route("/", get(root))
.route("/auth/login", get(auth::login))
.route("/auth/callback", get(auth::callback))
.route("/auth/logout", post(auth::logout))
.route("/api/me", get(api::me))
.route(
"/api/pages",
get(api::list_pages)
.post(api::create_page)
.layer(DefaultBodyLimit::max(api::MAX_UPLOAD_BYTES as usize)),
)
.route(
"/api/pages/{slug}",
delete(api::delete_page).patch(api::update_page),
)
.route("/api/users", get(api::list_users))
.route("/api/users/{id}", patch(api::update_user))
// Hosted-page serving (design §4.7, pages-adl9) — registered after
// every literal `/`, `/auth/*`, `/api/*` route above for
// readability. `axum`'s `matchit` router gives literal segments
// strictly higher priority than the single-dynamic-segment and
// catch-all patterns below regardless of registration order (see
// `routes::serve`'s module docs), so this ordering isn't load
// bearing — it's here so a reader scanning top-to-bottom sees the
// fixed routes before the catch-all page-serving ones.
.route("/{slug}", get(serve::redirect_to_index))
.route("/{slug}/", get(serve::serve_index))
.route("/{slug}/{*rest}", get(serve::serve_asset))
.with_state(state))
}
/// The embedded management UI (design §4.5, pages-jaqz): one dependency-free
/// HTML file with inline CSS/JS, baked into the Worker binary at compile
/// time so serving it needs no R2/D1 round-trip.
const INDEX_HTML: &str = include_str!("../../static/index.html");
/// Handler for `GET /` — serves the embedded management UI (design §4.1,
/// §4.5, pages-jaqz).
///
/// Security headers:
/// - `Content-Type: text/html; charset=utf-8` — declares the body type.
/// - `X-Content-Type-Options: nosniff` — same rationale as
/// `core::headers::NOSNIFF`: never let a browser sniff this into
/// something else.
/// - `Referrer-Policy: no-referrer` — this page's URL can carry no secrets
/// itself, but never leak `pages.elijah.run` as a referrer to any link a
/// hosted page (or GitHub, during OAuth) might otherwise receive it as.
/// - `Cache-Control: no-store` — the HTML body is static, but it renders
/// session-derived UI state via `fetch("/api/me")` client-side; `no-store`
/// keeps a shared/browser cache from ever serving this response across a
/// login/logout boundary or between users on a shared machine.
/// - `Content-Security-Policy` — deliberately **not** the `sandbox` policy
/// `routes::serve` applies to hosted content (design §2.1): this page must
/// keep its real `pages.elijah.run` origin so its own inline script can
/// make credentialed `fetch("/api/…")` calls. Every directive is
/// same-origin-only except where this page's own markup genuinely needs
/// more, and each is commented with why:
/// - `default-src 'self'` — fallback for any resource type not covered by
/// a more specific directive below; same-origin only.
/// - `script-src 'self' 'unsafe-inline' https://static.cloudflareinsights.com`
/// — this page's own script is its inline `<script>` block, but
/// Cloudflare injects scripts into proxied HTML at the edge: the Bot
/// Fight Mode JS-detection script (same-origin, under
/// `/cdn-cgi/challenge-platform/…`, hence `'self'`) and the Web
/// Analytics beacon (`static.cloudflareinsights.com`). Blocking those
/// breaks Cloudflare's challenge flow — sign-in navigations get bounced
/// back to `/` in a reload loop — so they must be allowed here.
/// - `style-src 'unsafe-inline'` — the inline `<style>`
/// block; no external stylesheet is referenced.
/// - `img-src 'self' https://avatars.githubusercontent.com` — the only
/// non-self images this page ever renders are GitHub avatar URLs
/// (`/api/me`'s `user.avatar_url`, and each row of `/api/users` in the
/// admin panel).
/// - `connect-src 'self' https://cloudflareinsights.com` — every
/// `fetch()` in our own inline script targets `/api/*` or `/auth/*`
/// same-origin; the extra host is where Cloudflare's injected analytics
/// beacon posts its measurements.
/// - `frame-ancestors 'none'` — this page (upload/delete/admin actions)
/// must never be embeddable in a frame on another site (clickjacking).
/// - `base-uri 'none'` — no `<base>` tag exists in this document and none
/// should ever be allowed to retarget its relative URLs.
/// - `form-action 'self'` — this page contains no `<form>` element at
/// all: "Sign in with GitHub" is a plain `<a href="/auth/login">`
/// navigation (not a form submission, so `form-action` doesn't even
/// apply to it), and logout is a same-origin `fetch` POST from script
/// (covered by `connect-src`, not `form-action`). `'self'` is kept
/// anyway as cheap defense-in-depth rather than omitting the directive
/// entirely, in case a future no-JS fallback ever adds a real `<form>`.
async fn root() -> Response {
Response::builder()
.status(StatusCode::OK)
.header(CONTENT_TYPE, "text/html; charset=utf-8")
.header("X-Content-Type-Options", "nosniff")
.header("Referrer-Policy", "no-referrer")
.header("Cache-Control", "no-store")
.header(
"Content-Security-Policy",
"default-src 'self'; \
script-src 'self' 'unsafe-inline' https://static.cloudflareinsights.com; \
style-src 'unsafe-inline'; \
img-src 'self' https://avatars.githubusercontent.com; \
connect-src 'self' https://cloudflareinsights.com; \
frame-ancestors 'none'; base-uri 'none'; form-action 'self'",
)
.body(Body::from(INDEX_HTML))
.expect("static header values are always valid")
}

@ -0,0 +1,241 @@
//! Hosted-page serving from R2 — `GET /{slug}`, `GET /{slug}/`, and
//! `GET /{slug}/{*rest}` (design §4.1, §4.7, pages-adl9).
//!
//! This module is wasm-only (see `routes::mod`'s `#![cfg(...)]`, which
//! covers this submodule too since it's declared inside that gated file).
//! Path/key resolution and traversal rejection is pure and lives in
//! [`crate::core::serve_path`] (native-tested); this file is thin glue that
//! validates the slug, calls that resolver, and does the R2 round-trip.
//!
//! # Routing & precedence
//!
//! Three routes are registered in `routes::router` (in addition to `/{slug}`
//! itself needing to redirect, since `{*rest}` catch-alls in `axum`'s
//! underlying `matchit` router never match a *zero-length* remainder — see
//! that crate's `catchall_off_by_one` test):
//!
//! - `/{slug}` → [`redirect_to_index`] (301 to `/{slug}/`)
//! - `/{slug}/` → [`serve_index`] (rest is always `""`)
//! - `/{slug}/{*rest}` → [`serve_asset`]
//!
//! These are single-dynamic-segment / catch-all patterns, while `/`,
//! `/auth/*`, and `/api/*` are all-literal patterns. `matchit` (axum's
//! router) gives literal segments strictly higher priority than named
//! parameters, which in turn beat catch-alls, **regardless of registration
//! order** (documented in `matchit`'s crate-level docs: "Static and dynamic
//! route segments are allowed to overlap. If they do, static segments will
//! be given higher priority"). So e.g. a request for `/auth/login` always
//! resolves to the literal route, never to `/{slug}/{*rest}` with
//! `slug="auth"` — but as a second, independent layer of defense,
//! `validate_slug` also rejects `"auth"` as a [reserved
//! name](crate::core::slug), so even a hypothetical precedence bug would
//! still 404 rather than serve/shadow anything. These routes are still
//! registered after the specific ones in `routes::router` for readability,
//! even though `matchit`'s precedence rule makes the order irrelevant.
//!
//! # Method handling
//!
//! Only `get(...)` handlers are registered for these three paths. Axum
//! automatically serves `HEAD` from the same `get` handler (stripping the
//! body), so that's free. Any other method (`POST`, `PUT`, ...) on a path
//! that otherwise matches one of these routes gets axum's built-in `405
//! Method Not Allowed` — chosen over a custom `404` because the resource
//! path is real and understood, only the method isn't supported. The
//! built-in 405 skips the sandbox headers applied below, which is
//! acceptable only because its body is empty and non-renderable.
use axum::{
body::Body,
extract::{Path, State},
http::{
header::{CONTENT_TYPE, LOCATION},
response::Builder as ResponseBuilder,
StatusCode,
},
response::Response,
};
use super::AppState;
use crate::core::{
headers::user_content_headers, mime::mime_for_path, serve_path::resolve_candidates,
slug::validate_slug,
};
/// Tiny, dependency-free inline HTML body for every hosted-page 404 (design
/// §4.7): unknown slug, reserved/invalid slug, or a path that resolves to no
/// R2 object under any candidate key. No templating — this is deliberately
/// static and small since it can be served on every miss.
const NOT_FOUND_HTML: &str = "<!doctype html>\n<html><head><meta charset=\"utf-8\">\
<title>404 Not Found</title></head><body><h1>404 Not Found</h1>\
<p>This page does not exist.</p></body></html>";
/// Applies the design §4.7 / §2.1 (and the pages-shqc §2.1 addendum)
/// security headers shared by every response this module returns — hits,
/// misses, and the redirect alike — via the pure, natively-tested
/// [`user_content_headers`]: `X-Content-Type-Options: nosniff`,
/// `Referrer-Policy: no-referrer`, `Cache-Control: public, max-age=300`, and
/// `Access-Control-Allow-Origin: *` unconditionally, plus the CSP `sandbox`
/// (never `allow-same-origin`) **iff** `trusted` is `false`. Centralized
/// here so no response-building branch below can accidentally omit one, and
/// so the trust-conditional CSP is applied exactly once, in one place.
fn apply_user_content_headers(builder: ResponseBuilder, trusted: bool) -> ResponseBuilder {
let mut builder = builder;
for (name, value) in user_content_headers(trusted) {
builder = builder.header(name, value);
}
builder
}
/// Builds the shared `404` response (design §4.7): still carries every
/// security header above, since a miss is still on the user-content path.
///
/// Always sandboxed (`trusted = false`), regardless of why the request
/// 404'd — an invalid/reserved slug and an unknown slug never reach a D1
/// row to read a trust flag from, and a genuine miss on a *known* trusted
/// page's asset is rare enough, and the response non-renderable-as-content
/// enough (a fixed static body, design §4.7), that varying it isn't worth
/// the complexity. See [`serve_page`].
fn not_found_response() -> Response {
apply_user_content_headers(Response::builder(), false)
.status(StatusCode::NOT_FOUND)
.header(CONTENT_TYPE, "text/html; charset=utf-8")
.body(Body::from(NOT_FOUND_HTML))
.expect("static header values are always valid")
}
/// Builds a generic `500` for a genuine R2/D1 failure (as opposed to a clean
/// "object not found") — still carries the shared security headers. Never
/// echoes the underlying error to the client; details go to
/// `worker::console_log!` only, same convention as `routes::api`.
///
/// Always sandboxed (`trusted = false`) — an infrastructure failure page, not
/// user content; there is no upside to varying it by trust.
fn internal_error_response() -> Response {
apply_user_content_headers(Response::builder(), false)
.status(StatusCode::INTERNAL_SERVER_ERROR)
.header(CONTENT_TYPE, "text/plain; charset=utf-8")
.body(Body::from("internal error"))
.expect("static header values are always valid")
}
// ── GET /{slug} ───────────────────────────────────────────────────────────
/// `GET /{slug}` — 301 redirect to `/{slug}/` (design §4.1). An invalid or
/// [reserved](crate::core::slug) slug 404s (with the same security headers
/// as every other response here) instead of redirecting to a location that
/// could never resolve to a page anyway.
///
/// No R2 access here (redirect only), so no `#[worker::send]` is needed —
/// unlike [`serve_index`]/[`serve_asset`], nothing in this handler crosses
/// an `await` holding a `!Send` JS value.
pub async fn redirect_to_index(Path(slug): Path<String>) -> Response {
if validate_slug(&slug).is_err() {
return not_found_response();
}
apply_user_content_headers(Response::builder(), false)
.status(StatusCode::MOVED_PERMANENTLY)
.header(LOCATION, format!("/{slug}/"))
.body(Body::empty())
.expect("well-formed header values are always valid")
}
// ── GET /{slug}/ ──────────────────────────────────────────────────────────
/// `GET /{slug}/` — serves the page's `index.html` (design §4.1, §4.7).
/// `rest` is always `""` here; see [`serve_page`].
///
/// `#[worker::send]` wraps the future because it awaits R2 (a JS-backed,
/// therefore `!Send`, future) — same pattern as `routes::api`/`routes::auth`
/// handlers that touch D1/R2/`worker::Fetch`.
#[worker::send]
pub async fn serve_index(State(state): State<AppState>, Path(slug): Path<String>) -> Response {
serve_page(&state, &slug, "").await
}
// ── GET /{slug}/{*rest} ───────────────────────────────────────────────────
/// `GET /{slug}/{*rest}` — serves an asset under the page, or its
/// directory-index fallback (design §4.1, §4.7). See [`serve_page`] and
/// [`resolve_candidates`] for the exact key candidates tried.
#[worker::send]
pub async fn serve_asset(
State(state): State<AppState>,
Path((slug, rest)): Path<(String, String)>,
) -> Response {
serve_page(&state, &slug, &rest).await
}
/// Shared serving logic for [`serve_index`] (`rest == ""`) and
/// [`serve_asset`] (`rest` from the `{*rest}` wildcard, already
/// percent-decoded valid UTF-8 — see `crate::core::serve_path`'s module
/// docs for what that guarantees).
///
/// 1. Invalid/reserved `slug` → `404` (no D1 lookup attempted; the slug
/// could never have a `pages` row).
/// 2. [`Db::get_page`](crate::db::Db::get_page) — a D1 lookup, done
/// **before** touching R2 (pages-shqc): `None` → `404` immediately,
/// without any R2 round trip. `Some(page)` carries
/// [`PageRow::effective_trust`](crate::db::PageRow::effective_trust),
/// which every response built below in this call uses to pick headers
/// via [`user_content_headers`] (through [`apply_user_content_headers`]).
/// A D1 error here → `500`.
/// 3. [`resolve_candidates`] → empty (unsafe/oversized `rest`) → `404`.
/// 4. Each candidate key is tried against R2 `get` **in order**; the first
/// hit wins. `Content-Type` is derived from the *matched* key's
/// extension via [`mime_for_path`] (so a `foo` exact-match and a
/// `foo/index.html` directory-index fallback get different, individually
/// correct content types).
/// 5. A genuine R2 error on any candidate short-circuits to a `500` rather
/// than continuing to try the remaining candidates (an infrastructure
/// failure, not evidence the object is absent).
/// 6. All candidates miss cleanly (`Ok(None)`) → `404`.
async fn serve_page(state: &AppState, slug: &str, rest: &str) -> Response {
if validate_slug(slug).is_err() {
return not_found_response();
}
let page = match state.db.get_page(slug).await {
Ok(Some(p)) => p,
Ok(None) => return not_found_response(),
Err(e) => {
worker::console_log!("pages: get_page failed for '{slug}': {e}");
return internal_error_response();
}
};
let trusted = page.effective_trust();
let candidates = resolve_candidates(slug, rest);
for key in candidates {
match state.bucket.0.get(key.clone()).execute().await {
Ok(Some(object)) => {
let Some(body) = object.body() else {
// `get` always returns a body-bearing Object on a hit;
// defensive fallback only, not expected in practice.
continue;
};
let bytes = match body.bytes().await {
Ok(b) => b,
Err(e) => {
worker::console_log!("pages: R2 body read failed for '{key}': {e}");
return internal_error_response();
}
};
return apply_user_content_headers(Response::builder(), trusted)
.status(StatusCode::OK)
.header(CONTENT_TYPE, mime_for_path(&key))
.body(Body::from(bytes))
.expect("well-formed header values are always valid");
}
Ok(None) => continue,
Err(e) => {
worker::console_log!("pages: R2 get failed for '{key}': {e}");
return internal_error_response();
}
}
}
not_found_response()
}

@ -0,0 +1,13 @@
//! Native unit test module for the `pages` crate root.
//!
//! Kept trivial for now — the wasm-only Workers glue in `lib.rs` cannot run
//! natively (it depends on `worker`/`axum`, which are wasm32-only
//! dependencies). Real coverage lands with the pure `core`/`auth` policy
//! modules added by later tickets, per
//! `docs/plans/2026-07-12-pages-design.md` §4.5.
/// Sanity check that the native test harness runs at all.
#[test]
fn crate_compiles_and_tests_run() {
assert_eq!(1 + 1, 2);
}

@ -0,0 +1,938 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>pages.elijah.run</title>
<style>
:root {
--bg: #f6f7f9;
--bg-card: #ffffff;
--text: #16181d;
--text-muted: #5b6270;
--border: #dfe3e8;
--accent: #2563eb;
--accent-text: #ffffff;
--danger: #dc2626;
--danger-bg: #fdecec;
--danger-border: #f5b5b1;
--success: #15803d;
--success-bg: #eafaf0;
--success-border: #a7e3bd;
--warn: #92400e;
--warn-bg: #fff7e6;
--warn-border: #f3d38a;
--info-bg: #eef4ff;
--info-border: #b9cdfb;
--code-bg: #eef0f3;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.06);
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #14161a;
--bg-card: #1d2025;
--text: #e7e9ec;
--text-muted: #9aa1ac;
--border: #33373f;
--accent: #5b8bf0;
--accent-text: #0a0d12;
--danger: #f87171;
--danger-bg: #2a1414;
--danger-border: #5c2323;
--success: #4ade80;
--success-bg: #12261a;
--success-border: #234a30;
--warn: #fbbf24;
--warn-bg: #2a2110;
--warn-border: #533f14;
--info-bg: #16233d;
--info-border: #2a3f68;
--code-bg: #262a31;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.4), 0 1px 2px rgba(0, 0, 0, 0.3);
}
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.5;
}
a { color: var(--accent); }
header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--border);
background: var(--bg-card);
flex-wrap: wrap;
}
header h1 {
font-size: 1.15rem;
margin: 0;
font-weight: 600;
}
#auth-area {
display: flex;
align-items: center;
gap: 0.6rem;
font-size: 0.9rem;
}
#auth-area img {
width: 28px;
height: 28px;
border-radius: 50%;
display: block;
}
main {
max-width: 900px;
margin: 0 auto;
padding: 1.25rem;
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 10px;
padding: 1.1rem 1.25rem;
box-shadow: var(--shadow);
}
.card h2 {
margin-top: 0;
font-size: 1.05rem;
}
button, .btn {
font: inherit;
cursor: pointer;
border: 1px solid var(--border);
background: var(--bg-card);
color: var(--text);
border-radius: 6px;
padding: 0.45rem 0.9rem;
}
button:hover, .btn:hover { border-color: var(--accent); }
button.primary {
background: var(--accent);
color: var(--accent-text);
border-color: var(--accent);
}
button.primary:disabled {
opacity: 0.55;
cursor: not-allowed;
}
button.danger {
color: var(--danger);
border-color: var(--danger-border);
}
button.danger:hover { background: var(--danger-bg); }
input[type="text"] {
font: inherit;
padding: 0.45rem 0.6rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg);
color: var(--text);
width: 100%;
max-width: 320px;
}
input[type="text"].invalid { border-color: var(--danger); }
label {
display: block;
font-size: 0.85rem;
color: var(--text-muted);
margin-bottom: 0.3rem;
}
.field { margin-bottom: 0.8rem; }
.hint {
font-size: 0.8rem;
color: var(--text-muted);
margin-top: 0.3rem;
}
.hint.error { color: var(--danger); }
#dropzone {
border: 2px dashed var(--border);
border-radius: 8px;
padding: 1.5rem;
text-align: center;
color: var(--text-muted);
cursor: pointer;
transition: border-color 0.15s ease, background-color 0.15s ease;
}
#dropzone.dragover {
border-color: var(--accent);
background: var(--info-bg);
}
#dropzone .filename {
color: var(--text);
font-weight: 600;
margin-top: 0.4rem;
word-break: break-all;
}
.banner {
border-radius: 8px;
border: 1px solid var(--border);
padding: 0.7rem 0.9rem;
margin-bottom: 0.9rem;
font-size: 0.9rem;
}
.banner.success {
background: var(--success-bg);
border-color: var(--success-border);
color: var(--success);
}
.banner.error {
background: var(--danger-bg);
border-color: var(--danger-border);
color: var(--danger);
}
.banner.warning {
background: var(--warn-bg);
border-color: var(--warn-border);
color: var(--warn);
}
.banner.info {
background: var(--info-bg);
border-color: var(--info-border);
color: var(--text);
}
.banner a { font-weight: 600; }
table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
th, td {
text-align: left;
padding: 0.5rem 0.6rem;
border-bottom: 1px solid var(--border);
vertical-align: middle;
}
th { color: var(--text-muted); font-weight: 600; font-size: 0.8rem; text-transform: uppercase; }
.table-scroll { overflow-x: auto; }
tr.user-row img {
width: 20px;
height: 20px;
border-radius: 50%;
vertical-align: middle;
margin-right: 0.4rem;
}
.badge {
display: inline-block;
font-size: 0.72rem;
font-weight: 600;
padding: 0.1rem 0.45rem;
border-radius: 999px;
background: var(--info-bg);
color: var(--accent);
border: 1px solid var(--info-border);
}
input[type="checkbox"] + .badge {
margin-left: 0.4rem;
}
.muted { color: var(--text-muted); font-size: 0.85rem; }
code {
background: var(--code-bg);
padding: 0.1rem 0.35rem;
border-radius: 4px;
font-size: 0.85em;
}
details > summary {
cursor: pointer;
font-weight: 600;
font-size: 1.05rem;
}
footer {
max-width: 900px;
margin: 0 auto;
padding: 1.25rem;
color: var(--text-muted);
font-size: 0.8rem;
text-align: center;
}
.empty {
color: var(--text-muted);
font-size: 0.9rem;
font-style: italic;
}
.row-actions { text-align: right; white-space: nowrap; }
</style>
</head>
<body>
<header>
<h1>pages.elijah.run</h1>
<div id="auth-area"></div>
</header>
<main>
<section id="upload-card" class="card" hidden>
<h2>Upload a page</h2>
<div id="upload-banner"></div>
<div id="dropzone">
<div id="dropzone-label">Drag &amp; drop an <code>.html</code> file or a <code>.zip</code> of static assets here, or click to choose one.</div>
<div class="filename" id="dropzone-filename"></div>
<input type="file" id="file-input" accept=".html,.htm,.zip" hidden>
</div>
<div class="field" style="margin-top: 0.9rem;">
<label for="name-input">Page name (slug)</label>
<input type="text" id="name-input" maxlength="63" placeholder="my-page" autocomplete="off">
<div class="hint" id="name-hint">Lowercase letters, digits, hyphens; must start with a letter or digit.</div>
</div>
<button type="button" class="primary" id="upload-submit" disabled>Upload</button>
</section>
<section id="upload-notice" class="card" hidden>
<p class="muted" style="margin: 0;">Your account isn't approved to upload yet. Ask an admin to enable it for you.</p>
</section>
<section class="card">
<h2>Pages</h2>
<div id="pages-banner"></div>
<div class="table-scroll">
<table id="pages-table">
<thead>
<tr>
<th>Slug</th>
<th>Owner</th>
<th>Size</th>
<th>Created</th>
<th>Trusted</th>
<th></th>
</tr>
</thead>
<tbody id="pages-body"></tbody>
</table>
</div>
<div class="empty" id="pages-empty" hidden>No pages published yet.</div>
</section>
<section id="admin-card" class="card" hidden>
<details id="admin-details">
<summary>Users</summary>
<div id="users-banner" style="margin-top: 0.8rem;"></div>
<div class="table-scroll">
<table id="users-table">
<thead>
<tr>
<th>Login</th>
<th>Can upload</th>
<th>Trusted</th>
<th>Role</th>
<th>Joined</th>
</tr>
</thead>
<tbody id="users-body"></tbody>
</table>
</div>
</details>
</section>
</main>
<footer>
Hosted pages are sandboxed (no cookies/storage) unless marked trusted by an admin.
</footer>
<script>
(function () {
"use strict";
// ── State ────────────────────────────────────────────────────────────
var me = { authenticated: false };
var currentFile = null;
var usersLoaded = false;
// ── DOM refs ─────────────────────────────────────────────────────────
var authArea = document.getElementById("auth-area");
var uploadCard = document.getElementById("upload-card");
var uploadNotice = document.getElementById("upload-notice");
var uploadBanner = document.getElementById("upload-banner");
var dropzone = document.getElementById("dropzone");
var dropzoneFilename = document.getElementById("dropzone-filename");
var fileInput = document.getElementById("file-input");
var nameInput = document.getElementById("name-input");
var nameHint = document.getElementById("name-hint");
var uploadSubmit = document.getElementById("upload-submit");
var pagesBanner = document.getElementById("pages-banner");
var pagesBody = document.getElementById("pages-body");
var pagesEmpty = document.getElementById("pages-empty");
var adminCard = document.getElementById("admin-card");
var adminDetails = document.getElementById("admin-details");
var usersBanner = document.getElementById("users-banner");
var usersBody = document.getElementById("users-body");
var DEFAULT_NAME_HINT = "Lowercase letters, digits, hyphens; must start with a letter or digit.";
// ── Reserved names + slug grammar (mirrors src/core/slug.rs) ────────────
var RESERVED = [
"api", "auth", "admin", "assets", "static", "login", "logout", "oauth",
"favicon.ico", "robots.txt", "sitemap.xml", "index.html", "www",
"health", "status", "well-known", "cdn-cgi"
];
var SLUG_RE = /^[a-z0-9][a-z0-9-]{0,62}$/;
/**
* Mirrors `core::slug::default_slug_from_filename`: strip the last
* extension (unless the '.' is the first character), lowercase, map
* anything outside [a-z0-9] to '-', collapse consecutive hyphens, trim
* leading/trailing hyphens, cap at 63 chars, and fall back to "my-page"
* if the result is empty or reserved.
*/
function defaultSlugFromFilename(filename) {
var idx = filename.lastIndexOf(".");
var stem = idx > 0 ? filename.slice(0, idx) : filename;
var lowered = stem.toLowerCase();
var mapped = "";
var lastWasHyphen = false;
for (var i = 0; i < lowered.length; i++) {
var c = lowered.charAt(i);
var isLowerOrDigit = (c >= "a" && c <= "z") || (c >= "0" && c <= "9");
var out = isLowerOrDigit ? c : "-";
if (out === "-") {
if (lastWasHyphen) continue;
lastWasHyphen = true;
} else {
lastWasHyphen = false;
}
mapped += out;
}
var trimmed = mapped.replace(/^-+/, "").replace(/-+$/, "");
var truncated = trimmed.slice(0, 63);
while (truncated.charAt(truncated.length - 1) === "-") {
truncated = truncated.slice(0, -1);
}
if (truncated === "" || RESERVED.indexOf(truncated) !== -1) {
return "my-page";
}
return truncated;
}
/** Client-side mirror of `validate_slug`'s grammar + reserved check, for
* a live hint only — the server is the source of truth and re-validates
* everything. */
function slugHint(name) {
if (name === "") return "Page name must not be empty.";
if (!SLUG_RE.test(name)) {
return "Only lowercase letters, digits, and hyphens are allowed; must start with a letter or digit.";
}
if (RESERVED.indexOf(name) !== -1) {
return "'" + name + "' is a reserved name and cannot be used.";
}
return null;
}
function isValidSlug(name) {
return slugHint(name) === null;
}
// ── Small helpers ────────────────────────────────────────────────────
function clearChildren(el) {
while (el.firstChild) el.removeChild(el.firstChild);
}
function humanSize(bytes) {
if (bytes < 1024) return bytes + " B";
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KiB";
return (bytes / (1024 * 1024)).toFixed(1) + " MiB";
}
function formatDate(s) {
if (!s) return "";
var iso = s.indexOf("T") === -1 ? s.replace(" ", "T") + "Z" : s;
var d = new Date(iso);
if (isNaN(d.getTime())) return s;
return d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
}
function el(tag, opts) {
var e = document.createElement(tag);
opts = opts || {};
if (opts.text !== undefined) e.textContent = opts.text;
if (opts.className) e.className = opts.className;
return e;
}
function showBanner(container, kind, message, linkUrl, linkText) {
clearChildren(container);
var b = el("div", { className: "banner " + kind });
b.appendChild(document.createTextNode(message));
if (linkUrl) {
b.appendChild(document.createTextNode(" "));
var a = el("a", { text: linkText || linkUrl });
a.href = linkUrl;
a.rel = "noopener";
b.appendChild(a);
}
container.appendChild(b);
}
function clearBanner(container) {
clearChildren(container);
}
/** Wraps fetch + JSON parsing; never throws — resolves to
* { ok, status, body } where `body` is the parsed JSON (or null if the
* response had no/invalid JSON body). */
function apiFetch(url, options) {
options = options || {};
options.credentials = "same-origin";
return fetch(url, options).then(function (resp) {
return resp.text().then(function (text) {
var body = null;
if (text) {
try { body = JSON.parse(text); } catch (e) { body = null; }
}
return { ok: resp.ok, status: resp.status, body: body };
});
}).catch(function (err) {
return { ok: false, status: 0, body: null, networkError: true };
});
}
// ── Auth area ────────────────────────────────────────────────────────
function renderAuthArea() {
clearChildren(authArea);
if (me.authenticated && me.user) {
if (me.user.avatar_url) {
var img = el("img");
img.src = me.user.avatar_url;
img.alt = "";
authArea.appendChild(img);
}
authArea.appendChild(el("span", { text: me.user.login }));
if (me.user.is_admin) {
authArea.appendChild(el("span", { className: "badge", text: "admin" }));
}
var logoutBtn = el("button", { text: "Logout" });
logoutBtn.type = "button";
logoutBtn.addEventListener("click", function () {
logoutBtn.disabled = true;
apiFetch("/auth/logout", { method: "POST" }).then(function () {
window.location.reload();
});
});
authArea.appendChild(logoutBtn);
} else {
var link = el("a", { className: "btn", text: "Sign in with GitHub" });
link.href = "/auth/login";
authArea.appendChild(link);
}
}
// ── Upload card ──────────────────────────────────────────────────────
function renderUploadArea() {
var canUpload = !!(me.authenticated && me.user && me.user.can_upload);
var authedNoUpload = !!(me.authenticated && me.user && !me.user.can_upload);
uploadCard.hidden = !canUpload;
uploadNotice.hidden = !authedNoUpload;
}
function setDropzoneFile(file) {
currentFile = file;
if (file) {
dropzoneFilename.textContent = file.name;
nameInput.value = defaultSlugFromFilename(file.name);
} else {
dropzoneFilename.textContent = "";
}
validateNameField();
}
function extensionAllowed(filename) {
var lower = filename.toLowerCase();
return lower.slice(-5) === ".html" || lower.slice(-4) === ".htm" || lower.slice(-4) === ".zip";
}
function contentTypeFor(filename) {
return filename.toLowerCase().slice(-4) === ".zip" ? "application/zip" : "text/html";
}
function validateNameField() {
var name = nameInput.value;
var hint = slugHint(name);
var fileOk = !!currentFile && extensionAllowed(currentFile.name);
if (!currentFile) {
nameInput.classList.remove("invalid");
nameHint.textContent = DEFAULT_NAME_HINT;
nameHint.classList.remove("error");
uploadSubmit.disabled = true;
return;
}
if (!fileOk) {
nameHint.textContent = "Only .html, .htm, or .zip files are supported.";
nameHint.classList.add("error");
uploadSubmit.disabled = true;
return;
}
if (hint) {
nameInput.classList.add("invalid");
nameHint.textContent = hint;
nameHint.classList.add("error");
uploadSubmit.disabled = true;
} else {
nameInput.classList.remove("invalid");
nameHint.textContent = DEFAULT_NAME_HINT;
nameHint.classList.remove("error");
uploadSubmit.disabled = false;
}
}
dropzone.addEventListener("click", function () {
fileInput.click();
});
dropzone.addEventListener("dragover", function (e) {
e.preventDefault();
dropzone.classList.add("dragover");
});
dropzone.addEventListener("dragleave", function () {
dropzone.classList.remove("dragover");
});
dropzone.addEventListener("drop", function (e) {
e.preventDefault();
dropzone.classList.remove("dragover");
var files = e.dataTransfer && e.dataTransfer.files;
if (files && files.length > 0) {
setDropzoneFile(files[0]);
}
});
fileInput.addEventListener("change", function () {
if (fileInput.files && fileInput.files.length > 0) {
setDropzoneFile(fileInput.files[0]);
}
});
nameInput.addEventListener("input", validateNameField);
// Mirrors the server's 10 MiB compressed-upload cap (design §2.2) so an
// oversize file gets a friendly message instead of a raw HTTP 413.
var MAX_UPLOAD_BYTES = 10 * 1024 * 1024;
uploadSubmit.addEventListener("click", function () {
if (!currentFile || !isValidSlug(nameInput.value)) return;
var file = currentFile;
var name = nameInput.value;
if (file.size > MAX_UPLOAD_BYTES) {
showBanner(uploadBanner, "error", "File is too large — the limit is 10 MiB.");
return;
}
uploadSubmit.disabled = true;
clearBanner(uploadBanner);
var url = "/api/pages?name=" + encodeURIComponent(name);
apiFetch(url, {
method: "POST",
headers: { "Content-Type": contentTypeFor(file.name) },
body: file
}).then(function (res) {
if (res.networkError) {
showBanner(uploadBanner, "error", "Network error — please try again.");
uploadSubmit.disabled = false;
return;
}
if (res.status === 201 && res.body) {
showBanner(uploadBanner, "success", "Uploaded! Live at ", res.body.url, res.body.url);
currentFile = null;
fileInput.value = "";
nameInput.value = "";
dropzoneFilename.textContent = "";
nameHint.textContent = DEFAULT_NAME_HINT;
nameHint.classList.remove("error");
nameInput.classList.remove("invalid");
loadPages();
return;
}
if (res.status === 409) {
showBanner(uploadBanner, "error", "That name is already taken — pick another.");
uploadSubmit.disabled = false;
return;
}
if (res.status === 403) {
var msg = (res.body && res.body.error) || "You're not authorized to upload right now.";
showBanner(uploadBanner, "warning", msg);
uploadSubmit.disabled = false;
return;
}
if (res.status === 401) {
showBanner(uploadBanner, "warning", "Please sign in to upload.");
uploadSubmit.disabled = false;
return;
}
var errMsg = (res.body && res.body.error) || ("Upload failed (HTTP " + res.status + ").");
showBanner(uploadBanner, "error", errMsg);
uploadSubmit.disabled = false;
});
});
// ── Pages list ───────────────────────────────────────────────────────
function renderPages(pages) {
clearChildren(pagesBody);
pagesEmpty.hidden = pages.length > 0;
pages.forEach(function (row) {
var tr = el("tr");
var slugTd = el("td");
var link = el("a", { text: row.slug });
link.href = "/" + row.slug + "/";
slugTd.appendChild(link);
tr.appendChild(slugTd);
tr.appendChild(el("td", { text: row.owner_login }));
tr.appendChild(el("td", { text: humanSize(row.total_bytes) }));
tr.appendChild(el("td", { text: formatDate(row.created_at) }));
var trustedTd = el("td");
var effectiveTrust = !!(row.effective_trust !== undefined ? row.effective_trust : (row.trusted || row.owner_trusted));
var isAdmin = !!(me.authenticated && me.user && me.user.is_admin);
if (isAdmin) {
var trustCheckbox = el("input");
trustCheckbox.type = "checkbox";
trustCheckbox.checked = !!row.trusted;
trustCheckbox.title = row.owner_trusted
? "Owner is trusted, so this page is served trusted regardless of this checkbox."
: "Mark this page as trusted (served without the CSP sandbox).";
trustCheckbox.addEventListener("change", function () {
var checked = trustCheckbox.checked;
trustCheckbox.disabled = true;
apiFetch("/api/pages/" + encodeURIComponent(row.slug), {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ trusted: checked })
}).then(function (res) {
if (res.ok) {
clearBanner(pagesBanner);
loadPages();
} else {
trustCheckbox.checked = !checked;
trustCheckbox.disabled = false;
var msg = (res.body && res.body.error) || "Update failed.";
showBanner(pagesBanner, "error", msg);
}
});
});
trustedTd.appendChild(trustCheckbox);
if (effectiveTrust) {
trustedTd.appendChild(el("span", { className: "badge", text: "trusted" }));
}
} else if (effectiveTrust) {
trustedTd.appendChild(el("span", { className: "badge", text: "trusted" }));
}
tr.appendChild(trustedTd);
var actionsTd = el("td", { className: "row-actions" });
var canDelete = !!(me.authenticated && me.user &&
(me.user.is_admin || me.user.login === row.owner_login));
if (canDelete) {
var delBtn = el("button", { className: "danger", text: "Delete" });
delBtn.type = "button";
delBtn.addEventListener("click", function () {
if (!window.confirm("Delete page '" + row.slug + "'? This cannot be undone.")) return;
delBtn.disabled = true;
apiFetch("/api/pages/" + encodeURIComponent(row.slug), { method: "DELETE" })
.then(function (res) {
if (res.ok) {
clearBanner(pagesBanner);
loadPages();
} else {
var msg = (res.body && res.body.error) || "Delete failed.";
showBanner(pagesBanner, "error", msg);
delBtn.disabled = false;
}
});
});
actionsTd.appendChild(delBtn);
}
tr.appendChild(actionsTd);
pagesBody.appendChild(tr);
});
}
function loadPages() {
return apiFetch("/api/pages").then(function (res) {
if (res.ok && res.body && Array.isArray(res.body.pages)) {
renderPages(res.body.pages);
} else {
showBanner(pagesBanner, "error", "Failed to load pages.");
}
});
}
// ── Admin: users panel ───────────────────────────────────────────────
function renderUsers(users) {
clearChildren(usersBody);
users.forEach(function (u) {
var tr = el("tr", { className: "user-row" });
var loginTd = el("td");
if (u.avatar_url) {
var img = el("img");
img.src = u.avatar_url;
img.alt = "";
loginTd.appendChild(img);
}
loginTd.appendChild(document.createTextNode(u.login));
tr.appendChild(loginTd);
var uploadTd = el("td");
var checkbox = el("input");
checkbox.type = "checkbox";
checkbox.checked = !!u.can_upload;
checkbox.addEventListener("change", function () {
var checked = checkbox.checked;
checkbox.disabled = true;
apiFetch("/api/users/" + encodeURIComponent(u.id), {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ can_upload: checked })
}).then(function (res) {
checkbox.disabled = false;
if (!res.ok) {
checkbox.checked = !checked;
var msg = (res.body && res.body.error) || "Update failed.";
showBanner(usersBanner, "error", msg);
} else {
clearBanner(usersBanner);
}
});
});
uploadTd.appendChild(checkbox);
tr.appendChild(uploadTd);
var trustedTd = el("td");
var trustedCheckbox = el("input");
trustedCheckbox.type = "checkbox";
trustedCheckbox.checked = !!u.trusted;
trustedCheckbox.title = "Every page this user owns is served trusted (no CSP sandbox) while this is checked.";
trustedCheckbox.addEventListener("change", function () {
var checked = trustedCheckbox.checked;
trustedCheckbox.disabled = true;
apiFetch("/api/users/" + encodeURIComponent(u.id), {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ trusted: checked })
}).then(function (res) {
trustedCheckbox.disabled = false;
if (!res.ok) {
trustedCheckbox.checked = !checked;
var msg = (res.body && res.body.error) || "Update failed.";
showBanner(usersBanner, "error", msg);
} else {
clearBanner(usersBanner);
}
});
});
trustedTd.appendChild(trustedCheckbox);
tr.appendChild(trustedTd);
var roleTd = el("td");
if (u.is_admin) roleTd.appendChild(el("span", { className: "badge", text: "admin" }));
tr.appendChild(roleTd);
tr.appendChild(el("td", { text: formatDate(u.created_at) }));
usersBody.appendChild(tr);
});
}
function loadUsers() {
return apiFetch("/api/users").then(function (res) {
if (res.ok && res.body && Array.isArray(res.body.users)) {
renderUsers(res.body.users);
} else {
var msg = (res.body && res.body.error) || "Failed to load users.";
showBanner(usersBanner, "error", msg);
}
});
}
adminDetails.addEventListener("toggle", function () {
if (adminDetails.open && !usersLoaded) {
usersLoaded = true;
loadUsers();
}
});
function renderAdminArea() {
adminCard.hidden = !(me.authenticated && me.user && me.user.is_admin);
}
// ── Init ─────────────────────────────────────────────────────────────
function loadMe() {
return apiFetch("/api/me").then(function (res) {
if (res.ok && res.body) {
me = res.body;
} else {
me = { authenticated: false };
}
renderAuthArea();
renderUploadArea();
renderAdminArea();
});
}
loadMe().then(function () {
loadPages();
});
}());
</script>
</body>
</html>

@ -0,0 +1,42 @@
# Wrangler configuration for the pages Worker.
# Used by `wrangler deploy` — the Cloudflare Terraform provider v4 does not
# support uploading ES module workers that import wasm files, so wrangler
# handles the multipart module bundle upload (see pages/CLAUDE.md
# "Deviations from repo defaults"). OpenTofu (infra/) owns the R2 bucket,
# D1 database, DNS, and rate limits.
name = "pages"
main = "build/index.js"
compatibility_date = "2026-07-12"
[build]
command = "worker-build --release"
# Route: maps all of pages.elijah.run to this worker. The DNS record it
# rides on is created by the OpenTofu config in infra/ (proxied AAAA 100::).
[[routes]]
pattern = "pages.elijah.run/*"
zone_name = "elijah.run"
# D1 database binding — referenced in workers-rs code as `env.d1("DB")`.
[[d1_databases]]
binding = "DB"
database_name = "pages"
# ID output by the OpenTofu D1 resource in infra/ (tofu output d1_database_id).
database_id = "6c5cd319-18d6-4ec8-bfa3-64479aef12ed"
# R2 bucket binding — referenced in workers-rs code as `env.bucket("PAGES")`.
[[r2_buckets]]
binding = "PAGES"
bucket_name = "pages"
[vars]
BASE_URL = "https://pages.elijah.run"
# GitHub OAuth App client ID (public). Set the real value before deploying.
GITHUB_CLIENT_ID = "Ov23liQFioTbWqqfUPYi"
# GitHub login that is auto-promoted to admin on first login. Set the real value before deploying.
ADMIN_GITHUB_LOGIN = "pop"
# GITHUB_CLIENT_SECRET is a wrangler secret, not a var — set it with:
# wrangler secret put GITHUB_CLIENT_SECRET
# Never store it in this file or commit it to the repo.
Loading…
Cancel
Save