|
|
---
|
|
|
# pages-ytau
|
|
|
title: 'Update a page in place: re-upload content (PUT /api/pages/:slug) + upload modal'
|
|
|
status: completed
|
|
|
type: feature
|
|
|
priority: normal
|
|
|
created_at: 2026-07-14T20:15:00Z
|
|
|
updated_at: 2026-07-14T20:34:43Z
|
|
|
---
|
|
|
|
|
|
Today updating a page means delete + re-create. Add in-place content replacement.
|
|
|
|
|
|
## Backend
|
|
|
- [x] `core::origin::can_replace` (owner-or-admin, same rule as `can_delete`) + tests
|
|
|
- [x] `Db::update_page_stats(slug, file_count, total_bytes)` (NotFound if slug missing)
|
|
|
- [x] `PUT /api/pages/{slug}` handler `replace_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
|
|
|
- [x] Route wiring with the 10 MiB body limit applied to PUT only
|
|
|
- [x] Refactor shared upload plumbing out of `create_page`
|
|
|
|
|
|
## Frontend (static/index.html)
|
|
|
- [x] Replace the always-visible "Upload a page" card with a modal
|
|
|
- [x] "+ New Page" button opens the modal in create mode
|
|
|
- [x] "Update" button on each page row (owner/admin + can_upload) opens the modal in replace mode with the slug pre-filled and read-only
|
|
|
- [x] Modal: Esc/backdrop/close-button dismissal, focus handling
|
|
|
|
|
|
## Docs
|
|
|
- [x] Design doc addendum, ARCHITECTURE, PLANNING, README (HTTP surface table)
|
|
|
- [x] `USER_CONTENT_CACHE_CONTROL` doc comment says "v0 has no in-place update" — update it
|
|
|
|
|
|
## Summary of Changes
|
|
|
|
|
|
**Backend**
|
|
|
- `core/origin.rs`: `can_replace` (owner-or-admin, delegates to `can_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_at` deliberately 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 with `create_page`) and `list_page_keys` (cursor-paginated prefix listing, shared with `delete_page`).
|
|
|
- `routes/mod.rs`: PUT wired with the 10 MiB `DefaultBodyLimit` merged 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.css` now 404 (stale keys pruned), `file_count` 3→1, `created_at`/`owner_login` unchanged. Admin-set `trusted` survived 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 dev` rewrites the request `Origin` to the `[[routes]]` host, so `BASE_URL=http://localhost:8787` in `.dev.vars` makes every mutating route 403. Use `BASE_URL=http://pages.elijah.run` locally. Recorded in PLANNING.md.
|
|
|
- `wrangler dev` also drops a miniflare cache in `node_modules/.mf`; added to `.gitignore`.
|
|
|
- Possible follow-ups (not done): an `updated_at` column + "Updated" column in the list (needs a migration); shorter `Cache-Control` or cache-busting so a replaced page isn't stale for up to 5 minutes.
|