|
|
//! `/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 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<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")
|
|
|
}
|
|
|
}
|
|
|
}
|