# Deploy hu13 on Cloudflare Pages (Free)

The same `dist/` works on Docker/`hu13 serve`, nginx, and Pages. On
Pages the inbox is implemented by a Pages Function backed by R2; a
static-only upload has no working inbox.

Pages normalizes `.html` request paths with a 308 (for example
`/issues/index.html` → `/issues/` and `/files/a.md.html` →
`/files/a.md`). Every generated link still reaches the same page; the
redirect is a Pages platform behavior, not a missing file. Docker/nginx
serve the exact `.html` path with no redirect. If byte-identical URLs
matter, put a proxy/Worker in front or accept the Pages canonical form;
`_redirects` status 200 cannot override these clean-URL redirects.

## 1. Build the site

```sh
cargo build --release
./target/release/hu13 build . -o dist \
  --public-origin https://hu13.example.com
```

`--public-origin` makes the clone command, `og:image`, and Atom links
absolute. The build refuses output over Cloudflare's
limits (20,000 files, 25 MiB/file) and writes `_headers` and
`_routes.json` into `dist/`.

## 2. Create the R2 bucket and Pages project

```sh
npx wrangler r2 bucket create hu13-inbox
npx wrangler pages project create hu13
```

Bind the bucket to the Pages project as **`INBOX`** (dashboard:
Settings → Functions → R2 bindings, or the `[[r2_buckets]]` block in
`wrangler.toml`). Set the required secret:

```sh
npx wrangler pages secret put INBOX_SALT --project-name hu13
```

`INBOX_SALT` is a random string (for example `openssl rand -hex 32`).
The function hashes `v1|<ip>|<yyyy-mm-dd>|<salt>` with SHA-256; no
plaintext IP is stored. Rotating the secret unlinks old hashes.

Optional variables: `INBOX_RATE_PER_HOUR` (default 2),
`INBOX_QUOTA` (default 500), `INBOX_MAX_TITLE` (300),
`INBOX_MAX_BODY` (20000).

## 3. Deploy

```sh
npx wrangler pages deploy dist --project-name hu13
```

Wrangler uploads `functions/` alongside `dist/`. `dist/_routes.json`
limits Functions to `/inbox/*`, so every other request is static.
Keep `wrangler.toml` at the repo root for the R2 binding.

## What the Function implements

`functions/inbox/[kind].js` serves both mailboxes:

- `POST /inbox/issue` (`body`) and `POST /inbox/pr-prompt` (`prompt`,
  `body` accepted as an alias)
- urlencoded only; 32 KiB whole-body cap; strict UTF-8; NUL → 400
- controls stripped; title ≤300, body ≤20000, handle ≤40, contact ≤100
- license checkbox required; filled honeypot returns a fake receipt
  without storing
- same-origin `Origin`/`Referer` gate; no input is echoed or logged
- fixed ~500 ms response delay; `Cache-Control: no-store` and
  `X-Content-Type-Options: nosniff`
- one R2 object per accepted submission:
  `<uuid>.json` with `type/title/body/handle/contact/license_version/
  received_at/salted_ip_hash` (same shape as the Docker inbox)

Rate limits are layered: the Function has a best-effort isolate-local
bucket, but that is **not global**. Add a Cloudflare WAF rate-limiting
rule for `/inbox/*` (for example 10 requests/minute per IP) as the
durable control. The global quota is checked against the R2 bucket
before storing; R2 list consistency means a burst can briefly exceed it.

## Reading submissions

```sh
npx wrangler r2 object list hu13-inbox
npx wrangler r2 object get hu13-inbox/<uuid>.json
```

`hu13 inbox list` reads the local filesystem inbox used by Docker/`serve`;
it does not read R2. Copy objects down if you want the terminal reader.

## Limits and caveats

- Pages Free: 20,000 files and 25 MiB per file per deployment. The
  build enforces both; large assets belong outside git or in Git LFS.
- The function bundle must be deployed with `functions/`; uploading only
  `dist/` (drag-and-drop or a plain static host) gives a dead inbox.
- `repo.git` is a dumb-HTTP repo: cloning works on any static host, no
  smart server. No GitHub dependency.
- Committed `/raw/*` bytes and release artifacts under
  `/releases/<version>/*` always download (`Content-Disposition:
  attachment`) on both `serve` and Pages, so hostile HTML/SVG can never
  execute in the inbox origin. The Pages `_headers` rule uses one named
  placeholder plus one splat (`/releases/:version/*`): Pages silently
  skips rules with two splats.
