You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
vibed/pages/.beans/pages-ytau--update-a-page-i...

56 lines
4.7 KiB
Markdown

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

---
# 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.