//! `/api/*` JSON handlers — design §4.1 (HTTP surface table) and §4.6 //! (upload pipeline). //! //! This module is wasm-only (see `routes::mod`'s `#![cfg(...)]`, which //! covers this submodule too since it's declared inside that gated file). //! Every response is JSON; errors are always `{"error": ""}` with //! the status code documented on each handler. Handlers stay thin: slug //! grammar lives in [`crate::core::slug`], upload/zip-bomb validation in //! [`crate::core::upload`], and the `Origin`/ownership checks in //! [`crate::core::origin`] — this file only sequences those pure checks //! against [`crate::db::Db`] and R2. //! //! `#[worker::send]` is applied to every handler here (not just the //! "mutating" ones) because every handler awaits at least one JS-backed //! future (a D1 query via [`current_user`], at minimum) — see //! `routes::auth::callback` for why that wrapper is needed at all. use axum::{ body::Bytes, extract::{Path, Query, State}, http::{ header::{CONTENT_LENGTH, CONTENT_TYPE, ORIGIN}, HeaderMap, StatusCode, }, response::{IntoResponse, Response}, Json, }; use serde::Deserialize; use serde_json::json; use super::auth::current_user; use super::AppState; use crate::core::origin::{can_delete, can_replace, origin_allowed}; use crate::core::slug::validate_slug; use crate::core::upload::{build_manifest, detect_kind, ManifestEntry, UploadLimits}; use crate::db::DbError; /// Maximum accepted raw upload size — 24 MiB (design §2.2, §4.6 step 4). /// Mirrors [`UploadLimits::default`]'s `max_compressed`; kept as a separate /// constant (rather than reading it off a constructed `UploadLimits`) so the /// `Content-Length` pre-check in [`create_page`] doesn't need to allocate /// one before the request body is available. pub(crate) const MAX_UPLOAD_BYTES: u64 = 24 * 1024 * 1024; /// Maximum aliases a single page may have (pages-9lsk) — an anti-squatting /// cap enforced by [`add_alias`]. Aliases are cheap D1 rows (no R2), but each /// one claims a name in the shared URL namespace, so this bounds how many /// names one page can hold. const MAX_ALIASES_PER_PAGE: i64 = 10; /// Builds a `{"error": ""}` JSON response with the given status. fn json_error(status: StatusCode, message: impl Into) -> Response { (status, Json(json!({ "error": message.into() }))).into_response() } /// Extracts and parses the `Content-Length` header, if present and valid. fn content_length(headers: &HeaderMap) -> Option { headers .get(CONTENT_LENGTH) .and_then(|v| v.to_str().ok()) .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` with the [`UploadError`](crate::core::upload::UploadError) message /// if the payload fails validation (zip/tar bomb, no `index.html`, unsafe /// paths, corrupt compression, …). /// /// The [`UploadKind`](crate::core::upload::UploadKind) is chosen by sniffing /// the body's leading **magic bytes** ([`detect_kind`]), not the client's /// `Content-Type` — see that function for why. `Content-Type` is passed /// through only as a hint; a body with no recognised archive signature (raw /// HTML included) falls back to `Html`, so an upload is never rejected merely /// for an absent or unexpected `Content-Type`. /// /// 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", )); } // Sniff the upload kind from the body's magic bytes (never the declared // Content-Type — see `detect_kind`). The Content-Type is forwarded only // as a hint for the no-magic (raw-HTML) fallback. let content_type = headers .get(CONTENT_TYPE) .and_then(|v| v.to_str().ok()) .unwrap_or("") .to_ascii_lowercase(); let kind = detect_kind(body.as_ref(), &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). /// /// Always `200`, **never** `401` — the frontend probes this endpoint /// unconditionally on every page load to decide what UI to show, so an /// absent/expired session is just `{"authenticated": false}`, not an error. /// A DB failure while resolving the session is likewise folded into /// `{"authenticated": false}` (logged via `worker::console_log!`) rather /// than surfaced as a 500, for the same reason. #[worker::send] pub async fn me(State(state): State, headers: HeaderMap) -> Response { match current_user(&state.db, &headers).await { Ok(Some(user)) => Json(json!({ "authenticated": true, "user": { "login": user.login, "avatar_url": user.avatar_url, "can_upload": user.can_upload, "is_admin": user.is_admin, } })) .into_response(), Ok(None) => Json(json!({ "authenticated": false })).into_response(), Err(e) => { worker::console_log!("pages: /api/me session lookup failed: {e}"); Json(json!({ "authenticated": false })).into_response() } } } // ── GET /api/pages ──────────────────────────────────────────────────────── /// `GET /api/pages` — public list of all published pages (design §4.1). #[worker::send] pub async fn list_pages(State(state): State) -> Response { let rows = match state.db.list_pages().await { Ok(rows) => rows, Err(e) => { worker::console_log!("pages: list_pages failed: {e}"); return json_error(StatusCode::INTERNAL_SERVER_ERROR, "failed to list pages"); } }; // Fetch every alias once and group by canonical slug (pages-9lsk), so each // page carries its `aliases[]` without an N+1 per-page query. let mut aliases_by_slug: std::collections::HashMap> = std::collections::HashMap::new(); match state.db.list_all_aliases().await { Ok(pairs) => { for (alias, slug) in pairs { aliases_by_slug.entry(slug).or_default().push(alias); } } Err(e) => { worker::console_log!("pages: list_all_aliases failed: {e}"); return json_error(StatusCode::INTERNAL_SERVER_ERROR, "failed to list pages"); } } let pages: Vec<_> = rows .into_iter() .map(|r| { let effective_trust = r.effective_trust(); let aliases = aliases_by_slug.get(&r.slug).cloned().unwrap_or_default(); json!({ "slug": r.slug, "owner_login": r.owner_login, "file_count": r.file_count, "total_bytes": r.total_bytes, "trusted": r.trusted, "owner_trusted": r.owner_trusted, "effective_trust": effective_trust, "created_at": r.created_at, "aliases": aliases, }) }) .collect(); Json(json!({ "pages": pages })).into_response() } // ── POST /api/pages?name= ────────────────────────────────────────── /// Query parameters for [`create_page`]. #[derive(Debug, Deserialize)] pub struct UploadQuery { /// The desired page slug. `None`/absent is treated the same as an /// empty string, which [`validate_slug`] rejects as /// [`SlugError::Empty`](crate::core::slug::SlugError::Empty) — a `400`, /// not a distinct "missing parameter" error. name: Option, } /// `POST /api/pages?name=` — upload a new page (design §4.1, §4.6). /// /// Pipeline, in the exact order design §4.6 specifies: /// /// 1. No session → `401`. /// 2. `!can_upload` → `403` `"not authorized to upload"`. /// 3. `Origin` header must exactly equal the configured `BASE_URL` → else /// `403` (design §2.1; see [`origin_allowed`]). /// 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-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** /// failure, best-effort delete the R2 keys already written plus the D1 /// row, then `500`. /// 12. `201` with `{slug, url, file_count, total_bytes}`. /// /// The axum body-size limit for this route is raised to `MAX_UPLOAD_BYTES` /// in `routes::router` (the 2 MiB default is too small); that layer bounds /// memory while the body is collected, and steps 6/7 above re-check the same /// cap so honest oversize requests get our JSON error shape instead of /// axum's built-in (non-JSON) rejection body. #[worker::send] pub async fn create_page( State(state): State, Query(query): Query, 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: create_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. Slug validation. let name = query.name.unwrap_or_default(); if let Err(e) = validate_slug(&name) { return json_error(StatusCode::BAD_REQUEST, e.to_string()); } // 4b. Namespace: reject a name already claimed by an *alias* (pages-9lsk). // Slugs and aliases share one URL namespace; a name colliding with an // existing page slug is caught later by `insert_page` (step 10), so // this covers the alias side of that uniqueness rule. match state.db.alias_exists(&name).await { Ok(true) => return json_error(StatusCode::CONFLICT, "name already taken"), Ok(false) => {} Err(e) => { worker::console_log!("pages: create_page alias_exists failed: {e}"); return json_error(StatusCode::INTERNAL_SERVER_ERROR, "internal error"); } } // 5. Quota. match state.db.count_pages_for_user(user.id).await { Ok(count) if count >= 50 => { return json_error(StatusCode::FORBIDDEN, "page quota reached"); } Ok(_) => {} Err(e) => { worker::console_log!("pages: count_pages_for_user failed: {e}"); return json_error(StatusCode::INTERNAL_SERVER_ERROR, "internal error"); } } // 6-9. 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(); // 10. Reserve the slug in D1 *before* writing anything to R2. if let Err(e) = state .db .insert_page(&name, user.id, file_count, total_bytes) .await { return match e { DbError::NameTaken => json_error(StatusCode::CONFLICT, "name already taken"), other => { worker::console_log!("pages: insert_page failed: {other}"); json_error(StatusCode::INTERNAL_SERVER_ERROR, "internal error") } }; } // 11. Write every file to R2, rolling back on any failure. let mut written_keys: Vec = Vec::with_capacity(manifest.len()); for entry in &manifest { let key = format!("{name}/{}", entry.path); match state .bucket .0 .put(key.clone(), entry.bytes.clone()) .execute() .await { Ok(_) => written_keys.push(key), Err(e) => { worker::console_log!("pages: R2 put failed for '{key}': {e}"); rollback_upload(&state, &written_keys, &name).await; return json_error(StatusCode::INTERNAL_SERVER_ERROR, "upload failed"); } } } // 12. Success. let url = format!("{}/{}/", state.config.base_url, name); ( StatusCode::CREATED, Json(json!({ "slug": name, "url": url, "file_count": file_count, "total_bytes": total_bytes, })), ) .into_response() } /// Best-effort cleanup after a failed R2 write partway through /// [`create_page`]'s upload loop (design §4.6 step 11): deletes every R2 /// key already written and the just-inserted D1 `pages` row. Failures here /// are only logged — the caller has already decided to return `500` /// regardless, and there is no better recovery available. async fn rollback_upload(state: &AppState, written_keys: &[String], slug: &str) { for key in written_keys { if let Err(e) = state.bucket.0.delete(key.clone()).await { worker::console_log!("pages: rollback R2 delete failed for '{key}': {e}"); } } if let Err(e) = state.db.delete_page(slug).await { worker::console_log!("pages: rollback delete_page failed for '{slug}': {e}"); } } // ── 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). /// /// 1. No session → `401`. /// 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 ([`list_page_keys`]) and delete every /// object, then delete the D1 row. /// 6. `200 {"deleted": ""}`. #[worker::send] pub async fn delete_page( State(state): State, Path(slug): Path, headers: HeaderMap, ) -> 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: delete_page session lookup failed: {e}"); return json_error(StatusCode::INTERNAL_SERVER_ERROR, "internal error"); } }; // 2. Origin check (before any DB lookups, per design §4.6 — a // cross-origin caller learns nothing about page existence). 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"); } // 3. 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: get_page failed: {e}"); return json_error(StatusCode::INTERNAL_SERVER_ERROR, "internal error"); } }; // 4. Owner or admin. if !can_delete(user.id, user.is_admin, page.owner_id) { return json_error(StatusCode::FORBIDDEN, "not authorized to delete this page"); } // 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"); } }; 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"); } } // 6. Remove the page's aliases (pages-9lsk) first, so their names free up // even though R2 is already gone. Done before the page row so a failure // here leaves the page (and its aliases) intact and consistent rather // than orphaning aliases that point at a now-missing page. if let Err(e) = state.db.delete_aliases_for_page(&slug).await { worker::console_log!("pages: delete_aliases_for_page failed: {e}"); return json_error(StatusCode::INTERNAL_SERVER_ERROR, "internal error"); } // 7. Delete the D1 row. if let Err(e) = state.db.delete_page(&slug).await { worker::console_log!("pages: delete_page failed: {e}"); return json_error(StatusCode::INTERNAL_SERVER_ERROR, "internal error"); } Json(json!({ "deleted": slug })).into_response() } // ── GET /api/users ──────────────────────────────────────────────────────── /// `GET /api/users` — admin-only user list (design §4.1). #[worker::send] pub async fn list_users(State(state): State, headers: HeaderMap) -> Response { 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: list_users session lookup failed: {e}"); return json_error(StatusCode::INTERNAL_SERVER_ERROR, "internal error"); } }; if !user.is_admin { return json_error(StatusCode::FORBIDDEN, "admin only"); } match state.db.list_users().await { Ok(users) => { let users: Vec<_> = users .into_iter() .map(|u| { json!({ "id": u.id, "login": u.login, "avatar_url": u.avatar_url, "can_upload": u.can_upload, "is_admin": u.is_admin, "trusted": u.trusted, "created_at": u.created_at, }) }) .collect(); Json(json!({ "users": users })).into_response() } Err(e) => { worker::console_log!("pages: list_users failed: {e}"); json_error(StatusCode::INTERNAL_SERVER_ERROR, "failed to list users") } } } // ── PATCH /api/users/:id ────────────────────────────────────────────────── /// Request body for [`update_user`]. /// /// Both fields are optional (pages-shqc extended this from the original /// `can_upload`-only body) so the same endpoint can toggle either flag /// independently from the admin users panel; whichever field(s) are present /// get applied. A body with neither field is rejected (`400`) rather than /// silently succeeding as a no-op — see [`update_user`]. #[derive(Debug, Deserialize)] pub struct UpdateUserBody { /// New value for the target user's `can_upload` flag, if present. can_upload: Option, /// New value for the target user's `trusted` flag, if present /// (pages-shqc, design §2.1 addendum) — grants/revokes trust for every /// page this user owns, via [`crate::db::PageRow::effective_trust`]. trusted: Option, } /// `PATCH /api/users/:id` — admin-only: set a user's `can_upload` and/or /// `trusted` flag (design §4.1; `trusted` added by pages-shqc, design §2.1 /// addendum). Also Origin-checked (design §2.1: all mutating routes). /// /// Body is `{"can_upload"?: bool, "trusted"?: bool}` — at least one field /// must be present (`400` if neither is), and whichever are present are /// applied; this keeps the original `{"can_upload": bool}`-only body /// backward compatible. No special-casing for an admin changing their own /// `can_upload` — admins are promoted with `can_upload = 1` already, so /// this never locks an admin out of uploading. #[worker::send] pub async fn update_user( State(state): State, Path(id): Path, headers: HeaderMap, Json(body): Json, ) -> Response { 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: update_user session lookup failed: {e}"); return json_error(StatusCode::INTERNAL_SERVER_ERROR, "internal error"); } }; if !user.is_admin { return json_error(StatusCode::FORBIDDEN, "admin only"); } 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"); } if body.can_upload.is_none() && body.trusted.is_none() { return json_error( StatusCode::BAD_REQUEST, "request body must set at least one of 'can_upload' or 'trusted'", ); } if let Some(can_upload) = body.can_upload { if let Err(e) = state.db.set_can_upload(id, can_upload).await { return match e { DbError::NotFound => json_error(StatusCode::NOT_FOUND, "user not found"), other => { worker::console_log!("pages: set_can_upload failed: {other}"); json_error(StatusCode::INTERNAL_SERVER_ERROR, "internal error") } }; } } if let Some(trusted) = body.trusted { if let Err(e) = state.db.set_user_trusted(id, trusted).await { return match e { DbError::NotFound => json_error(StatusCode::NOT_FOUND, "user not found"), other => { worker::console_log!("pages: set_user_trusted failed: {other}"); json_error(StatusCode::INTERNAL_SERVER_ERROR, "internal error") } }; } } Json(json!({ "updated": id })).into_response() } // ── PATCH /api/pages/:slug ──────────────────────────────────────────────── /// Request body for [`update_page`]. #[derive(Debug, Deserialize)] pub struct SetPageTrustedBody { /// New value for the page's own `trusted` flag. trusted: bool, } /// `PATCH /api/pages/:slug` — admin-only: set a page's own `trusted` flag /// (pages-shqc, design §2.1 addendum). Also Origin-checked (design §2.1: all /// mutating routes). /// /// 1. No session → `401`. /// 2. Not an admin → `403` `"admin only"`. /// 3. `Origin` check → `403` (design §2.1). /// 4. Unknown `slug` → `404`. /// 5. `200 {"updated": ""}`. /// /// This only sets the page's own flag — it does not touch the owner's /// `users.trusted` (that's [`update_user`]'s job). A page's *effective* /// trust (what `routes::serve` actually applies) is `trusted OR /// owner_trusted`, so a page can still end up served untrusted even after /// this returns `200` if `trusted = false` was requested and the owner also /// isn't trusted — that's expected, not a bug. #[worker::send] pub async fn update_page( State(state): State, Path(slug): Path, headers: HeaderMap, Json(body): Json, ) -> Response { 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: update_page session lookup failed: {e}"); return json_error(StatusCode::INTERNAL_SERVER_ERROR, "internal error"); } }; if !user.is_admin { return json_error(StatusCode::FORBIDDEN, "admin only"); } 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"); } match state.db.set_page_trusted(&slug, body.trusted).await { Ok(()) => Json(json!({ "updated": slug })).into_response(), Err(DbError::NotFound) => json_error(StatusCode::NOT_FOUND, "page not found"), Err(e) => { worker::console_log!("pages: set_page_trusted failed: {e}"); json_error(StatusCode::INTERNAL_SERVER_ERROR, "internal error") } } } // ── POST /api/pages/:slug/aliases ───────────────────────────────────────── /// Request body for [`add_alias`]. #[derive(Debug, Deserialize)] pub struct AddAliasBody { /// The alternate name to add. Validated with the same /// [`validate_slug`](crate::core::slug::validate_slug) grammar and /// reserved-name rules as a real page slug, since it lives in the same /// URL namespace. alias: String, } /// `POST /api/pages/:slug/aliases` — add an alias to a page (pages-9lsk). /// /// 1. No session → `401`. /// 2. `!can_upload` → `403` — adding a name is publish-like, so it needs the /// same permission as [`create_page`]/[`replace_page`]. /// 3. `Origin` check → `403` (design §2.1: all mutating routes). /// 4. Unknown `slug` → `404`. /// 5. Neither owner nor admin → `403` (same [`can_replace`] rule as content /// replacement). /// 6. `alias` fails [`validate_slug`](crate::core::slug::validate_slug) → /// `400` (grammar or reserved name). /// 7. `alias` equals the page's own slug → `400` (a page is already served at /// its slug; a self-alias is meaningless). /// 8. `alias` already names a page or another alias → `409` `"name already /// taken"` (the shared-namespace invariant: the page-slug collision is /// checked here via [`Db::get_page`](crate::db::Db::get_page), the /// alias-vs-alias collision by [`Db::insert_alias`](crate::db::Db::insert_alias)). /// 9. Page already has [`MAX_ALIASES_PER_PAGE`] aliases → `409`. /// 10. `201` `{alias, slug}`. /// /// The new alias is attributed to the page's owner (`page.owner_id`), not the /// caller — so an admin adding an alias to someone else's page doesn't record /// themselves as its owner. #[worker::send] pub async fn add_alias( State(state): State, Path(slug): Path, headers: HeaderMap, Json(body): Json, ) -> 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: add_alias 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: add_alias 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 modify this page"); } // 6. Validate the alias name (same grammar/reserved rules as a slug). let alias = body.alias; if let Err(e) = validate_slug(&alias) { return json_error(StatusCode::BAD_REQUEST, e.to_string()); } // 7. A self-alias is a no-op name — reject it explicitly. if alias == slug { return json_error( StatusCode::BAD_REQUEST, "alias must differ from the page's name", ); } // 8a. Namespace: reject a name already used by a page (the alias-vs-alias // case is handled by insert_alias's UNIQUE → NameTaken below). match state.db.get_page(&alias).await { Ok(Some(_)) => return json_error(StatusCode::CONFLICT, "name already taken"), Ok(None) => {} Err(e) => { worker::console_log!("pages: add_alias get_page(alias) failed: {e}"); return json_error(StatusCode::INTERNAL_SERVER_ERROR, "internal error"); } } // 9. Per-page alias cap. match state.db.count_aliases_for_page(&slug).await { Ok(n) if n >= MAX_ALIASES_PER_PAGE => { return json_error(StatusCode::CONFLICT, "alias limit reached for this page"); } Ok(_) => {} Err(e) => { worker::console_log!("pages: count_aliases_for_page failed: {e}"); return json_error(StatusCode::INTERNAL_SERVER_ERROR, "internal error"); } } // 10. Insert, attributing the alias to the page's owner. match state.db.insert_alias(&alias, &slug, page.owner_id).await { Ok(()) => ( StatusCode::CREATED, Json(json!({ "alias": alias, "slug": slug })), ) .into_response(), Err(DbError::NameTaken) => json_error(StatusCode::CONFLICT, "name already taken"), Err(e) => { worker::console_log!("pages: insert_alias failed: {e}"); json_error(StatusCode::INTERNAL_SERVER_ERROR, "internal error") } } } // ── DELETE /api/pages/:slug/aliases/:alias ──────────────────────────────── /// `DELETE /api/pages/:slug/aliases/:alias` — remove an alias (pages-9lsk). /// /// 1. No session → `401`. /// 2. `Origin` check → `403`. /// 3. Unknown `slug` → `404`. /// 4. Neither owner nor admin → `403`. /// 5. `alias` isn't an alias *of this page* → `404` (the delete is scoped to /// `slug` in [`Db::delete_alias`](crate::db::Db::delete_alias)). /// 6. `200` `{deleted: ""}`. /// /// Unlike [`add_alias`], removing an alias does **not** require `can_upload`: /// it's a cleanup action (mirroring [`delete_page`], which also only needs /// owner-or-admin), so a user whose upload permission was revoked can still /// tidy up names on pages they own. #[worker::send] pub async fn remove_alias( State(state): State, Path((slug, alias)): Path<(String, String)>, headers: HeaderMap, ) -> 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: remove_alias session lookup failed: {e}"); return json_error(StatusCode::INTERNAL_SERVER_ERROR, "internal error"); } }; // 2. 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"); } // 3. Page must exist (authorize against the page that owns the alias). 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: remove_alias get_page failed: {e}"); return json_error(StatusCode::INTERNAL_SERVER_ERROR, "internal error"); } }; // 4. Owner or admin. if !can_delete(user.id, user.is_admin, page.owner_id) { return json_error(StatusCode::FORBIDDEN, "not authorized to modify this page"); } // 5-6. Delete the alias, scoped to this page. match state.db.delete_alias(&alias, &slug).await { Ok(()) => Json(json!({ "deleted": alias })).into_response(), Err(DbError::NotFound) => json_error(StatusCode::NOT_FOUND, "alias not found"), Err(e) => { worker::console_log!("pages: delete_alias failed: {e}"); json_error(StatusCode::INTERNAL_SERVER_ERROR, "internal error") } } }