diff --git a/pages/.beans/pages-9lsk--page-aliases-multiple-long-lived-names-per-page-se.md b/pages/.beans/pages-9lsk--page-aliases-multiple-long-lived-names-per-page-se.md new file mode 100644 index 0000000..993fd2e --- /dev/null +++ b/pages/.beans/pages-9lsk--page-aliases-multiple-long-lived-names-per-page-se.md @@ -0,0 +1,61 @@ +--- +# pages-9lsk +title: 'Page aliases: multiple long-lived names per page (serve-direct)' +status: in-progress +type: feature +priority: normal +created_at: 2026-07-21T17:32:02Z +updated_at: 2026-07-21T17:41:39Z +--- + +Add removable, long-lived **aliases** to pages: a page keeps one canonical `slug` and can have N extra names in the same URL namespace that serve the page's content directly (no redirect). Replaces the earlier rename/redirect idea (no rename feature). + +Confirmed decisions: +1. Serve-direct — the alias URL shows the content with the URL unchanged; content is stored once under the canonical `/` R2 prefix; alias resolution just picks the canonical slug as the R2 prefix. No redirect, so no 301-cache pitfalls. +2. Aliases replace rename entirely. No canonical-rename feature. +3. Adding an alias requires `can_upload` (plus owner-or-admin + origin check), same authz shape as replace/delete. + +## Namespace invariant (critical) +Aliases and page slugs share ONE URL namespace; a name is unique across BOTH `pages.slug` and `aliases.alias`. +- Creating a page named X → 409 if X is any existing slug OR alias. +- Adding alias Y → 409 if Y is any existing slug OR alias; also reject Y == the page's own slug. +- Alias must pass the same `validate_slug` (grammar + reserved names) as a real slug. +- A real page always wins over an alias (aliases are only consulted on a page-miss), so no shadowing. + +## Data model (D1) +CREATE TABLE aliases ( + alias TEXT PRIMARY KEY, + slug TEXT NOT NULL REFERENCES pages(slug), + owner_id INTEGER NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE INDEX idx_aliases_slug ON aliases(slug); +schema.sql is idempotent (IF NOT EXISTS) — migration is additive/safe. + +## Serve resolution (serve.rs) +In serve_page: if get_page(slug) misses, fall back to get_page_by_alias(slug) (JOIN aliases->pages->users, returns the canonical PageRow + effective_trust in one query). Serve using page.slug as the R2 prefix (NOT the requested alias) via resolve_candidates(&page.slug, rest). redirect_to_index (/{name} -> /{name}/) is unchanged and already works for alias names. + +## Lifecycle +- Delete page → delete its aliases (explicit delete_aliases_for_page in delete_page handler; don't rely on FK cascade). +- Replace content → aliases untouched. +- Cap: max 10 aliases per page (anti-squatting). + +## API +- POST /api/pages/{slug}/aliases {"alias":"bar"} -> 201 (session, can_upload, origin, page exists, owner-or-admin, validate_slug, namespace check, cap) +- DELETE /api/pages/{slug}/aliases/{alias} -> 200 +- Include aliases[] per page in GET /api/pages (one extra list_all_aliases query, grouped by slug). +- create_page: add alias-namespace check (reject name that is an existing alias). + +## Frontend +Per-page in the management UI: list aliases with remove (x) buttons + an "Add alias" input. Wire to the two endpoints. + +## Tasks +- [x] D1 migration: aliases table + index in migrations/schema.sql (+ 0003-aliases.sql) +- [x] db.rs: get_page_by_alias, insert_alias, delete_alias, list_aliases_for_page, list_all_aliases, count_aliases_for_page, delete_aliases_for_page, alias_exists +- [x] serve.rs: alias fallback in serve_page (serve under canonical slug prefix) +- [x] api.rs: add_alias + remove_alias handlers; namespace check in create_page; alias cleanup in delete_page; aliases[] in list_pages +- [x] router: POST /api/pages/{slug}/aliases, DELETE /api/pages/{slug}/aliases/{alias} +- [x] frontend: alias chips (with x to remove) + Add alias button +- [x] docs: design doc (§4.1/§4.2/§4.3) + PLANNING.md +- [x] validation (all six) pass: 196 unit + 20 doctests, both-target clippy clean +- [ ] migrate prod D1 + deploy (with user go-ahead) diff --git a/pages/docs/PLANNING.md b/pages/docs/PLANNING.md index 01691ba..7e12877 100644 --- a/pages/docs/PLANNING.md +++ b/pages/docs/PLANNING.md @@ -63,3 +63,21 @@ `BASE_URL=http://localhost:8787` makes every mutating route 403 on the Origin check. Set `BASE_URL=http://pages.elijah.run` in `.dev.vars` when exercising the API locally. +- **2026-07-21** — pages-9lsk: page **aliases** (design §4.1). A page keeps + one canonical `slug` and gains up to 10 removable, long-lived alternate + names in the same URL namespace. New `aliases` table + (`migrations/0003-aliases.sql`, idempotent `CREATE TABLE IF NOT EXISTS`) + + `idx_aliases_slug`; namespace uniqueness across `pages.slug`+`aliases.alias` + is enforced in the handlers (SQLite can't express a cross-table rule). + Serving is **serve-direct**: `routes::serve` falls back to + `Db::get_page_by_alias` on a canonical miss and serves the canonical page's + R2 content under its `/` prefix — no redirect, no second copy, and a + real page always wins over an alias of the same name. New `POST + /api/pages/:slug/aliases` (can_upload + owner-or-admin) and `DELETE + /api/pages/:slug/aliases/:alias` (owner-or-admin); `create_page` now rejects + a name held by an alias; `delete_page` removes a page's aliases; + `GET /api/pages` returns each page's `aliases[]`. Management UI shows alias + chips (with × to remove) and an "Add alias" button. No R2/behavioral change + to existing pages. Replaced the earlier rename+conditional-redirect idea + (dropped — 301-cache fragility). 196 unit + 20 doctests, both-target clippy + clean. diff --git a/pages/docs/plans/2026-07-12-pages-design.md b/pages/docs/plans/2026-07-12-pages-design.md index f26d4ab..3dbb57b 100644 --- a/pages/docs/plans/2026-07-12-pages-design.md +++ b/pages/docs/plans/2026-07-12-pages-design.md @@ -166,12 +166,25 @@ so it should be granted even more sparingly than a single-page grant. | GET | `/api/users` | admin | List users | | PATCH | `/api/users/:id` | admin | Set `can_upload` and/or `trusted` (JSON body `{"can_upload"?: bool, "trusted"?: bool}`, at least one required — pages-shqc) | | PATCH | `/api/pages/:slug` | admin | Set a page's own `trusted` flag (JSON body `{"trusted": bool}` — pages-shqc) | +| POST | `/api/pages/:slug/aliases` | can_upload **and** (owner or admin) | Add an alias to a page (JSON body `{"alias": ""}`). 201 / 400 / 401 / 403 / 404 / 409 (pages-9lsk) | +| DELETE | `/api/pages/:slug/aliases/:alias` | owner or admin | Remove an alias. 200 / 401 / 403 / 404 (pages-9lsk) | | GET | `/:slug` | – | 301 → `/:slug/` | -| GET | `/:slug/` | – | Serve `index.html` of the page | +| GET | `/:slug/` | – | Serve `index.html` of the page (or, if `:slug` is an alias, its canonical page's) | | GET | `/:slug/*path` | – | Serve asset; directory-style paths fall back to `/index.html`; 404 JSON-less page otherwise | Single-file `.html` uploads are stored as `/index.html`. +**Aliases (pages-9lsk).** A page keeps one canonical `slug` and may have up to +10 removable **aliases** — extra long-lived names in the *same* URL namespace. +A name is unique across **both** `pages.slug` and `aliases.alias`; the +handlers enforce that cross-table rule (SQLite can't). Serving is +**serve-direct**: on a canonical page miss the serve path falls back to +alias resolution and serves the canonical page's R2 content under its +`/` prefix — the alias URL shows the content with no redirect and no +second copy. A real page always wins over an alias of the same name (the +fallback only runs on a page miss). Deleting a page removes its aliases; +`GET /api/pages` returns each page's `aliases[]`. + ### 4.2 D1 schema ```sql @@ -201,18 +214,29 @@ CREATE TABLE pages ( trusted INTEGER NOT NULL DEFAULT 0, -- pages-shqc, §2.1 addendum created_at TEXT NOT NULL DEFAULT (datetime('now')) ); + +CREATE TABLE aliases ( -- pages-9lsk + alias TEXT PRIMARY KEY, -- alternate name, unique across slugs+aliases (enforced in handlers) + slug TEXT NOT NULL REFERENCES pages(slug), -- canonical page it serves + owner_id INTEGER NOT NULL REFERENCES users(id), + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE INDEX idx_aliases_slug ON aliases(slug); ``` `trusted` on both tables was added by pages-shqc (§2.1 addendum) after v0 shipped — `migrations/0002-trust.sql` is the incremental `ALTER TABLE` for -the already-live production database; `migrations/schema.sql` above already -has both columns for fresh installs. +the already-live production database. The `aliases` table was added by +pages-9lsk — `migrations/0003-aliases.sql` is its incremental (idempotent, +`CREATE TABLE IF NOT EXISTS`) migration. `migrations/schema.sql` already has +everything for fresh installs. ### 4.3 R2 layout `/` (e.g. `my-project/index.html`, `my-project/assets/app.js`). Deleting a page lists by prefix `/` and -deletes all objects. +deletes all objects. Aliases add **no** R2 objects — an alias request serves +the canonical page's existing `/…` objects directly (pages-9lsk). ### 4.4 Bindings & configuration (wrangler.toml) diff --git a/pages/migrations/0003-aliases.sql b/pages/migrations/0003-aliases.sql new file mode 100644 index 0000000..1d4978c --- /dev/null +++ b/pages/migrations/0003-aliases.sql @@ -0,0 +1,27 @@ +-- pages.elijah.run — incremental migration: page aliases +-- +-- pages-9lsk: adds removable, long-lived **aliases** — extra names a page can +-- be reached at, in the same URL namespace as `pages.slug`. Serving is +-- serve-direct (an alias request serves the canonical page's R2 content with +-- no redirect); namespace uniqueness across `pages.slug` and `aliases.alias` +-- is enforced in the route handlers (src/routes/api.rs). See design §4.1. +-- +-- Unlike 0002-trust.sql (an in-place `ALTER TABLE`), this adds a brand-new +-- table, so every statement is `IF NOT EXISTS` and the file is fully +-- idempotent — re-running it against a database that already has the table is +-- a safe no-op. `migrations/schema.sql` already contains the same definitions +-- for fresh installs; this file is the isolated change for the *existing* +-- production D1 database. +-- +-- Apply with (a separate, human-triggered step): +-- wrangler d1 execute --file=migrations/0003-aliases.sql (remote) +-- wrangler d1 execute --local --file=migrations/0003-aliases.sql (local dev) + +CREATE TABLE IF NOT EXISTS aliases ( + alias TEXT PRIMARY KEY, + slug TEXT NOT NULL REFERENCES pages(slug), + owner_id INTEGER NOT NULL REFERENCES users(id), + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_aliases_slug ON aliases(slug); diff --git a/pages/migrations/schema.sql b/pages/migrations/schema.sql index 247912c..56923e0 100644 --- a/pages/migrations/schema.sql +++ b/pages/migrations/schema.sql @@ -80,3 +80,26 @@ CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at); -- `ON DELETE CASCADE` scan when a user row is deleted, plus any future -- "list my sessions" / "log out everywhere" feature keyed on user_id. CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id); + +-- Page aliases (pages-9lsk): extra, long-lived names a page can be reached +-- at, in the *same* URL namespace as `pages.slug`. `alias` is the primary key +-- so a duplicate alias surfaces as a UNIQUE violation (mapped to +-- `DbError::NameTaken` -> 409, exactly like `pages.slug`). Namespace +-- uniqueness across BOTH tables (an alias may not equal any existing slug, +-- and vice versa) is enforced in the route handlers (src/routes/api.rs), not +-- by a DB constraint — SQLite can't express a cross-table uniqueness rule. +-- `slug` is the canonical page the alias resolves to; content lives only once, +-- under the canonical `/` R2 prefix, and an alias hit serves it directly +-- (no redirect) — see src/routes/serve.rs. Aliases are removed explicitly when +-- their page is deleted (Db::delete_aliases_for_page in the delete_page +-- handler); the `REFERENCES pages(slug)` FK documents the relationship but the +-- cleanup does not rely on cascade being enabled. `idx_aliases_slug` backs the +-- per-page alias lookups (list/count/delete-for-page) so they don't scan. +CREATE TABLE IF NOT EXISTS aliases ( + alias TEXT PRIMARY KEY, + slug TEXT NOT NULL REFERENCES pages(slug), + owner_id INTEGER NOT NULL REFERENCES users(id), + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_aliases_slug ON aliases(slug); diff --git a/pages/src/db.rs b/pages/src/db.rs index ccf0795..2d38e9e 100644 --- a/pages/src/db.rs +++ b/pages/src/db.rs @@ -711,4 +711,204 @@ impl Db { } Ok(()) } + + // ── Aliases (pages-9lsk) ─────────────────────────────────────────────── + + /// Resolve an alias to its **canonical** page, joining owner login and + /// trust exactly like [`Db::get_page`]. + /// + /// Returns `Ok(None)` if `alias` is not a registered alias (or its page + /// has since been deleted — the `JOIN pages` drops orphans). The returned + /// [`PageRow`]'s `slug` is the canonical slug, which `routes::serve` uses + /// as the R2 key prefix so an alias request serves the real content + /// directly (no redirect), with the canonical page's + /// [`effective_trust`](PageRow::effective_trust). + pub async fn get_page_by_alias(&self, alias: &str) -> Result, DbError> { + let row = self + .0 + .prepare( + "SELECT p.slug, p.owner_id, u.login AS owner_login, p.file_count, p.total_bytes, \ + p.trusted AS trusted, u.trusted AS owner_trusted, p.created_at \ + FROM aliases a JOIN pages p ON p.slug = a.slug JOIN users u ON u.id = p.owner_id \ + WHERE a.alias = ?1", + ) + .bind(&[JsValue::from_str(alias)]) + .map_err(classify)? + .first::(None) + .await + .map_err(classify)?; + Ok(row.map(PageRow::from)) + } + + /// Whether `name` is already registered as an alias. + /// + /// Backs the namespace-uniqueness checks in + /// [`create_page`](crate::routes::api::create_page) and + /// [`add_alias`](crate::routes::api::add_alias): a name must be free + /// across *both* `pages.slug` and `aliases.alias` (SQLite can't express + /// that cross-table rule as a constraint, so the handlers check it). + pub async fn alias_exists(&self, name: &str) -> Result { + #[derive(Deserialize)] + struct OneRow { + #[allow(dead_code)] + one: i64, + } + let row = self + .0 + .prepare("SELECT 1 AS one FROM aliases WHERE alias = ?1") + .bind(&[JsValue::from_str(name)]) + .map_err(classify)? + .first::(None) + .await + .map_err(classify)?; + Ok(row.is_some()) + } + + /// Insert an alias row (pages-9lsk). + /// + /// Returns `Err(DbError::NameTaken)` if `alias` already exists as an alias + /// (`aliases.alias` primary key) — detected from the D1 error message the + /// same fragile-but-narrow way as [`Db::insert_page`] (see the module + /// docs). The caller separately rejects a `name` that collides with a + /// `pages.slug` (via [`Db::get_page`]) before calling this, so the two + /// checks together enforce full namespace uniqueness. + pub async fn insert_alias( + &self, + alias: &str, + slug: &str, + owner_id: i64, + ) -> Result<(), DbError> { + let result = self + .0 + .prepare("INSERT INTO aliases (alias, slug, owner_id) VALUES (?1, ?2, ?3)") + .bind(&[ + JsValue::from_str(alias), + JsValue::from_str(slug), + JsValue::from_f64(owner_id as f64), + ]) + .map_err(classify)? + .run() + .await; + + match result { + Ok(_) => Ok(()), + Err(e) => { + let msg = e.to_string().to_lowercase(); + if msg.contains("unique") { + Err(DbError::NameTaken) + } else { + Err(DbError::Worker(e)) + } + } + } + } + + /// Delete a single alias, scoped to its page (pages-9lsk). + /// + /// `alias` must both exist *and* point at `slug`, else + /// `Err(DbError::NotFound)`. Scoping the `DELETE` to `slug` (rather than + /// `alias` alone) means an owner authorized against one page can only + /// remove that page's aliases, never an alias of some other page that + /// happens to share the request. + pub async fn delete_alias(&self, alias: &str, slug: &str) -> Result<(), DbError> { + let result = self + .0 + .prepare("DELETE FROM aliases WHERE alias = ?1 AND slug = ?2") + .bind(&[JsValue::from_str(alias), JsValue::from_str(slug)]) + .map_err(classify)? + .run() + .await + .map_err(classify)?; + + let changes = result + .meta() + .map_err(classify)? + .and_then(|m| m.changes) + .unwrap_or(0); + + if changes == 0 { + return Err(DbError::NotFound); + } + Ok(()) + } + + /// List a single page's alias names, alphabetically (pages-9lsk). + /// + /// Backed by `idx_aliases_slug` (`migrations/schema.sql`). Used by the + /// upload/replace responses to echo the page's current aliases. + pub async fn list_aliases_for_page(&self, slug: &str) -> Result, DbError> { + #[derive(Deserialize)] + struct NameRow { + alias: String, + } + let rows = self + .0 + .prepare("SELECT alias FROM aliases WHERE slug = ?1 ORDER BY alias ASC") + .bind(&[JsValue::from_str(slug)]) + .map_err(classify)? + .all() + .await + .map_err(classify)? + .results::() + .map_err(classify)?; + Ok(rows.into_iter().map(|r| r.alias).collect()) + } + + /// List every `(alias, slug)` pair, alphabetically by alias (pages-9lsk). + /// + /// Lets [`list_pages`](crate::routes::api::list_pages) attach aliases to + /// their pages with a single extra query, rather than one lookup per page. + pub async fn list_all_aliases(&self) -> Result, DbError> { + #[derive(Deserialize)] + struct PairRow { + alias: String, + slug: String, + } + let rows = self + .0 + .prepare("SELECT alias, slug FROM aliases ORDER BY alias ASC") + .all() + .await + .map_err(classify)? + .results::() + .map_err(classify)?; + Ok(rows.into_iter().map(|r| (r.alias, r.slug)).collect()) + } + + /// Count a page's current aliases (pages-9lsk), to enforce the per-page + /// alias cap in [`add_alias`](crate::routes::api::add_alias). Backed by + /// `idx_aliases_slug`. + pub async fn count_aliases_for_page(&self, slug: &str) -> Result { + #[derive(Deserialize)] + struct CountRow { + count: i64, + } + let row = self + .0 + .prepare("SELECT COUNT(*) AS count FROM aliases WHERE slug = ?1") + .bind(&[JsValue::from_str(slug)]) + .map_err(classify)? + .first::(None) + .await + .map_err(classify)?; + Ok(row.map(|r| r.count).unwrap_or(0)) + } + + /// Delete every alias pointing at `slug` (pages-9lsk). + /// + /// Called from [`delete_page`](crate::routes::api::delete_page) so + /// removing a page also frees its alias names for reuse. A no-op (not an + /// error) if the page has none. Kept explicit rather than relying on the + /// `REFERENCES pages(slug)` FK cascading, so cleanup works regardless of + /// whether D1 has foreign-key enforcement enabled. + pub async fn delete_aliases_for_page(&self, slug: &str) -> Result<(), DbError> { + self.0 + .prepare("DELETE FROM aliases WHERE slug = ?1") + .bind(&[JsValue::from_str(slug)]) + .map_err(classify)? + .run() + .await + .map_err(classify)?; + Ok(()) + } } diff --git a/pages/src/routes/api.rs b/pages/src/routes/api.rs index 2300fd0..323087f 100644 --- a/pages/src/routes/api.rs +++ b/pages/src/routes/api.rs @@ -42,6 +42,12 @@ use crate::db::DbError; /// 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() @@ -189,31 +195,49 @@ pub async fn me(State(state): State, headers: HeaderMap) -> Response { /// `GET /api/pages` — public list of all published pages (design §4.1). #[worker::send] pub async fn list_pages(State(state): State) -> Response { - match state.db.list_pages().await { - Ok(rows) => { - let pages: Vec<_> = rows - .into_iter() - .map(|r| { - let effective_trust = r.effective_trust(); - 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, - }) - }) - .collect(); - Json(json!({ "pages": pages })).into_response() - } + let rows = match state.db.list_pages().await { + Ok(rows) => rows, Err(e) => { worker::console_log!("pages: list_pages failed: {e}"); - json_error(StatusCode::INTERNAL_SERVER_ERROR, "failed to list pages") + 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= ────────────────────────────────────────── @@ -288,6 +312,19 @@ pub async fn create_page( 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 => { @@ -616,7 +653,16 @@ pub async fn delete_page( } } - // 6. Delete the D1 row. + // 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"); @@ -812,3 +858,197 @@ pub async fn update_page( } } } + +// ── 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") + } + } +} diff --git a/pages/src/routes/mod.rs b/pages/src/routes/mod.rs index a886715..bbe9b46 100644 --- a/pages/src/routes/mod.rs +++ b/pages/src/routes/mod.rs @@ -143,6 +143,14 @@ pub fn router(env: worker::Env) -> worker::Result { put(api::replace_page).layer(DefaultBodyLimit::max(api::MAX_UPLOAD_BYTES as usize)), ), ) + // Page aliases (pages-9lsk): add one to a page, or remove a specific + // one. Both are literal-`/api/`-prefixed, so they never collide with + // the catch-all page-serving routes below. + .route("/api/pages/{slug}/aliases", post(api::add_alias)) + .route( + "/api/pages/{slug}/aliases/{alias}", + delete(api::remove_alias), + ) .route("/api/users", get(api::list_users)) .route("/api/users/{id}", patch(api::update_user)) // Hosted-page serving (design §4.7, pages-adl9) — registered after diff --git a/pages/src/routes/serve.rs b/pages/src/routes/serve.rs index 8253e80..229d119 100644 --- a/pages/src/routes/serve.rs +++ b/pages/src/routes/serve.rs @@ -174,12 +174,15 @@ pub async fn serve_asset( /// 1. Invalid/reserved `slug` → `404` (no D1 lookup attempted; the slug /// could never have a `pages` row). /// 2. [`Db::get_page`](crate::db::Db::get_page) — a D1 lookup, done -/// **before** touching R2 (pages-shqc): `None` → `404` immediately, -/// without any R2 round trip. `Some(page)` carries +/// **before** touching R2 (pages-shqc). On a miss, fall back to +/// [`Db::get_page_by_alias`](crate::db::Db::get_page_by_alias) +/// (pages-9lsk); still `None` → `404` immediately, without any R2 round +/// trip. Either way the resolved `page` carries /// [`PageRow::effective_trust`](crate::db::PageRow::effective_trust), /// which every response built below in this call uses to pick headers -/// via [`user_content_headers`] (through [`apply_user_content_headers`]). -/// A D1 error here → `500`. +/// via [`user_content_headers`] (through [`apply_user_content_headers`]), +/// and its (canonical) `slug` is used as the R2 key prefix — so an alias +/// request serves the aliased page's content directly. A D1 error → `500`. /// 3. [`resolve_candidates`] → empty (unsafe/oversized `rest`) → `404`. /// 4. Each candidate key is tried against R2 `get` **in order**; the first /// hit wins. `Content-Type` is derived from the *matched* key's @@ -197,7 +200,19 @@ async fn serve_page(state: &AppState, slug: &str, rest: &str) -> Response { let page = match state.db.get_page(slug).await { Ok(Some(p)) => p, - Ok(None) => return not_found_response(), + // Not a canonical page — fall back to alias resolution (pages-9lsk). + // An alias serves its canonical page's content *directly* (no + // redirect): resolve to that page's `PageRow` and serve from its + // (canonical) R2 prefix below. This fallback runs only on a canonical + // miss, so a real page always wins over an alias of the same name. + Ok(None) => match state.db.get_page_by_alias(slug).await { + Ok(Some(p)) => p, + Ok(None) => return not_found_response(), + Err(e) => { + worker::console_log!("pages: get_page_by_alias failed for '{slug}': {e}"); + return internal_error_response(); + } + }, Err(e) => { worker::console_log!("pages: get_page failed for '{slug}': {e}"); return internal_error_response(); @@ -205,7 +220,10 @@ async fn serve_page(state: &AppState, slug: &str, rest: &str) -> Response { }; let trusted = page.effective_trust(); - let candidates = resolve_candidates(slug, rest); + // Serve from the *canonical* slug's R2 prefix: for a direct page hit this + // equals `slug`; for an alias hit it's the aliased page's slug, so the + // alias URL serves the real content with no extra copy and no redirect. + let candidates = resolve_candidates(&page.slug, rest); for key in candidates { match state.bucket.0.get(key.clone()).execute().await { diff --git a/pages/static/index.html b/pages/static/index.html index b3aa60b..6803527 100644 --- a/pages/static/index.html +++ b/pages/static/index.html @@ -280,6 +280,30 @@ margin-left: 0.4rem; } + /* Alias chips under a page's slug (pages-9lsk). */ + .aliases { margin-top: 0.3rem; display: flex; flex-wrap: wrap; gap: 0.3rem; } + .alias-chip { + display: inline-flex; + align-items: center; + gap: 0.2rem; + font-size: 0.72rem; + padding: 0.08rem 0.2rem 0.08rem 0.45rem; + border-radius: 999px; + background: var(--code-bg); + border: 1px solid var(--info-border); + } + .alias-chip a { color: var(--text-muted); } + .alias-remove { + border: none; + background: transparent; + color: var(--text-muted); + cursor: pointer; + font-size: 0.95rem; + line-height: 1; + padding: 0 0.15rem; + } + .alias-remove:hover { color: var(--danger, #c0392b); } + .muted { color: var(--text-muted); font-size: 0.85rem; } code { @@ -946,10 +970,49 @@ pages.forEach(function (row) { var tr = el("tr"); + // Owner-or-admin is the server's rule for both DELETE and the alias + // routes (core::origin::can_delete / can_replace). + var canManage = !!(me.authenticated && me.user && + (me.user.is_admin || me.user.login === row.owner_login)); + var slugTd = el("td"); var link = el("a", { text: row.slug }); link.href = "/" + row.slug + "/"; slugTd.appendChild(link); + // Alias chips (pages-9lsk): each is a live alternate URL; owners/admins + // get a × to remove it. + if (Array.isArray(row.aliases) && row.aliases.length) { + var aliasWrap = el("div", { className: "aliases" }); + row.aliases.forEach(function (a) { + var chip = el("span", { className: "alias-chip" }); + var alink = el("a", { text: a }); + alink.href = "/" + a + "/"; + chip.appendChild(alink); + if (canManage) { + var x = el("button", { className: "alias-remove", text: "×" }); + x.type = "button"; + x.title = "Remove alias '" + a + "'"; + x.addEventListener("click", function () { + if (!window.confirm("Remove alias '" + a + "'? The page stays at '" + row.slug + "'.")) return; + x.disabled = true; + apiFetch("/api/pages/" + encodeURIComponent(row.slug) + "/aliases/" + encodeURIComponent(a), + { method: "DELETE" }).then(function (res) { + if (res.ok) { + clearBanner(pagesBanner); + loadPages(); + } else { + var msg = (res.body && res.body.error) || "Failed to remove alias."; + showBanner(pagesBanner, "error", msg); + x.disabled = false; + } + }); + }); + chip.appendChild(x); + } + aliasWrap.appendChild(chip); + }); + slugTd.appendChild(aliasWrap); + } tr.appendChild(slugTd); tr.appendChild(el("td", { text: row.owner_login })); @@ -995,12 +1058,10 @@ tr.appendChild(trustedTd); var actionsTd = el("td", { className: "row-actions" }); - // Owner-or-admin is the server's rule for both DELETE and PUT - // (core::origin::can_delete / can_replace); updating additionally needs - // upload permission, since it publishes new content. - var canDelete = !!(me.authenticated && me.user && - (me.user.is_admin || me.user.login === row.owner_login)); - if (canDelete && canUpload()) { + // `canManage` (owner-or-admin) is the server's rule for DELETE, PUT and + // the alias routes; Update and Add-alias additionally need upload + // permission, since they publish content / claim a new name. + if (canManage && canUpload()) { var updateBtn = el("button", { text: "Update" }); updateBtn.type = "button"; updateBtn.title = "Replace this page's content with a new upload."; @@ -1009,8 +1070,36 @@ openUploadModal(row.slug, updateBtn); }); actionsTd.appendChild(updateBtn); + + var aliasBtn = el("button", { text: "Add alias" }); + aliasBtn.type = "button"; + aliasBtn.title = "Add another name that serves this page."; + aliasBtn.addEventListener("click", function () { + var name = window.prompt("New alias for '" + row.slug + "' (lowercase letters, numbers, hyphens):"); + if (name === null) return; + name = name.trim(); + if (!name) return; + aliasBtn.disabled = true; + apiFetch("/api/pages/" + encodeURIComponent(row.slug) + "/aliases", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ alias: name }) + }).then(function (res) { + aliasBtn.disabled = false; + if (res.ok) { + clearBanner(pagesBanner); + showBanner(pagesBanner, "success", "Alias added — also live at ", "/" + res.body.alias + "/", "/" + res.body.alias + "/"); + loadPages(); + } else if (res.status === 409) { + showBanner(pagesBanner, "error", (res.body && res.body.error) || "That name is already taken."); + } else { + showBanner(pagesBanner, "error", (res.body && res.body.error) || "Failed to add alias."); + } + }); + }); + actionsTd.appendChild(aliasBtn); } - if (canDelete) { + if (canManage) { var delBtn = el("button", { className: "danger", text: "Delete" }); delBtn.type = "button"; delBtn.addEventListener("click", function () {