feat(pages): update a page in place via re-upload [pages-ytau]

Updating a page meant deleting it and re-creating it under the same name.
Add PUT /api/pages/:slug, which replaces an existing page's content while
keeping its URL, owner, trust flag, and created_at.

Authorized to owner-or-admin (core::origin::can_replace, same rule as
delete) *and* can_upload, so a revoked uploader can't keep pushing content
to pages they already own.

The replace can't roll back the way create does — R2 has no multi-object
transaction and the previous bytes aren't retained — so the ordering bounds
the damage instead: new objects are written before any old key is deleted (a
viewer mid-update sees old-or-new per file, never a blank page), stale keys
are pruned last and best-effort (a failure only orphans an object), and the
D1 stats are refreshed only once R2 succeeds. Design §4.6b covers the
reasoning; §5's "no page overwrite" scope cut is retired.

In the UI, the always-visible upload card becomes a modal driven by two
entry points: "+ New Page" (create) and a per-row "Update" (replace, with
the slug pre-filled and read-only).

Verified end to end against wrangler dev with local D1/R2 — create, serve,
replace, stale assets pruned, stats/trust/ownership preserved, plus the
401/403/404/400/413 paths — and the UI driven in jsdom (37 assertions).
152 unit + 19 doctests, both-target clippy clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
main
Elijah Voigt 1 week ago
parent d9c4830cf9
commit bc4c952533

@ -0,0 +1,55 @@
---
# 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.

3
pages/.gitignore vendored

@ -3,6 +3,9 @@
.env
.dev.vars
.wrangler/
# `wrangler dev` drops a miniflare cache here (node_modules/.mf) even though
# this project has no npm dependencies.
node_modules/
infra/.terraform/
infra/*.tfstate*
infra/.terraform.lock.hcl

@ -10,7 +10,10 @@ WebAssembly.
- 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.
- Update a page in place: **Update** on any page you own re-uploads its
content under the same name (the URL never changes, and files the new
upload doesn't include are removed).
- Admins approve which users may upload, and can delete or update 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
@ -26,7 +29,7 @@ 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)
- `/api/*` — JSON API (list/upload/update/delete pages, manage users)
- `/<slug>/*` — page content served from R2 with strict security headers
Uploads are validated hard: strict slug grammar + reserved names, zip

@ -29,7 +29,24 @@ Full design + security analysis: `plans/2026-07-12-pages-design.md`.
- **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 `/`.
with `include_str!` and served at `/`. Publishing happens in one modal used
by two flows (pages-ytau): "+ New Page" (`POST /api/pages?name=<slug>`) and
a per-row "Update" (`PUT /api/pages/<slug>`, slug fixed and read-only).
## Page content lifecycle
A page's bytes live under one R2 prefix, `<slug>/`, with a D1 `pages` row
holding its owner, stats, and trust flag. The three mutating paths differ only
in how they treat that prefix (see design §4.6, §4.6b, §4.3):
- **Create** (`POST`) reserves the slug in D1 *first*, then writes R2 objects;
any failure rolls both back, since the slug held nothing before.
- **Replace** (`PUT`) writes the new objects over the prefix, then prunes the
old keys the new upload didn't overwrite, then updates the D1 stats —
preserving `owner_id`, `trusted`, and `created_at`. It cannot roll back (R2
has no multi-object transaction), so the ordering is chosen so a viewer
mid-update sees old-or-new content per file, never a blank page.
- **Delete** lists the prefix, deletes every object, then drops the D1 row.
## Trust boundaries

@ -46,3 +46,20 @@
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.
- **2026-07-14** — pages-ytau: in-place page updates, retiring the design's
"no page overwrite: delete + re-upload" scope cut. New `PUT
/api/pages/:slug` (design §4.6b) replaces a page's content — owner-or-admin
*and* `can_upload`, new objects written before stale keys are pruned, D1
stats refreshed while `owner_id`/`trusted`/`created_at` are preserved. No
schema change. The UI's always-visible upload card became a modal driven by
a "+ New Page" button and a per-row "Update" button (slug pre-filled,
read-only). 152 unit + 19 doctests, both-target clippy clean. Verified end
to end against `wrangler dev` with local D1/R2 (create → serve → replace →
stale assets 404 → stats/trust/ownership preserved; 401/403/404/400/413
paths) and the UI driven in jsdom (37 assertions).
Local-dev gotcha found while verifying: `wrangler dev` rewrites the request
`Origin` to the `[[routes]]` host (`pages.elijah.run`), so a local
`BASE_URL=http://localhost:8787` makes every mutating route 403 on the
Origin check. Set `BASE_URL=http://pages.elijah.run` in `.dev.vars` when
exercising the API locally.

@ -158,6 +158,7 @@ so it should be granted even more sparingly than a single-page grant.
| 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 |
| PUT | `/api/pages/:slug` | can_upload **and** (owner or admin) | Replace an existing page's content in place — same raw body shape as POST. 200 / 400 / 401 / 403 / 404 / 413 (pages-ytau, §4.6b) |
| 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) |
@ -255,6 +256,38 @@ infra/*.tf
7. `put` each file to R2 under `<slug>/…`; on failure, best-effort cleanup
of written objects and the D1 row → 500.
### 4.6b Replace pipeline (pages-ytau, added after v0)
`PUT /api/pages/:slug` replaces an existing page's content, retiring the §5
scope cut "no page overwrite/update: delete + re-upload".
1. Auth → 401; `can_upload` → 403 (a revoked uploader cannot keep pushing
content to pages they already own); Origin check → 403.
2. `get_page(slug)` → 404 if unknown; owner-or-admin → 403 (same rule as
delete: `core::origin::can_replace`).
3. Size caps / `Content-Type` / manifest validation — identical to §4.6
steps 4-5, shared with the create path via
`routes::api::manifest_from_request`.
4. List the existing `<slug>/` R2 keys, **then** `put` every new manifest
entry, **then** delete the old keys the new manifest didn't overwrite.
5. `update_page_stats` refreshes `file_count`/`total_bytes` only —
`owner_id`, `trusted`, and `created_at` are preserved, so an admin-granted
trust survives a re-upload and an admin replacing another user's page does
not take ownership of it.
Unlike §4.6, this is **not** rollback-able: R2 has no multi-object
transaction and the previous bytes are not kept anywhere, so once the first
`put` lands the page is already a mix of versions. The ordering above bounds
the damage — writing new content before deleting anything means a viewer
during the window sees old-or-new per file rather than a blank page, and
stale-key deletion is best-effort *after* the new content is durably written
(a failure there only orphans an object, which the next replace or a delete
sweeps up). A `put` failure mid-way returns 500 and leaves D1 untouched; the
fix is to re-upload, which is idempotent. See `routes::api::replace_page`.
Content is cached for 5 minutes (§4.7's `Cache-Control`), so a replaced page
can serve its previous version to a cached client for up to that long.
### 4.7 Serving pipeline
`GET /:slug[/*path]` → reserved/invalid slug → 404. Otherwise a D1
@ -275,7 +308,8 @@ natively-tested) and `src/routes/serve.rs` (the wasm-only caller).
## 5. v0 scope cuts (explicit)
- No page overwrite/update: delete + re-upload.
- ~~No page overwrite/update: delete + re-upload.~~ **Retired by pages-ytau**
`PUT /api/pages/:slug` replaces content in place (§4.6b).
- No per-page subdomains (see §2.1 residual risk).
- No OpenAPI spec generation (repo TODO is unresolved; endpoints documented
here and in README).

@ -54,9 +54,13 @@ 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.
/// Short, public cache: content changes only when the owner explicitly
/// re-uploads it — either delete + re-create, or an in-place replacement (`PUT
/// /api/pages/:slug`, pages-ytau) — so a five-minute edge/browser cache is safe
/// and reduces R2 read volume without risking long-lived staleness. The cost is
/// that a viewer can keep seeing the previous version for up to five minutes
/// after a replacement; that's the deliberate trade (no per-asset cache
/// busting exists, and a hard-refresh always bypasses it).
pub const USER_CONTENT_CACHE_CONTROL: &str = "public, max-age=300";
/// HTTP header name for [`CSP_SANDBOX`].

@ -55,6 +55,31 @@ pub fn can_delete(user_id: i64, is_admin: bool, owner_id: i64) -> bool {
is_admin || user_id == owner_id
}
/// Returns `true` if `user_id` may replace the content of a page owned by
/// `owner_id` (pages-ytau: `PUT /api/pages/:slug`) — the same owner-or-admin
/// rule as [`can_delete`], since replacing a page's content is equivalent in
/// blast radius to deleting it and re-uploading under the same slug.
///
/// Kept as a distinct (delegating) function rather than reusing `can_delete`
/// at the call site so the *authorization question being asked* is legible in
/// `routes::api::replace_page`, and so the two rules can diverge later
/// without touching either call site. Note the caller checks `can_upload`
/// separately — this predicate answers only "whose page is it", exactly as
/// `can_delete` does.
///
/// # Examples
///
/// ```
/// use pages::core::origin::can_replace;
///
/// assert!(can_replace(1, false, 1)); // owner
/// assert!(can_replace(2, true, 1)); // admin, not owner
/// assert!(!can_replace(2, false, 1)); // neither
/// ```
pub fn can_replace(user_id: i64, is_admin: bool, owner_id: i64) -> bool {
can_delete(user_id, is_admin, owner_id)
}
#[cfg(test)]
mod tests {
use super::*;
@ -141,4 +166,34 @@ mod tests {
fn admin_can_delete_own_page_too() {
assert!(can_delete(1, true, 1));
}
// ── can_replace ──────────────────────────────────────────────────────
#[test]
fn owner_can_replace_own_page() {
assert!(can_replace(1, false, 1));
}
#[test]
fn admin_can_replace_others_page() {
assert!(can_replace(2, true, 1));
}
#[test]
fn non_owner_non_admin_cannot_replace() {
assert!(!can_replace(2, false, 1));
}
#[test]
fn replace_matches_delete_for_every_combination() {
// Pins the "same rule as delete" contract documented on `can_replace`.
for user_id in [1, 2] {
for is_admin in [false, true] {
assert_eq!(
can_replace(user_id, is_admin, 1),
can_delete(user_id, is_admin, 1)
);
}
}
}
}

@ -546,6 +546,51 @@ impl Db {
}
}
/// Update a page's stored file stats after its content is replaced
/// in-place (pages-ytau, design §4.6b: `PUT /api/pages/:slug`).
///
/// Only touches `file_count`/`total_bytes` — `owner_id`, `trusted`, and
/// `created_at` are deliberately preserved: a replacement re-uploads
/// *content*, it does not re-publish the page. In particular, keeping
/// `trusted` means an admin-granted trust survives the owner pushing new
/// content, and keeping `owner_id` means an admin replacing someone
/// else's page does not silently take ownership of it.
///
/// Returns `Err(DbError::NotFound)` if `slug` does not exist, same
/// convention as [`Db::set_can_upload`]. Callers still do their own
/// [`Db::get_page`] first (to authorize owner-or-admin), so in practice
/// that variant only fires if the page is deleted concurrently mid-request.
pub async fn update_page_stats(
&self,
slug: &str,
file_count: i64,
total_bytes: i64,
) -> Result<(), DbError> {
let result = self
.0
.prepare("UPDATE pages SET file_count = ?1, total_bytes = ?2 WHERE slug = ?3")
.bind(&[
JsValue::from_f64(file_count as f64),
JsValue::from_f64(total_bytes as f64),
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(())
}
/// Delete a page's `pages` row by slug.
///
/// Only removes the D1 row — R2 object cleanup for the `<slug>/` prefix

@ -30,9 +30,9 @@ use serde_json::json;
use super::auth::current_user;
use super::AppState;
use crate::core::origin::{can_delete, origin_allowed};
use crate::core::origin::{can_delete, can_replace, origin_allowed};
use crate::core::slug::validate_slug;
use crate::core::upload::{build_manifest, UploadKind, UploadLimits};
use crate::core::upload::{build_manifest, ManifestEntry, UploadKind, UploadLimits};
use crate::db::DbError;
/// Maximum accepted raw upload size — 10 MiB (design §2.2, §4.6 step 4).
@ -55,6 +55,106 @@ fn content_length(headers: &HeaderMap) -> Option<u64> {
.and_then(|v| v.parse::<u64>().ok())
}
/// Runs the body-independent half of the upload pipeline shared by
/// [`create_page`] and [`replace_page`] (design §4.6 steps 69): the size
/// caps, the `Content-Type` → [`UploadKind`] mapping, and
/// [`build_manifest`].
///
/// Returns the ready-to-write manifest, or the exact JSON error [`Response`]
/// the caller should return as-is:
///
/// - `413` if `Content-Length` (when present) *or* the actual buffered body
/// exceeds [`MAX_UPLOAD_BYTES`] — both are checked, so a missing or
/// understated `Content-Length` can't slip a larger body through.
/// - `400 "unsupported content type"` for anything that isn't `text/html` or
/// a zip content type.
/// - `400` with the [`UploadError`](crate::core::upload::UploadError) message
/// if the payload fails validation (zip bomb, no `index.html`, unsafe
/// paths, …).
///
/// Both callers reach this only *after* their own auth/authorization checks,
/// so an unauthorized caller never learns anything about their payload's
/// validity.
fn manifest_from_request(
headers: &HeaderMap,
body: &Bytes,
) -> Result<Vec<ManifestEntry>, Response> {
// Content-Length pre-check.
if let Some(len) = content_length(headers) {
if len > MAX_UPLOAD_BYTES {
return Err(json_error(
StatusCode::PAYLOAD_TOO_LARGE,
"upload exceeds the maximum allowed size",
));
}
}
// Hard cap on the actual buffered body.
if body.len() as u64 > MAX_UPLOAD_BYTES {
return Err(json_error(
StatusCode::PAYLOAD_TOO_LARGE,
"upload exceeds the maximum allowed size",
));
}
// 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 Err(json_error(
StatusCode::BAD_REQUEST,
"unsupported content type",
));
};
build_manifest(kind, body.to_vec(), &UploadLimits::default())
.map_err(|e| json_error(StatusCode::BAD_REQUEST, e.to_string()))
}
/// Lists every R2 key stored under a page's `<slug>/` prefix, following the
/// list cursor until the result is no longer truncated.
///
/// Shared by [`delete_page`] (which deletes them all) and [`replace_page`]
/// (which deletes only the ones the new upload doesn't overwrite). A
/// truncated result with no cursor to continue from ends the loop rather than
/// spinning forever — the caller then sees a short list, which for both
/// callers degrades to leaving orphaned objects behind, never to deleting the
/// wrong ones.
async fn list_page_keys(state: &AppState, slug: &str) -> Result<Vec<String>, worker::Error> {
let prefix = format!("{slug}/");
let mut keys: Vec<String> = Vec::new();
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 = builder.execute().await?;
for obj in listed.objects() {
keys.push(obj.key());
}
if !listed.truncated() {
break;
}
cursor = listed.cursor();
if cursor.is_none() {
break;
}
}
Ok(keys)
}
// ── GET /api/me ───────────────────────────────────────────────────────────
/// `GET /api/me` — the caller's current session, if any (design §4.1).
@ -141,13 +241,9 @@ pub struct UploadQuery {
/// 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.
/// 6-9. Size caps, `Content-Type` → [`UploadKind`], and [`build_manifest`]
/// validation — all in [`manifest_from_request`], shared with
/// [`replace_page`]; `413` / `400` as documented there.
/// 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**
@ -206,42 +302,10 @@ pub async fn create_page(
}
}
// 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()) {
// 6-9. Size caps, Content-Type → UploadKind, manifest validation.
let manifest = match manifest_from_request(&headers, &body) {
Ok(m) => m,
Err(e) => return json_error(StatusCode::BAD_REQUEST, e.to_string()),
Err(response) => return response,
};
let file_count = manifest.len() as i64;
@ -312,6 +376,184 @@ async fn rollback_upload(state: &AppState, written_keys: &[String], slug: &str)
}
}
// ── PUT /api/pages/:slug ──────────────────────────────────────────────────
/// `PUT /api/pages/:slug` — replace an existing page's content in place
/// (pages-ytau, design §4.6b). Same request shape as [`create_page`] (raw
/// `.html` or `.zip` body, typed by `Content-Type`), but the slug comes from
/// the path and must already exist.
///
/// 1. No session → `401`.
/// 2. `!can_upload` → `403` — a user whose upload permission was revoked
/// cannot keep pushing content to pages they already own.
/// 3. `Origin` check → `403` (design §2.1: all mutating routes).
/// 4. Unknown slug → `404`. The slug is *not* re-validated against
/// [`validate_slug`](crate::core::slug::validate_slug): it can only have
/// got into `pages` through [`create_page`], which already validated it,
/// and a slug that doesn't exist 404s anyway.
/// 5. Neither the owner nor an admin → `403` (see [`can_replace`]).
/// 6. Size caps / `Content-Type` / [`build_manifest`] via
/// [`manifest_from_request`] → `413` / `400`.
/// 7. List the existing `<slug>/` R2 keys, then write every new manifest
/// entry, then delete the old keys the new manifest did **not** overwrite.
/// 8. Update `file_count`/`total_bytes` in D1
/// ([`Db::update_page_stats`](crate::db::Db::update_page_stats)) —
/// `owner_id`, `trusted`, and `created_at` are preserved.
/// 9. `200` with the same body shape as [`create_page`]'s `201`.
///
/// # Why this isn't transactional
///
/// [`create_page`] can roll back cleanly (delete what it wrote, drop the row
/// it inserted) because before it ran, the slug held *nothing*. A replacement
/// has no such luxury: R2 exposes no multi-object transaction, so once the
/// first `put` lands, the page is already a mix of old and new content and
/// there is no saved copy of the old bytes to restore.
///
/// The ordering above is chosen to make that window as benign as possible:
///
/// - **New content is written before any old key is deleted.** Every entry in
/// the new manifest overwrites its old counterpart at the same key, so a
/// viewer during the write window sees old-or-new per file, never a 404 for
/// a file that exists in both versions. Deleting first would blank the page
/// for the whole upload.
/// - **Stale deletion is last, and best-effort.** Old keys absent from the new
/// manifest (files removed between versions) are deleted after every new
/// object is durably written. A failure there is logged but does *not* fail
/// the request: the page is already fully updated and correct, and the only
/// consequence is an orphaned object that the next replacement or a
/// [`delete_page`] (which lists the whole prefix) will clean up.
/// - **D1 stats are updated only after R2 succeeds**, so `file_count` /
/// `total_bytes` never advertise content that isn't there.
///
/// If a `put` fails partway, the page keeps serving a mix of both versions and
/// this returns `500`; the fix is to re-upload, which is idempotent (same slug,
/// same keys overwritten). D1 is left untouched in that case, so the row still
/// describes the *previous* upload — stale stats on a page whose content is
/// half-new is the least-bad option available without object versioning.
#[worker::send]
pub async fn replace_page(
State(state): State<AppState>,
Path(slug): Path<String>,
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: replace_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. 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: replace_page get_page failed: {e}");
return json_error(StatusCode::INTERNAL_SERVER_ERROR, "internal error");
}
};
// 5. Owner or admin.
if !can_replace(user.id, user.is_admin, page.owner_id) {
return json_error(StatusCode::FORBIDDEN, "not authorized to update this page");
}
// 6. Size caps, Content-Type → UploadKind, manifest validation.
let manifest = match manifest_from_request(&headers, &body) {
Ok(m) => m,
Err(response) => return response,
};
let file_count = manifest.len() as i64;
let total_bytes: i64 = manifest.iter().map(|e| e.bytes.len() as i64).sum();
// 7a. Snapshot the keys the previous version left behind, *before*
// overwriting anything — afterwards, a listing can no longer tell old
// keys from new ones.
let old_keys = match list_page_keys(&state, &slug).await {
Ok(keys) => keys,
Err(e) => {
worker::console_log!("pages: replace_page R2 list failed for '{slug}/': {e}");
return json_error(StatusCode::INTERNAL_SERVER_ERROR, "update failed");
}
};
// 7b. Write the new content. No rollback is possible here (see the
// "Why this isn't transactional" section above) — a failure leaves the
// page mid-update and is surfaced as a 500 telling the caller to retry.
let mut new_keys: Vec<String> = Vec::with_capacity(manifest.len());
for entry in &manifest {
let key = format!("{slug}/{}", entry.path);
match state
.bucket
.0
.put(key.clone(), entry.bytes.clone())
.execute()
.await
{
Ok(_) => new_keys.push(key),
Err(e) => {
worker::console_log!("pages: replace_page R2 put failed for '{key}': {e}");
return json_error(
StatusCode::INTERNAL_SERVER_ERROR,
"update failed partway through the page may be serving a mix of the old \
and new content; upload again to retry",
);
}
}
}
// 7c. Delete the leftovers: old keys the new version doesn't include.
// Best-effort — the page is already fully updated at this point, so a
// failure here only orphans an object (logged, not fatal).
for key in old_keys.iter().filter(|k| !new_keys.contains(k)) {
if let Err(e) = state.bucket.0.delete(key.clone()).await {
worker::console_log!("pages: replace_page stale R2 delete failed for '{key}': {e}");
}
}
// 8. Refresh the stats now that R2 holds exactly the new manifest.
if let Err(e) = state
.db
.update_page_stats(&slug, file_count, total_bytes)
.await
{
return match e {
// The page was deleted out from under us between step 4 and here.
DbError::NotFound => json_error(StatusCode::NOT_FOUND, "page not found"),
other => {
worker::console_log!("pages: update_page_stats failed: {other}");
json_error(StatusCode::INTERNAL_SERVER_ERROR, "internal error")
}
};
}
// 9. Success.
let url = format!("{}/{}/", state.config.base_url, slug);
Json(json!({
"slug": slug,
"url": url,
"file_count": file_count,
"total_bytes": total_bytes,
}))
.into_response()
}
// ── DELETE /api/pages/:slug ───────────────────────────────────────────────
/// `DELETE /api/pages/:slug` — delete a page (design §4.1, §4.3).
@ -320,9 +562,8 @@ async fn rollback_upload(state: &AppState, written_keys: &[String], slug: &str)
/// 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.
/// 5. List the `<slug>/` R2 prefix ([`list_page_keys`]) and delete every
/// object, then delete the D1 row.
/// 6. `200 {"deleted": "<slug>"}`.
#[worker::send]
pub async fn delete_page(
@ -362,41 +603,21 @@ pub async fn delete_page(
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,
// 5. Delete every R2 object under the `<slug>/` prefix.
let keys = match list_page_keys(&state, &slug).await {
Ok(keys) => keys,
Err(e) => {
worker::console_log!("pages: R2 list failed for prefix '{prefix}': {e}");
worker::console_log!("pages: R2 list failed for prefix '{slug}/': {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());
for key in keys {
if let Err(e) = state.bucket.0.delete(key.clone()).await {
worker::console_log!("pages: R2 delete failed for '{key}': {e}");
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}");

@ -34,7 +34,7 @@ use axum::{
extract::DefaultBodyLimit,
http::{header::CONTENT_TYPE, StatusCode},
response::Response,
routing::{delete, get, patch, post},
routing::{delete, get, patch, post, put},
Router,
};
@ -98,12 +98,20 @@ pub struct AppState {
/// 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).
/// The two content-upload routes — `POST /api/pages` and `PUT
/// /api/pages/{slug}` (pages-ytau) — each get their 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::manifest_from_request` re-checks the same limit to return the
/// JSON-shaped 413 on honest oversize requests (design §2.2, §4.6).
///
/// On `/api/pages/{slug}` the raised limit is `merge`d in on the `PUT`
/// method only, rather than layered over the whole `MethodRouter`: `PATCH`
/// (a tiny `{"trusted": bool}` JSON body) and `DELETE` (no body) have no
/// reason to accept 10 MiB, and keeping axum's 2 MiB default on them costs
/// nothing.
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")?));
@ -131,7 +139,9 @@ pub fn router(env: worker::Env) -> worker::Result<Router> {
)
.route(
"/api/pages/{slug}",
delete(api::delete_page).patch(api::update_page),
delete(api::delete_page).patch(api::update_page).merge(
put(api::replace_page).layer(DefaultBodyLimit::max(api::MAX_UPLOAD_BYTES as usize)),
),
)
.route("/api/users", get(api::list_users))
.route("/api/users/{id}", patch(api::update_user))

@ -161,6 +161,12 @@
input[type="text"].invalid { border-color: var(--danger); }
input[type="text"]:read-only {
background: var(--code-bg);
color: var(--text-muted);
cursor: not-allowed;
}
label {
display: block;
font-size: 0.85rem;
@ -305,6 +311,74 @@
}
.row-actions { text-align: right; white-space: nowrap; }
.row-actions button + button { margin-left: 0.4rem; }
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 0.8rem;
}
.card-header h2 { margin: 0; }
/* ── Upload modal ─────────────────────────────────────────────────── */
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
z-index: 10;
}
.modal-backdrop[hidden] { display: none; }
.modal {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 10px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.25);
padding: 1.1rem 1.25rem;
width: 100%;
max-width: 520px;
max-height: calc(100vh - 2rem);
overflow-y: auto;
}
.modal-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
margin-bottom: 0.9rem;
}
.modal-header h2 { margin: 0; font-size: 1.05rem; }
.modal-close {
border: none;
background: none;
color: var(--text-muted);
font-size: 1.4rem;
line-height: 1;
padding: 0 0.25rem;
border-radius: 6px;
}
.modal-close:hover { color: var(--text); }
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 1rem;
}
</style>
</head>
<body>
@ -315,28 +389,15 @@
</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">
<div class="card-header">
<h2>Pages</h2>
<button type="button" class="primary" id="new-page-btn" hidden>+ New Page</button>
</div>
<div id="pages-banner"></div>
<div class="table-scroll">
<table id="pages-table">
@ -378,6 +439,30 @@
</section>
</main>
<div class="modal-backdrop" id="upload-modal" hidden>
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="upload-modal-title">
<div class="modal-header">
<h2 id="upload-modal-title">Upload a page</h2>
<button type="button" class="modal-close" id="upload-modal-close" aria-label="Close">&times;</button>
</div>
<div id="upload-banner"></div>
<div id="dropzone" role="button" tabindex="0">
<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>
<div class="modal-actions">
<button type="button" id="upload-cancel">Cancel</button>
<button type="button" class="primary" id="upload-submit" disabled>Upload</button>
</div>
</div>
</div>
<footer>
Hosted pages are sandboxed (no cookies/storage) unless marked trusted by an admin.
</footer>
@ -391,12 +476,26 @@
var currentFile = null;
var usersLoaded = false;
// The upload modal serves both flows (pages-ytau): "create" posts to
// /api/pages?name=<slug>, "replace" puts to /api/pages/<replaceSlug> with
// the slug fixed. `replaceSlug` is null in create mode.
var uploadMode = "create";
var replaceSlug = null;
// Element focus is returned here when the modal closes (the button that
// opened it), so keyboard users don't get dumped back at the top of the page.
var modalOpener = null;
// ── DOM refs ─────────────────────────────────────────────────────────
var authArea = document.getElementById("auth-area");
var uploadCard = document.getElementById("upload-card");
var uploadNotice = document.getElementById("upload-notice");
var uploadModal = document.getElementById("upload-modal");
var uploadModalTitle = document.getElementById("upload-modal-title");
var uploadModalClose = document.getElementById("upload-modal-close");
var uploadCancel = document.getElementById("upload-cancel");
var newPageBtn = document.getElementById("new-page-btn");
var uploadBanner = document.getElementById("upload-banner");
var dropzone = document.getElementById("dropzone");
var dropzoneLabel = document.getElementById("dropzone-label");
var dropzoneFilename = document.getElementById("dropzone-filename");
var fileInput = document.getElementById("file-input");
var nameInput = document.getElementById("name-input");
@ -570,20 +669,99 @@
}
}
// ── Upload card ──────────────────────────────────────────────────────
// ── Upload modal ─────────────────────────────────────────────────────
function canUpload() {
return !!(me.authenticated && me.user && me.user.can_upload);
}
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;
newPageBtn.hidden = !canUpload();
uploadNotice.hidden = !authedNoUpload;
}
/**
* Opens the modal. Pass a slug to open in "replace" mode (content upload
* for an existing page: name pre-filled and read-only); pass nothing for
* "create" mode. `opener` is the button that triggered this, so focus can
* be restored to it on close.
*/
function openUploadModal(slug, opener) {
uploadMode = slug ? "replace" : "create";
replaceSlug = slug || null;
modalOpener = opener || null;
// Reset any state left over from a previous open.
currentFile = null;
fileInput.value = "";
dropzoneFilename.textContent = "";
clearBanner(uploadBanner);
nameInput.classList.remove("invalid");
if (uploadMode === "replace") {
uploadModalTitle.textContent = "Update page";
dropzoneLabel.textContent = "Drop the new .html file or .zip here, or click to choose one. It replaces this page's current content.";
nameInput.value = slug;
nameInput.readOnly = true;
uploadSubmit.textContent = "Update";
} else {
uploadModalTitle.textContent = "Upload a page";
dropzoneLabel.textContent = "Drag & drop an .html file or a .zip of static assets here, or click to choose one.";
nameInput.value = "";
nameInput.readOnly = false;
uploadSubmit.textContent = "Upload";
}
validateNameField();
uploadModal.hidden = false;
// In replace mode the name is fixed, so the file picker is the only thing
// left to do — put focus there rather than on a read-only input.
if (uploadMode === "replace") {
dropzone.focus();
} else {
nameInput.focus();
}
}
function closeUploadModal() {
uploadModal.hidden = true;
currentFile = null;
fileInput.value = "";
nameInput.readOnly = false;
uploadSubmit.disabled = true;
if (modalOpener && document.contains(modalOpener)) {
modalOpener.focus();
}
modalOpener = null;
}
newPageBtn.addEventListener("click", function () {
openUploadModal(null, newPageBtn);
});
uploadModalClose.addEventListener("click", closeUploadModal);
uploadCancel.addEventListener("click", closeUploadModal);
// Backdrop click closes; a click *inside* the dialog must not (the check
// that the event target is the backdrop itself, not a descendant).
uploadModal.addEventListener("click", function (e) {
if (e.target === uploadModal) closeUploadModal();
});
document.addEventListener("keydown", function (e) {
if (e.key === "Escape" && !uploadModal.hidden) closeUploadModal();
});
function setDropzoneFile(file) {
currentFile = file;
if (file) {
dropzoneFilename.textContent = file.name;
// Only derive a slug from the filename when creating: in replace mode
// the slug identifies the page being updated and must not move.
if (uploadMode === "create") {
nameInput.value = defaultSlugFromFilename(file.name);
}
} else {
dropzoneFilename.textContent = "";
}
@ -599,13 +777,22 @@
return filename.toLowerCase().slice(-4) === ".zip" ? "application/zip" : "text/html";
}
/** The idle (non-error) hint under the name field, which differs by mode:
* in replace mode the name is fixed, so the grammar rules are noise and
* what matters is that the upload overwrites what's there. */
function defaultNameHint() {
return uploadMode === "replace"
? "This page's current content will be replaced. The name can't be changed."
: DEFAULT_NAME_HINT;
}
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.textContent = defaultNameHint();
nameHint.classList.remove("error");
uploadSubmit.disabled = true;
return;
@ -623,7 +810,7 @@
uploadSubmit.disabled = true;
} else {
nameInput.classList.remove("invalid");
nameHint.textContent = DEFAULT_NAME_HINT;
nameHint.textContent = defaultNameHint();
nameHint.classList.remove("error");
uploadSubmit.disabled = false;
}
@ -633,6 +820,13 @@
fileInput.click();
});
dropzone.addEventListener("keydown", function (e) {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
fileInput.click();
}
});
dropzone.addEventListener("dragover", function (e) {
e.preventDefault();
dropzone.classList.add("dragover");
@ -664,9 +858,10 @@
var MAX_UPLOAD_BYTES = 10 * 1024 * 1024;
uploadSubmit.addEventListener("click", function () {
if (!currentFile || !isValidSlug(nameInput.value)) return;
var replacing = uploadMode === "replace";
var name = replacing ? replaceSlug : nameInput.value;
if (!currentFile || !isValidSlug(name)) 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;
@ -674,9 +869,12 @@
uploadSubmit.disabled = true;
clearBanner(uploadBanner);
var url = "/api/pages?name=" + encodeURIComponent(name);
apiFetch(url, {
method: "POST",
var request = replacing
? { url: "/api/pages/" + encodeURIComponent(name), method: "PUT", okStatus: 200 }
: { url: "/api/pages?name=" + encodeURIComponent(name), method: "POST", okStatus: 201 };
apiFetch(request.url, {
method: request.method,
headers: { "Content-Type": contentTypeFor(file.name) },
body: file
}).then(function (res) {
@ -685,15 +883,17 @@
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");
if (res.status === request.okStatus && res.body) {
// Success closes the modal; the confirmation lands on the pages card
// behind it, which is also where the refreshed row appears.
closeUploadModal();
showBanner(
pagesBanner,
"success",
replacing ? "Updated! Live at " : "Uploaded! Live at ",
res.body.url,
res.body.url
);
loadPages();
return;
}
@ -702,6 +902,11 @@
uploadSubmit.disabled = false;
return;
}
if (res.status === 404) {
showBanner(uploadBanner, "error", "That page no longer exists — it may have been deleted.");
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);
@ -713,7 +918,8 @@
uploadSubmit.disabled = false;
return;
}
var errMsg = (res.body && res.body.error) || ("Upload failed (HTTP " + res.status + ").");
var verb = replacing ? "Update" : "Upload";
var errMsg = (res.body && res.body.error) || (verb + " failed (HTTP " + res.status + ").");
showBanner(uploadBanner, "error", errMsg);
uploadSubmit.disabled = false;
});
@ -776,8 +982,21 @@
tr.appendChild(trustedTd);
var actionsTd = el("td", { className: "row-actions" });
// Owner-or-admin is the server's rule for both DELETE and PUT
// (core::origin::can_delete / can_replace); updating additionally needs
// upload permission, since it publishes new content.
var canDelete = !!(me.authenticated && me.user &&
(me.user.is_admin || me.user.login === row.owner_login));
if (canDelete && canUpload()) {
var updateBtn = el("button", { text: "Update" });
updateBtn.type = "button";
updateBtn.title = "Replace this page's content with a new upload.";
updateBtn.addEventListener("click", function () {
clearBanner(pagesBanner);
openUploadModal(row.slug, updateBtn);
});
actionsTd.appendChild(updateBtn);
}
if (canDelete) {
var delBtn = el("button", { className: "danger", text: "Delete" });
delBtn.type = "button";

Loading…
Cancel
Save