4.7 KiB
| title | status | type | priority | created_at | updated_at |
|---|---|---|---|---|---|
| Update a page in place: re-upload content (PUT /api/pages/:slug) + upload modal | completed | feature | normal | 2026-07-14T20:15:00Z | 2026-07-14T20:34:43Z |
Today updating a page means delete + re-create. Add in-place content replacement.
Backend
core::origin::can_replace(owner-or-admin, same rule ascan_delete) + testsDb::update_page_stats(slug, file_count, total_bytes)(NotFound if slug missing)PUT /api/pages/{slug}handlerreplace_page: auth → can_upload → Origin → page exists → owner/admin → size caps → content-type → build_manifest → write new R2 objects → delete stale keys under<slug>/not in the new manifest → update D1 stats → 200- Route wiring with the 10 MiB body limit applied to PUT only
- Refactor shared upload plumbing out of
create_page
Frontend (static/index.html)
- Replace the always-visible "Upload a page" card with a modal
- "+ New Page" button opens the modal in create mode
- "Update" button on each page row (owner/admin + can_upload) opens the modal in replace mode with the slug pre-filled and read-only
- Modal: Esc/backdrop/close-button dismissal, focus handling
Docs
- Design doc addendum, ARCHITECTURE, PLANNING, README (HTTP surface table)
USER_CONTENT_CACHE_CONTROLdoc comment says "v0 has no in-place update" — update it
Summary of Changes
Backend
core/origin.rs:can_replace(owner-or-admin, delegates tocan_delete) + tests, incl. one pinning the "same rule as delete" contract.db.rs:Db::update_page_stats(slug, file_count, total_bytes)— refreshes stats only;owner_id,trusted,created_atdeliberately preserved.routes/api.rs:PUT /api/pages/{slug}→replace_page. Auth →can_upload→ Origin → page exists → owner/admin → size/type/manifest → snapshot old R2 keys → write new objects → prune stale keys (best-effort) → update D1 stats → 200. Extracted two shared helpers:manifest_from_request(size caps + Content-Type +build_manifest, shared withcreate_page) andlist_page_keys(cursor-paginated prefix listing, shared withdelete_page).routes/mod.rs: PUT wired with the 10 MiBDefaultBodyLimitmerged onto the PUT method only, so PATCH/DELETE keep axum's 2 MiB default.
Not transactional, by necessity: R2 has no multi-object transaction and the old bytes aren't retained, so a replace can't roll back like a create can. Ordering bounds the damage: new content is written before any old key is deleted (a viewer mid-update sees old-or-new per file, never a blank page), stale-key deletion is last and best-effort (a failure only orphans an object), and D1 stats are updated only after R2 succeeds. A mid-way put failure returns 500 with a retry message and leaves D1 untouched.
Frontend — the always-visible "Upload a page" card is gone. One modal serves both flows: "+ New Page" (create) and a per-row "Update" button (replace: slug pre-filled + read-only, filename no longer rewrites the slug). Esc / backdrop / Cancel / × all dismiss; focus returns to the opening button; success closes the modal and banners on the pages card.
Docs: design §4.6b (replace pipeline + why it can't be transactional), HTTP surface table, §5 scope cut struck through, ARCHITECTURE "Page content lifecycle", README, PLANNING.
Verification
- Full suite green: 152 unit + 19 doctests, both-target clippy clean.
- End-to-end against
wrangler dev(local D1 + R2), session seeded directly in local D1: create 3-file zip → serves v1 → PUT single .html → serves v2,old.js/style.cssnow 404 (stale keys pruned),file_count3→1,created_at/owner_loginunchanged. Admin-settrustedsurvived an owner re-upload. Negative paths: 401 (no session), 403 (bad/missing Origin, non-owner,can_upload=0), 404 (unknown slug), 400 (bad content-type, zip without index.html), 413 (11 MiB body). - UI driven in jsdom with a stubbed fetch: 37/37 assertions (modal modes, correct endpoint+method per mode, slug not rewritten in replace mode, dismissal paths, no state leaking between opens).
Notes / follow-ups
- Local-dev gotcha:
wrangler devrewrites the requestOriginto the[[routes]]host, soBASE_URL=http://localhost:8787in.dev.varsmakes every mutating route 403. UseBASE_URL=http://pages.elijah.runlocally. Recorded in PLANNING.md. wrangler devalso drops a miniflare cache innode_modules/.mf; added to.gitignore.- Possible follow-ups (not done): an
updated_atcolumn + "Updated" column in the list (needs a migration); shorterCache-Controlor cache-busting so a replaced page isn't stale for up to 5 minutes.