# The Forum — system documentation

How the rebuilt board at `app/Forum/` works, what it inherited, and what it
changed. Companion to `app/Forum/CLAUDE.md` (which is the short working brief).

---

## 1. What was there before

`hef.cfm` is a frame — the forum's `i.cfm`. It dispatches `?f=<page>` to
`f_he*.cfm` files, and one engine serves **two** boards through an `hTbl`
switch:

| `hTbl` | Tables | What it is |
|---|---|---|
| `hef` | `hef`, `hef2`, `hef_old`, `hef2_old`, `hef_s` | The Forum |
| `he`  | `he`, `he2`, `he_old`, `he2_old`, `he_s`     | The Help Center |

`f_hef.cfm` and friends are one-line wrappers that set `hTbl` and include the
`f_he*` equivalent.

### The schema

A thread is a row in `hef`; a reply is a row in `hef2` pointing back through
`belongto`. Both tables denormalise the author's name into `usernic`, which
turns out to matter enormously (§5).

```
hef       id, nshort(30), nlong, usernic(20), userid, datetime,
          lastuserid, lastusernic, lastpost, type, apost, auserid,
          ausernic, atime, closeflag, publicflag, paidflag
hef2      id, belongto, nlong, usernic(20), userid, datetime
hef_s     id, nic            -- a row's PRESENCE means "pinned"
he_type   id, short, name, detail, publicflag, access, orderby, emailflag
```

`he_type` is the section list for both boards, and `access` (minimum
`adminflag`) plus `publicflag` are the whole permission model.

### Sizes on the live board

| | rows |
|---|---|
| `hef` threads | 9,341 |
| `hef2` replies | 122,036 |
| `hef_old` / `hef2_old` | 24 / 37,208 |
| distinct authors | 2,234 |
| span | Feb 2006 – Jul 2026 |

---

## 2. Four things wrong with the old board

These are findings from reading the code and the data, not opinions about
style. Each one shaped the rebuild.

### 2.1 · 1,341 threads nobody could reach

`f_he.cfm:139` lists the Forum with:

```cfml
<cfset heType = "and type>=100 and type<=199 ">
```

But 1,341 `hef` rows carry `type = 1` — posted between 2006 and 2023, and
**1,236 of them are still open** (`closeflag IS NULL`). They predate the
category scheme. No listing in the old UI could ever show them; only a direct
`?f=hef_detail&hi=<id>` link resolved.

They were never deleted. They were unreachable, which on a board looks
identical from the outside.

**Fixed by** giving them a real section — *The Archive*, a
`forum_section` row for `hef`/`1` with `postable = 0`. Readable, linkable,
searchable; read-only, because new conversation belongs in a live section.

### 2.2 · Stored XSS, and it was used

`f_he_new.cfm` escapes angle brackets and then, 40 lines later in the same
function:

```cfml
if ( form.chat contains "[[" ) form.chat=replacenocase(form.chat,"[[","<","ALL");
if ( form.chat contains "]]" ) form.chat=replacenocase(form.chat,"]]",">","ALL");
```

That conversion exists so server-generated section headers can carry markup,
but it runs over the **whole body, including what the player typed**. Typing
`[[script]]` stores a real script tag. `f_he_detail.cfm` then prints `nlong`
raw.

Players found it. The live board holds:

- a 2009 reply that rewrites `document.stepform.action`
- a 2010 reply that sets `document.stepform.forum2.disabled = true`, disabling
  the reply box for everyone who opens the thread
- `<iframe>`s pointing at a bare third-party IP and at an IRC widget
- a hidden 0×0 tracking pixel to `c.gigcount.com`

**Fixed at the boundary, not in the data.** `Text.render()` runs every body —
2006 or today — through a tag/attribute whitelist. Rewriting 160,000 posts was
never an option: most of them legitimately contain `<br>`, `<font>` and `<u>`,
and editing twenty years of other people's words to fix a bug in the software
is the wrong trade.

### 2.3 · Permissions decided in four places

`f_he.cfm`, `f_he_detail.cfm`, `f_he_new.cfm` and the commented-out block in
`f_he.cfm` each build their own version of the same `publicflag` / `access` /
`adminflag` test, and they do not agree. That is how a section can be hidden on
the index and readable by URL — which the rebuild reproduced on its first
attempt and a test caught: section 105 (`access = 1`) listed in full to
signed-out visitors because the listing query only filtered `publicflag`.

**Fixed by** putting every decision in `Base.cfc` and calling it from both the
index and the listing.

### 2.4 · Moderation by editing the post

Closing a thread appended a sentence into the author's own text:

```cfml
nlong = '#h.nlong#<br><br><font color=##804000>Thread closed by #session.username# on ...</font>'
```

No record of who, no undo, and a permanent edit to somebody's words. There was
no lock, no move, no report queue, and no audit trail.

**Fixed by** `forum_thread_meta` (state) plus `forum_modlog` (append-only
record). Archive still uses the legacy copy-to-`_old` path, because that is
where twenty years of closed threads already live and both boards have to agree.

---

## 3. Architecture

### Request flow

```
index.cfm
  ├── bootstrap (s_loadvar.cfm if the application scope is cold)
  ├── F = new components.People()          one object, whole inheritance chain
  ├── route: ?p=<page>  (+ legacy ?hi= / ?ch= compatibility)
  ├── POST action? -> do it, set a flash, redirect (POST-Redirect-GET)
  ├── cfsavecontent -> views/<page>.cfm
  └── render chrome around it
```

Writes always redirect. The legacy board re-rendered the list inline after a
post, so a refresh re-posted and the back button showed a stale page.

### Why server-rendered

The mockup built its chrome in JavaScript because it had to work from
`file://`. A forum is a body of text people find through search engines and
link to each other; chrome and content that exist only after a script runs are
not reliably indexable, not linkable, and gone if the script fails.
`assets/js/forum.js` is enhancement only — delete it and the board still
renders, navigates, posts and moderates.

### Component chain

| Component | Owns |
|---|---|
| `Base` | session identity (`me()`), permissions, query runner, section map, formatting |
| `Text` | storage form in (`store`), sanitised HTML out (`render`), `toEditable`, excerpts |
| `Board` | index, section listings, threads, replies, search, read state, view counts |
| `Posts` | create, reply, edit, remove, CSRF, flood control, subscriptions, notifications |
| `Mod` | pin, lock, move, archive, restore, mark-answer, reports, modlog |
| `People` | member directory, profiles, notification feed, PM bridge |

---

## 4. Reading live and archive together

The legacy board treats `_old` as a **mode** (`session.hearchive`), so archived
threads are invisible unless you find the dropdown. Here they are UNIONed into
one listing with an `archived` flag, because "closed in 2011" is a property of a
thread, not a different website.

The union must deduplicate:

```sql
select <cols>, 0 as archived from hef x     where <cond> <visibility>
union all
select <cols>, 1 as archived from hef_old x where <cond> <visibility>
  and not exists (select 1 from hef l where l.id = x.id)
```

Closing a thread copies to `_old` **and** deletes the live row. "Tag as
answered" copies **without** deleting. So 2,400 `hef2` rows and 73 `he` rows sit
in both tables, and a naive `UNION ALL` lists them twice.

---

## 5. Identity: posts outlive accounts

**1,897 of the 2,234 distinct forum authors have no `user` row.** Accounts were
purged over twenty years; `hef.usernic` is the only surviving record of who
wrote a post.

A member directory built from `user` would show a twenty-year board with 337
members and attribute 85% of its history to nobody. So the directory is built
from **authorship** — group `hef`/`hef2` by `userid`, take the name from the
post, and `LEFT JOIN user` only for the extras a live account can supply.

A departed author keeps a profile, a post count, and a working name link. The
page says the account is closed instead of inventing a rank and a fed for
somebody who left in 2011. It is also why `forum_profile` is keyed on `userid`
and is optional, rather than being columns on `user`.

---

## 6. New tables

All in `app/Admin/sql/forum_schema.sql`. Purely additive — no existing column
is altered and no row is deleted. Keyed on `(src, thread_id)` so the same
tables serve the Help Center if it is ever folded in.

| Table | Holds |
|---|---|
| `forum_category` | Board-index groupings (`he_type` has no notion of one) |
| `forum_section` | Placement + presentation per `he_type`, per board. **Not permissions.** |
| `forum_prefix` | Thread prefixes |
| `forum_thread_meta` | Views, prefix, pin, lock, moved-from |
| `forum_read` | Per-user read markers (`last_seen_reply`) |
| `forum_subscribe` | Thread following — the many-to-many `he.emailflag` could not be |
| `forum_react` | Reactions (schema only, no UI yet) |
| `forum_report` | Player reports → moderation queue |
| `forum_modlog` | Append-only staff action record |
| `forum_profile` | Forum title + signature |
| `forum_notify` | On-site notification feed |

Plus indexes on the existing tables — `(type, lastpost)` on `hef` matters most:
without it every section listing is an index scan plus a filesort.

### Subscriptions

`he.emailflag` is a single column on the *thread* row, so the old board could
notify exactly one person — the original poster — about exactly one thing.
Anyone else following a discussion had no way to say so. `forum_subscribe` is
the relation that column could not express; mode 0 also sends a PM through the
game's own inbox, so a reply reaches players who never open the forum.

---

## 7. Behaviour carried over deliberately

Not everything old was wrong. These are kept on purpose:

- **The 30-character title limit.** `nshort` is `varchar(30)`. Widening it
  would rewrite a column every legacy page still reads.
- **The storage form.** A backtick for the apostrophe, entity-escaped quotes,
  `<br>` for newlines. New posts stay byte-compatible with the old board, so
  nothing is stranded if this is rolled back. (Angle brackets are the one
  departure — see §9.)
- **The word filter**, unchanged. It is blunt and it always has been — changing
  what it censors is a moderation decision, not a refactor, and doing it quietly
  inside a rewrite would change how people's posts read.
- **Rate limits** — 30s between threads, 5s between replies, staff exempt.
- **Staff thresholds** — level ≥ 1 staff, ≥ 2 moderate, ≥ 3 pin/archive. Nobody
  gains or loses a power in the move.
- **Whole-row click** on thread lists (the old `NLcso3` handler), but the row
  still contains a real anchor so keyboard and middle-click work without JS.

## 8. Changed on purpose

- **Default sort is newest thread**, not last reply. On a board this quiet, one
  reply to a 2009 thread would otherwise bury the entire front page. "Last
  reply" is still one click away.
- **Filters live in the URL**, not the session. The old ones followed you
  between sections and a shared link showed the recipient something different.
- **Editing** — authors get 30 minutes on their own posts; the legacy board
  allowed staff only.
- **Archived threads are listed inline**, marked, instead of behind a mode
  switch.
- **The board index exists.** The old one had none: `hef.cfm` dropped you into a
  flat list and the shape of the board lived only in one row of header links.

---

## 9. HTML posting

**HTML is the default for everyone.** A post is stored as written and rendered
as markup; there is no "allow HTML" checkbox, because there is nothing left for
it to gate.

The legacy board hid raw HTML behind a staff-only radio for a good reason: an
HTML post was printed verbatim, so letting a player write one was letting them
write script. That reason is gone. `Text.render()` now whitelists tags and
attributes on the way **out**, which means the author's privilege level is
irrelevant to safety — a staff post and a player post pass through exactly the
same filter, as do all 160,000 posts written before the filter existed.

### The `style` attribute

Allowed, but rebuilt rather than passed through. `safeStyle()` splits the
attribute into declarations, checks each property against `ALLOW_CSS`, scrubs
each value, and reassembles. One bad declaration is dropped and the rest
survives — a typo should not cost the author their colour.

Deliberately **not** allowed: `position`, `top`/`left`/`right`/`bottom`,
`z-index`, `float`, `display`. A fixed-position element can be laid over the
rest of the page, which turns a forum post into a clickjack. Also blocked:
anything containing `url(`, `expression`, `behavior`, `binding`, `@import`, a
backslash escape (`\65 xpression` is the classic way past a substring check), or
a quote/brace/paren that could close the attribute.

Two values are bounded rather than rejected, because the intent is legitimate
but the extreme is hostile: `font-size` caps at 36px / 200% / 3em, and
`opacity` below 0.4 is dropped so a post cannot be made invisible.

### Showing markup instead of using it

`[code]...[/code]`. The content is pulled out **before** any other processing —
so it escapes cleanly, the word filter never touches it, and its newlines stay
newlines — and put back last as an escaped `<pre class="codeblock">`. It is the
only place in a body where literal angle brackets survive.

### One consequence worth knowing

`toEditable()` no longer decodes `&lt;` / `&gt;`. Since `store()` writes `<`
through unchanged, an entity in a stored body means the author wanted a
*literal* angle bracket. Decoding it on the way into the editor and re-storing
would silently turn a twenty-year-old post's quoted example markup into live
markup — changing what the post says.
