Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .github/workflows/forum.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Tests every pull request that touches the forum app, so a red PR shows
# before merging. Deploys happen in Cloudflare Workers Builds (see web/DEPLOY.md).
name: Forum

on:
pull_request:
paths: ["web/**", ".github/workflows/forum.yml"]
workflow_dispatch:

defaults:
run:
working-directory: web

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
cache-dependency-path: web/package-lock.json
- run: npm ci
- run: npm run typecheck
- run: npm test
1 change: 1 addition & 0 deletions web/.node-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
22
57 changes: 57 additions & 0 deletions web/DEPLOY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Deploying the forum

Cloudflare Workers Builds deploys the forum on every push to `main`. In the
Worker's settings (Workers & Pages → nodebox-forum → Settings → Build):

| Setting | Value |
| --- | --- |
| Git repository | `nodebox/forum`, production branch `main` |
| Root directory | `web` |
| Build command | `npm run ci:build` (typecheck, tests, client bundle) |
| Deploy command | `npm run ci:deploy` (D1 migrations, then `wrangler deploy`) |
| Non-production branch builds | Off. Preview versions would use the production D1 database and R2 bucket |

A failing test stops the build, so nothing is deployed. Migrations run before
the new code is live, so every migration must keep the running version
working: add tables and columns, don't drop or rename them in the same
release. Node comes from `web/.node-version` (22).

The build's API token needs **D1 → Edit** on top of the Workers permissions,
or the migration step fails with an authorisation error. If the token
Cloudflare creates for the build lacks it, edit that token (or pick one that
has it) under the build settings.

GitHub Actions (`.github/workflows/forum.yml`) only runs the tests on pull
requests; it holds no Cloudflare credentials.

## One-time setup

| What | Where |
| --- | --- |
| D1 database | Created; its id is in `wrangler.jsonc` |
| R2 bucket | `npx wrangler r2 bucket create nodebox-forum` |
| Hostname | After the first deploy: add `forum.nodebox.net` as a custom domain (`routes` in `wrangler.jsonc`). Until then the forum runs on its workers.dev URL |
| Email | Onboard `nodebox.net` for Email Sending in the dashboard; sender is `MAIL_FROM` (`forum@nodebox.net`) |
| Images | Uses the account's existing variants `medium` (1000 px wide, in posts) and `thumb` (600 × 600, galleries) |
| Worker secrets | `npm run secrets` copies `CF_ACCOUNT_ID`, `CF_IMAGES_HASH`, `CF_IMAGES_TOKEN` from 1Password (`op://NodeBox/NodeBox Forum/Cloudflare/…`) |
| Git connection | The build settings above. Connect it last: the first build deploys |

## First deploy

1. Merge this setup to `main` with the database id filled in. Nothing deploys
while the repository isn't connected in Cloudflare.
2. Connect the repository in the build settings. The first build creates the
schema in the empty D1 database and deploys.
3. Load the Tender data from the machine that has `data/` (it contains email
addresses; it never goes to GitHub):
`npx wrangler d1 execute nodebox-forum --remote --file ../data/import.sql`
4. Upload the old attachments to R2 and Images (script still to write).
5. Make the first admin: `npm run admin -- grant <email> --remote`.
6. Check: a sign-in email arrives, an upload works, a screenshot shows its
resized variant, an old Tender URL redirects.

## Rolling back

- Code: `npx wrangler rollback` (or pick a version under Workers → Deployments).
- Data: D1 Time Travel, `npx wrangler d1 time-travel restore nodebox-forum --timestamp <ISO time>`.
Migrations only go forward; a bad one is fixed by a new migration or a restore.
15 changes: 4 additions & 11 deletions web/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,14 +59,7 @@ in Node against local D1/R2 (wrangler's platform proxy): sign-in by email link,
claiming an imported account, posting with an attachment, reactions,
moderation, CSRF and old-URL redirects.

## Deploy (once the accounts exist)

1. `npx wrangler d1 create nodebox-forum`, paste the id into `wrangler.jsonc`.
2. `npx wrangler r2 bucket create nodebox-forum`.
3. Onboard the sending domain for Cloudflare Email Sending; set `MAILER` to
`cloudflare` and `MAIL_FROM`, `PUBLIC_URL` in `wrangler.jsonc`.
4. In Cloudflare Images, create the variants `nodebox-forum-post` (1200 px wide,
scale down) and `nodebox-forum-thumb` (400 px).
5. Secrets: `wrangler secret put` for `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`,
`CF_ACCOUNT_ID`, `CF_IMAGES_TOKEN`, `CF_IMAGES_HASH`.
6. `npx wrangler d1 migrations apply nodebox-forum --remote`, then `npm run deploy`.
## Deploy

Pushing to `main` tests, migrates and deploys through GitHub Actions. Setup,
the first deploy and rollback are in [DEPLOY.md](DEPLOY.md).
6 changes: 5 additions & 1 deletion web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,14 @@
"type": "module",
"scripts": {
"build:client": "node scripts/build-client.mjs",
"dev": "npm run build:client && wrangler dev",
"dev": "npm run build:client && wrangler dev --var MAILER:log --var PUBLIC_URL:http://localhost:8787",
"db:migrate:local": "wrangler d1 migrations apply nodebox-forum --local",
"db:migrate:remote": "wrangler d1 migrations apply nodebox-forum --remote",
"db:seed:local": "wrangler d1 execute nodebox-forum --local --file seed/dev.sql",
"deploy": "npm run build:client && wrangler deploy",
"secrets": "sh scripts/put-secrets.sh",
"ci:build": "npm run typecheck && npm test && npm run build:client",
"ci:deploy": "wrangler d1 migrations apply nodebox-forum --remote && wrangler deploy",
"types": "wrangler types",
"typecheck": "tsc --noEmit && tsc --noEmit -p client",
"test": "node --disable-warning=ExperimentalWarning --test test/*.test.ts",
Expand Down
6 changes: 5 additions & 1 deletion web/scripts/import-tender.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,8 +167,10 @@ export function convert(src: DatabaseSync, imagesPrefix = "nodebox-forum-"): { s
FROM comments WHERE discussion_id = ? AND coalesce(internal, 0) = 0 AND coalesce(system_message, 0) = 0
ORDER BY number`,
);
// One file can hang on several comments (the exporter keys on comment + path); the forum keeps it on the first.
const importedAttachments = new Set<string>();
const attachmentsStmt = src.prepare(
"SELECT download_path, comment_number, filename, content_type, size, width, height, created_at, local_path FROM attachments WHERE discussion_id = ?",
"SELECT download_path, comment_number, filename, content_type, size, width, height, created_at, local_path FROM attachments WHERE discussion_id = ? ORDER BY comment_number",
);

// Old URLs -> new topic paths, for rewriting links inside bodies.
Expand Down Expand Up @@ -260,6 +262,8 @@ export function convert(src: DatabaseSync, imagesPrefix = "nodebox-forum-"): { s
const pid = postIdByNumber.get(f.comment_number);
if (pid === undefined) continue; // attachment on an internal note
const id = attachmentId(f.download_path);
if (importedAttachments.has(id)) continue;
importedAttachments.add(id);
const type = f.content_type || "application/octet-stream";
out.push(
insert("attachments", {
Expand Down
9 changes: 9 additions & 0 deletions web/scripts/put-secrets.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
#!/bin/sh
# Copies the forum's Cloudflare secrets from 1Password into the Worker.
# Needs the 1Password CLI (op) signed in. Values go from op straight into
# wrangler over a pipe; nothing is printed or written to disk.
set -eu
cd "$(dirname "$0")/.."
for name in CF_ACCOUNT_ID CF_IMAGES_HASH CF_IMAGES_TOKEN; do
op read "op://NodeBox/NodeBox Forum/Cloudflare/$name" | npx wrangler secret put "$name"
done
9 changes: 5 additions & 4 deletions web/test/import.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,11 @@ function fixture(): DatabaseSync {
(101, 1, 3, 'Bob', NULL, 'Uw pakket', 0, '2014-05-04T09:00:00Z', '{}'),
(102, 1, 1, 'Ann', NULL, 'secret', 0, '${t}', '{}'),
(103, 1, 1, 'Ann', NULL, 'private', 0, '${t}', '{}');
INSERT INTO attachments (download_path, discussion_id, comment_number, filename, content_type, size, raw) VALUES
('/assets/abc/shot.png', 100, 1, 'shot.png', 'image/png', 1234, '{}'),
('/assets/def/project.zip', 100, 5, 'project.zip', 'application/zip', 99999, '{}'),
('/assets/ghi/note.txt', 100, 3, 'note.txt', 'text/plain', 1, '{}');
INSERT INTO attachments (download_path, download_url, discussion_id, comment_number, filename, content_type, size, raw) VALUES
('/assets/abc/shot.png', 'https://s3.example/abc/shot.png?sig=1', 100, 1, 'shot.png', 'image/png', 1234, '{}'),
('/assets/def/project.zip', 'https://s3.example/def/project.zip?sig=1', 100, 5, 'project.zip', 'application/zip', 99999, '{}'),
('/assets/abc/shot.png', 'https://s3.example/abc/shot.png?sig=2', 100, 5, 'shot.png', 'image/png', 1234, '{}'),
('/assets/ghi/note.txt', 'https://s3.example/ghi/note.txt?sig=1', 100, 3, 'note.txt', 'text/plain', 1, '{}');
-- Same shape as the spam filter's migrations/0002_spam.sql.
CREATE TABLE spam_verdicts (discussion_id INTEGER NOT NULL, comment_number INTEGER NOT NULL, verdict TEXT NOT NULL, confidence REAL,
source TEXT NOT NULL, reason TEXT, model TEXT, classified_at TEXT NOT NULL, PRIMARY KEY (discussion_id, comment_number));
Expand Down
20 changes: 12 additions & 8 deletions web/wrangler.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@
"compatibility_date": "2026-09-01",
"compatibility_flags": ["nodejs_compat"],
"assets": { "directory": "public", "binding": "ASSETS" },
// Runs on the workers.dev URL until the forum.nodebox.net custom domain is added (after the first deploy).
"workers_dev": true,
"observability": { "enabled": true },
"d1_databases": [
// Create with `wrangler d1 create nodebox-forum` and paste the id here.
{ "binding": "DB", "database_name": "nodebox-forum", "database_id": "00000000-0000-0000-0000-000000000000", "migrations_dir": "migrations" }
{ "binding": "DB", "database_name": "nodebox-forum", "database_id": "c66a199f-225d-46eb-baff-0f16b4696a41", "migrations_dir": "migrations" }
],
// Cloudflare Email Sending, for sign-in links. The sending domain must be onboarded in the dashboard.
"send_email": [{ "name": "EMAIL" }],
Expand All @@ -19,16 +21,18 @@
"vars": {
"SITE_NAME": "NodeBox Forum",
// Absolute origin, used in sign-in emails and the Google redirect URI.
"PUBLIC_URL": "http://localhost:8787",
// Cloudflare Images: ids and variant names are global to the account, so everything is prefixed.
// These are the production values; `npm run dev` overrides PUBLIC_URL and MAILER.
"PUBLIC_URL": "https://forum.nodebox.net",
// Cloudflare Images: image ids are global to the account, so they are prefixed. The variants are the account's shared ones.
"CF_IMAGES_PREFIX": "nodebox-forum-",
"CF_IMAGES_VARIANT_POST": "nodebox-forum-post",
"CF_IMAGES_VARIANT_THUMB": "nodebox-forum-thumb",
// "log" prints sign-in links to the console (development); "cloudflare" sends real mail.
"MAILER": "log",
"CF_IMAGES_VARIANT_POST": "medium",
"CF_IMAGES_VARIANT_THUMB": "thumb",
// "cloudflare" sends real mail; "log" prints sign-in links to the console (development).
"MAILER": "cloudflare",
"MAIL_FROM": "forum@nodebox.net"
}
// Secrets (wrangler secret put, or .dev.vars locally):
// GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET
// CF_ACCOUNT_ID, CF_IMAGES_TOKEN, CF_IMAGES_HASH (the account hash in imagedelivery.net URLs)
// Deploys run in Cloudflare Workers Builds on every push to main (web/DEPLOY.md).
}