You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

1055 lines
43 KiB
Rust

This file contains ambiguous Unicode characters!

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

//! `/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": "<message>"}` 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": "<message>"}` JSON response with the given status.
fn json_error(status: StatusCode, message: impl Into<String>) -> 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<u64> {
headers
.get(CONTENT_LENGTH)
.and_then(|v| v.to_str().ok())
.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` 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<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",
));
}
// 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 `<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).
///
/// 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<AppState>, 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<AppState>) -> 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<String, Vec<String>> =
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=<slug> ──────────────────────────────────────────
/// 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<String>,
}
/// `POST /api/pages?name=<slug>` — 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 `<slug>/<path>`; 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<AppState>,
Query(query): Query<UploadQuery>,
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<String> = 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 `<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).
///
/// 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 `<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(
State(state): State<AppState>,
Path(slug): Path<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: 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 `<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 '{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<AppState>, 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<bool>,
/// 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<bool>,
}
/// `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<AppState>,
Path(id): Path<i64>,
headers: HeaderMap,
Json(body): Json<UpdateUserBody>,
) -> 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": "<slug>"}`.
///
/// 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<AppState>,
Path(slug): Path<String>,
headers: HeaderMap,
Json(body): Json<SetPageTrustedBody>,
) -> 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<AppState>,
Path(slug): Path<String>,
headers: HeaderMap,
Json(body): Json<AddAliasBody>,
) -> 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: "<alias>"}`.
///
/// 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<AppState>,
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")
}
}
}