diff --git a/pages/.beans/pages-ytau--update-a-page-in-place-re-upload-content-put-apipa.md b/pages/.beans/pages-ytau--update-a-page-in-place-re-upload-content-put-apipa.md new file mode 100644 index 0000000..40b5f87 --- /dev/null +++ b/pages/.beans/pages-ytau--update-a-page-in-place-re-upload-content-put-apipa.md @@ -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 `/` 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. diff --git a/pages/.gitignore b/pages/.gitignore index 54c61c2..35e9c4f 100644 --- a/pages/.gitignore +++ b/pages/.gitignore @@ -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 diff --git a/pages/README.md b/pages/README.md index 8830201..6c8afa8 100644 --- a/pages/README.md +++ b/pages/README.md @@ -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//`. -- 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) - `//*` — page content served from R2 with strict security headers Uploads are validated hard: strict slug grammar + reserved names, zip diff --git a/pages/docs/ARCHITECTURE.md b/pages/docs/ARCHITECTURE.md index 18daa1f..3831b08 100644 --- a/pages/docs/ARCHITECTURE.md +++ b/pages/docs/ARCHITECTURE.md @@ -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=`) and + a per-row "Update" (`PUT /api/pages/`, slug fixed and read-only). + +## Page content lifecycle + +A page's bytes live under one R2 prefix, `/`, 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 diff --git a/pages/docs/PLANNING.md b/pages/docs/PLANNING.md index d16c956..01691ba 100644 --- a/pages/docs/PLANNING.md +++ b/pages/docs/PLANNING.md @@ -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. diff --git a/pages/docs/plans/2026-07-12-pages-design.md b/pages/docs/plans/2026-07-12-pages-design.md index a5947ed..ab84a3c 100644 --- a/pages/docs/plans/2026-07-12-pages-design.md +++ b/pages/docs/plans/2026-07-12-pages-design.md @@ -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=` | 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 `/…`; 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 `/` 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). diff --git a/pages/src/core/headers.rs b/pages/src/core/headers.rs index d163b2e..28f9ef5 100644 --- a/pages/src/core/headers.rs +++ b/pages/src/core/headers.rs @@ -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`]. diff --git a/pages/src/core/origin.rs b/pages/src/core/origin.rs index 9c9238f..f031c0c 100644 --- a/pages/src/core/origin.rs +++ b/pages/src/core/origin.rs @@ -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) + ); + } + } + } } diff --git a/pages/src/db.rs b/pages/src/db.rs index 23c5fb0..ccf0795 100644 --- a/pages/src/db.rs +++ b/pages/src/db.rs @@ -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 `/` prefix diff --git a/pages/src/routes/api.rs b/pages/src/routes/api.rs index 93bc9f2..592d353 100644 --- a/pages/src/routes/api.rs +++ b/pages/src/routes/api.rs @@ -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 { .and_then(|v| v.parse::().ok()) } +/// Runs the body-independent half of the upload pipeline shared by +/// [`create_page`] and [`replace_page`] (design §4.6 steps 6–9): 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, 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 `/` 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, worker::Error> { + let prefix = format!("{slug}/"); + let mut keys: Vec = Vec::new(); + let mut cursor: Option = 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 `/`; 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 `/` 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, + Path(slug): Path, + 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 = 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 `/` R2 prefix (paginating via the returned cursor -/// until `truncated()` is `false`) and delete every object, then delete -/// the D1 row. +/// 5. List the `/` R2 prefix ([`list_page_keys`]) and delete every +/// object, then delete the D1 row. /// 6. `200 {"deleted": ""}`. #[worker::send] pub async fn delete_page( @@ -362,38 +603,18 @@ pub async fn delete_page( return json_error(StatusCode::FORBIDDEN, "not authorized to delete this page"); } - // 5. Delete every R2 object under the `/` prefix, paginating via - // the cursor the list operation returns while `truncated()` is true. - let prefix = format!("{slug}/"); - let mut cursor: Option = 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; + // 5. Delete every R2 object under the `/` prefix. + let keys = match list_page_keys(&state, &slug).await { + Ok(keys) => keys, + Err(e) => { + worker::console_log!("pages: R2 list failed for prefix '{slug}/': {e}"); + return json_error(StatusCode::INTERNAL_SERVER_ERROR, "delete failed"); } - 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; + }; + 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"); } } diff --git a/pages/src/routes/mod.rs b/pages/src/routes/mod.rs index 97dc96d..a886715 100644 --- a/pages/src/routes/mod.rs +++ b/pages/src/routes/mod.rs @@ -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 { 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 { ) .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)) diff --git a/pages/static/index.html b/pages/static/index.html index 476121c..a4beb11 100644 --- a/pages/static/index.html +++ b/pages/static/index.html @@ -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; + } @@ -315,28 +389,15 @@
- -
-

Pages

+
+

Pages

+ +
@@ -378,6 +439,30 @@ + +
Hosted pages are sandboxed (no cookies/storage) unless marked trusted by an admin.
@@ -391,12 +476,26 @@ var currentFile = null; var usersLoaded = false; + // The upload modal serves both flows (pages-ytau): "create" posts to + // /api/pages?name=, "replace" puts to /api/pages/ 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; - nameInput.value = defaultSlugFromFilename(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";