feat(pages): add removable page aliases (serve-direct)

A page keeps one canonical slug and can have up to 10 long-lived,
removable aliases in the same URL namespace. 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 <slug>/ prefix — no
redirect, no second copy, and a real page always wins over an alias of
the same name.

- migrations: new `aliases` table + idx_aliases_slug (schema.sql +
  idempotent 0003-aliases.sql).
- db.rs: get_page_by_alias, insert_alias (NameTaken on collision),
  delete_alias (page-scoped), list/count/delete-for-page, alias_exists.
- api.rs: POST /api/pages/:slug/aliases (can_upload + owner-or-admin) and
  DELETE /api/pages/:slug/aliases/:alias (owner-or-admin); create_page
  rejects a name held by an alias; delete_page removes a page's aliases;
  GET /api/pages returns each page's aliases[].
- Namespace uniqueness across pages.slug + aliases.alias is enforced in
  the handlers (SQLite can't express the cross-table rule).
- Frontend: alias chips (× to remove) + an "Add alias" button.

Replaces the earlier rename+conditional-redirect idea (dropped for
301-cache fragility). No R2 or behavioral change to existing pages.
196 unit + 20 doctests, both-target clippy clean.

Bean: pages-9lsk.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
main
Elijah Voigt 1 day ago
parent 36d13cf266
commit 46e7a3dae5

@ -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 `<slug>/` 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)

@ -63,3 +63,21 @@
`BASE_URL=http://localhost:8787` makes every mutating route 403 on the `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 Origin check. Set `BASE_URL=http://pages.elijah.run` in `.dev.vars` when
exercising the API locally. 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 `<slug>/` 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.

@ -166,12 +166,25 @@ so it should be granted even more sparingly than a single-page grant.
| GET | `/api/users` | admin | List users | | 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/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) | | 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": "<name>"}`). 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` | | 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 `<path>/index.html`; 404 JSON-less page otherwise | | GET | `/:slug/*path` | | Serve asset; directory-style paths fall back to `<path>/index.html`; 404 JSON-less page otherwise |
Single-file `.html` uploads are stored as `<slug>/index.html`. Single-file `.html` uploads are stored as `<slug>/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
`<slug>/` 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 ### 4.2 D1 schema
```sql ```sql
@ -201,18 +214,29 @@ CREATE TABLE pages (
trusted INTEGER NOT NULL DEFAULT 0, -- pages-shqc, §2.1 addendum trusted INTEGER NOT NULL DEFAULT 0, -- pages-shqc, §2.1 addendum
created_at TEXT NOT NULL DEFAULT (datetime('now')) 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 `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 shipped — `migrations/0002-trust.sql` is the incremental `ALTER TABLE` for
the already-live production database; `migrations/schema.sql` above already the already-live production database. The `aliases` table was added by
has both columns for fresh installs. 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 ### 4.3 R2 layout
`<slug>/<relative-path>` (e.g. `my-project/index.html`, `<slug>/<relative-path>` (e.g. `my-project/index.html`,
`my-project/assets/app.js`). Deleting a page lists by prefix `<slug>/` and `my-project/assets/app.js`). Deleting a page lists by prefix `<slug>/` and
deletes all objects. deletes all objects. Aliases add **no** R2 objects — an alias request serves
the canonical page's existing `<slug>/…` objects directly (pages-9lsk).
### 4.4 Bindings & configuration (wrangler.toml) ### 4.4 Bindings & configuration (wrangler.toml)

@ -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 <DB_NAME> --file=migrations/0003-aliases.sql (remote)
-- wrangler d1 execute <DB_NAME> --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);

@ -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 -- `ON DELETE CASCADE` scan when a user row is deleted, plus any future
-- "list my sessions" / "log out everywhere" feature keyed on user_id. -- "list my sessions" / "log out everywhere" feature keyed on user_id.
CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(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 `<slug>/` 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);

@ -711,4 +711,204 @@ impl Db {
} }
Ok(()) 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<Option<PageRow>, 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::<PageRowRaw>(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<bool, DbError> {
#[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::<OneRow>(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<Vec<String>, 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::<NameRow>()
.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<Vec<(String, String)>, 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::<PairRow>()
.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<i64, DbError> {
#[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::<CountRow>(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(())
}
} }

@ -42,6 +42,12 @@ use crate::db::DbError;
/// one before the request body is available. /// one before the request body is available.
pub(crate) const MAX_UPLOAD_BYTES: u64 = 24 * 1024 * 1024; 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. /// Builds a `{"error": "<message>"}` JSON response with the given status.
fn json_error(status: StatusCode, message: impl Into<String>) -> Response { fn json_error(status: StatusCode, message: impl Into<String>) -> Response {
(status, Json(json!({ "error": message.into() }))).into_response() (status, Json(json!({ "error": message.into() }))).into_response()
@ -189,12 +195,35 @@ pub async fn me(State(state): State<AppState>, headers: HeaderMap) -> Response {
/// `GET /api/pages` — public list of all published pages (design §4.1). /// `GET /api/pages` — public list of all published pages (design §4.1).
#[worker::send] #[worker::send]
pub async fn list_pages(State(state): State<AppState>) -> Response { pub async fn list_pages(State(state): State<AppState>) -> Response {
match state.db.list_pages().await { let rows = match state.db.list_pages().await {
Ok(rows) => { 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 let pages: Vec<_> = rows
.into_iter() .into_iter()
.map(|r| { .map(|r| {
let effective_trust = r.effective_trust(); let effective_trust = r.effective_trust();
let aliases = aliases_by_slug.get(&r.slug).cloned().unwrap_or_default();
json!({ json!({
"slug": r.slug, "slug": r.slug,
"owner_login": r.owner_login, "owner_login": r.owner_login,
@ -204,16 +233,11 @@ pub async fn list_pages(State(state): State<AppState>) -> Response {
"owner_trusted": r.owner_trusted, "owner_trusted": r.owner_trusted,
"effective_trust": effective_trust, "effective_trust": effective_trust,
"created_at": r.created_at, "created_at": r.created_at,
"aliases": aliases,
}) })
}) })
.collect(); .collect();
Json(json!({ "pages": pages })).into_response() Json(json!({ "pages": pages })).into_response()
}
Err(e) => {
worker::console_log!("pages: list_pages failed: {e}");
json_error(StatusCode::INTERNAL_SERVER_ERROR, "failed to list pages")
}
}
} }
// ── POST /api/pages?name=<slug> ────────────────────────────────────────── // ── POST /api/pages?name=<slug> ──────────────────────────────────────────
@ -288,6 +312,19 @@ pub async fn create_page(
return json_error(StatusCode::BAD_REQUEST, e.to_string()); 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. // 5. Quota.
match state.db.count_pages_for_user(user.id).await { match state.db.count_pages_for_user(user.id).await {
Ok(count) if count >= 50 => { 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 { if let Err(e) = state.db.delete_page(&slug).await {
worker::console_log!("pages: delete_page failed: {e}"); worker::console_log!("pages: delete_page failed: {e}");
return json_error(StatusCode::INTERNAL_SERVER_ERROR, "internal error"); 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<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")
}
}
}

@ -143,6 +143,14 @@ pub fn router(env: worker::Env) -> worker::Result<Router> {
put(api::replace_page).layer(DefaultBodyLimit::max(api::MAX_UPLOAD_BYTES as usize)), 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", get(api::list_users))
.route("/api/users/{id}", patch(api::update_user)) .route("/api/users/{id}", patch(api::update_user))
// Hosted-page serving (design §4.7, pages-adl9) — registered after // Hosted-page serving (design §4.7, pages-adl9) — registered after

@ -174,12 +174,15 @@ pub async fn serve_asset(
/// 1. Invalid/reserved `slug` → `404` (no D1 lookup attempted; the slug /// 1. Invalid/reserved `slug` → `404` (no D1 lookup attempted; the slug
/// could never have a `pages` row). /// could never have a `pages` row).
/// 2. [`Db::get_page`](crate::db::Db::get_page) — a D1 lookup, done /// 2. [`Db::get_page`](crate::db::Db::get_page) — a D1 lookup, done
/// **before** touching R2 (pages-shqc): `None` → `404` immediately, /// **before** touching R2 (pages-shqc). On a miss, fall back to
/// without any R2 round trip. `Some(page)` carries /// [`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), /// [`PageRow::effective_trust`](crate::db::PageRow::effective_trust),
/// which every response built below in this call uses to pick headers /// which every response built below in this call uses to pick headers
/// via [`user_content_headers`] (through [`apply_user_content_headers`]). /// via [`user_content_headers`] (through [`apply_user_content_headers`]),
/// A D1 error here → `500`. /// 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`. /// 3. [`resolve_candidates`] → empty (unsafe/oversized `rest`) → `404`.
/// 4. Each candidate key is tried against R2 `get` **in order**; the first /// 4. Each candidate key is tried against R2 `get` **in order**; the first
/// hit wins. `Content-Type` is derived from the *matched* key's /// hit wins. `Content-Type` is derived from the *matched* key's
@ -196,8 +199,20 @@ async fn serve_page(state: &AppState, slug: &str, rest: &str) -> Response {
} }
let page = match state.db.get_page(slug).await { let page = match state.db.get_page(slug).await {
Ok(Some(p)) => p,
// 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(Some(p)) => p,
Ok(None) => return not_found_response(), 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) => { Err(e) => {
worker::console_log!("pages: get_page failed for '{slug}': {e}"); worker::console_log!("pages: get_page failed for '{slug}': {e}");
return internal_error_response(); 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 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 { for key in candidates {
match state.bucket.0.get(key.clone()).execute().await { match state.bucket.0.get(key.clone()).execute().await {

@ -280,6 +280,30 @@
margin-left: 0.4rem; 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; } .muted { color: var(--text-muted); font-size: 0.85rem; }
code { code {
@ -946,10 +970,49 @@
pages.forEach(function (row) { pages.forEach(function (row) {
var tr = el("tr"); 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 slugTd = el("td");
var link = el("a", { text: row.slug }); var link = el("a", { text: row.slug });
link.href = "/" + row.slug + "/"; link.href = "/" + row.slug + "/";
slugTd.appendChild(link); 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(slugTd);
tr.appendChild(el("td", { text: row.owner_login })); tr.appendChild(el("td", { text: row.owner_login }));
@ -995,12 +1058,10 @@
tr.appendChild(trustedTd); tr.appendChild(trustedTd);
var actionsTd = el("td", { className: "row-actions" }); var actionsTd = el("td", { className: "row-actions" });
// Owner-or-admin is the server's rule for both DELETE and PUT // `canManage` (owner-or-admin) is the server's rule for DELETE, PUT and
// (core::origin::can_delete / can_replace); updating additionally needs // the alias routes; Update and Add-alias additionally need upload
// upload permission, since it publishes new content. // permission, since they publish content / claim a new name.
var canDelete = !!(me.authenticated && me.user && if (canManage && canUpload()) {
(me.user.is_admin || me.user.login === row.owner_login));
if (canDelete && canUpload()) {
var updateBtn = el("button", { text: "Update" }); var updateBtn = el("button", { text: "Update" });
updateBtn.type = "button"; updateBtn.type = "button";
updateBtn.title = "Replace this page's content with a new upload."; updateBtn.title = "Replace this page's content with a new upload.";
@ -1009,8 +1070,36 @@
openUploadModal(row.slug, updateBtn); openUploadModal(row.slug, updateBtn);
}); });
actionsTd.appendChild(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" }); var delBtn = el("button", { className: "danger", text: "Delete" });
delBtn.type = "button"; delBtn.type = "button";
delBtn.addEventListener("click", function () { delBtn.addEventListener("click", function () {

Loading…
Cancel
Save