Ikonboard 3.1.1 — complete teardown

A complete teardown of Ikonboard 3.1.1 (Jarvis Entertainment Group, July 2002), the Perl CGI forum that ran ffoncrack.com: the single-entry dispatcher, four database backends, all 179 modules, the 29-table schema field by field, the security findings including CVE-2003-0770, what converting from Ikonboard 2.1.9 actually cost, and the file timestamps showing the last change ever made to it was an advertisement.

From the desktop window

ikonboard-3.1.1-teardown.txt — Ikonboard 3.1.1 complete teardown

ikonboard-3.1.1-teardown.txtOpen in new tab

ikonboard-3.1.1-teardown.txt — Ikonboard 3.1.1 complete teardown

Original text document

# Ikonboard 3.1.1 -- complete teardown

A Perl CGI web forum, released July 2002 by Jarvis Entertainment Group, Inc.
Four storage backends, server-side sessions, hashed passwords, member groups,
a compiled skin engine and mod_perl support -- 73,000 lines of Perl trying to
turn a category of hobbyist software into a real application, about eighteen
months before PHP took the category away entirely.

It is not a new version of Ikonboard 2. Compared line by line, the two share
seventeen lines of source, all of them boilerplate.

This is a full teardown of the distribution: the architecture, every module,
every on-disk record format, the security model measured against both 2002 and
now, the three upgrade paths that shipped and what they cost, who owned it and
who wrote it -- and what this particular copy reveals about how the release was
built, and about the last change anyone ever made to it.

The distribution is preserved at <https://archive.org/details/ib311>.

Written August 2026. Sources, uncertainties and acknowledgements are in
section 12.

---

## Contents

| | |
|---|---|
| 1 | [What it is](#1-what-it-is) -- the shape of the thing, and why this copy matters |
| 2 | [History and authorship](#2-history-and-authorship) -- the handover to Jarvis, Matt Mecham's exit, the contributors, the licensing |
| 3 | [Architecture](#3-architecture) -- one entry point, four database backends, templates compiled to Perl |
| 4 | [Module reference](#4-module-reference) -- all 179 files, every endpoint, and the dead code |
| 5 | [Data formats](#5-data-formats) -- 29 tables field by field, and the drift between backends |
| 6 | [Security](#6-security) -- what improved, what the input filter misses, and what was left open |
| 7 | [Installation and operation](#7-installation-and-operation) -- how you ran one of these in 2002 |
| 8 | [Upgrade path](#8-upgrade-path) -- the three migrations, and what converting from 2.1.9 actually cost |
| 9 | [Archaeology](#9-archaeology) -- the tarball clock, the copyright strata, and the last edit |
| 10 | [How to reproduce this](#10-how-to-reproduce-this) -- the method |
| 11 | [The toolchain](#11-the-toolchain) -- all nine analysis scripts in full |
| 12 | [About this teardown](#12-about-this-teardown) -- uncertainties, acknowledgements, corrections |

Sections 4, 5 and 6 are reference material and long. Sections 3, 8 and 9 are
the ones to read if you only read three.

---

## 1. What it is

Ikonboard 3.1.1 is a web forum written in Perl, released in July 2002 by Jarvis
Entertainment Group, Inc. It ran as CGI on shared hosting, stored its data in
any of four backends -- Berkeley DBM, MySQL, PostgreSQL or Oracle -- and put a
bulletin board on the internet for anyone with FTP access and a `cgi-bin`
directory.

It is 179 Perl files and about 73,000 lines. Its immediate predecessor,
Ikonboard 2.1.9, was 43 files and 15,586 lines. Compared line by line, ignoring
comments and whitespace, they share seventeen lines -- a JavaScript clock, a few
HTML table tags, an array of month names, and `print "Content-type:
text/html\n\n";`. Version 3 is not a new version of Ikonboard 2. It is a
different program with the same name.

---

### 1.1 The shape of the thing

Version 2 was forty standalone CGI scripts that each read some text files and
printed HTML. Version 3 is a single dispatcher and a library:

```
   GET /cgi-bin/ikonboard.cgi?act=ST&t=42&s=<session>
                                   |
                                   v
   +------------------------------------------------------------------+
   |  ikonboard.cgi          the only executable page in the product   |
   |                                                                   |
   |  require Boardinfo.cgi              <- generated config, as Perl   |
   |  %iB::IN = map { _clean_key => _clean_value } CGI->param           |
   |  iDatabase::SQL->new( DB_DRIVER => 'DBM' | 'mySQL' | 'pgSQL' | ... )|
   |  Sessions->authenticate                                            |
   |  $std->LoadSkin  ->  do Skin/Default/Universal.pm                  |
   |                                                                   |
   |  %Mode = ( ST => ['Topic','ShowTopic'], SF => ['Forum',...], ... ) |
   |         44 actions                                                 |
   |                                                                   |
   |  eval "require Topic; my $idx = Topic->new(); $idx->ShowTopic($db)"|
   +------------------------------------------------------------------+
                                   |
                    +--------------+--------------+
                    v                             v
        Sources/Topic.pm                Skin/Default/TopicView.pm
        controller: reads the DB,       view: subs that return
        decides what to show            qq~ ...HTML... ~
                    |                             |
                    +--------------+--------------+
                                   v
                              HTML response
```

Three things about that diagram are the whole design.

**One entry point.** Everything is `ikonboard.cgi` with an `act=` parameter.
The dispatch table is a literal hash, and an unrecognized `act` falls back to
the board index rather than reaching the `eval`. A second stage inside each
module dispatches again on `CODE=`; a third, `AD=1`, diverts the whole request
into the admin control panel.

**A database abstraction layer.** `Sources/iDatabase/` declares the schema once
and implements it four times. An operator on a hosting plan with no database
server got DBM files; an operator with MySQL got MySQL; the board code above
the driver did not know or care. This is the single largest architectural
change from version 2, and the reason the product could target both a hobbyist
on a $5 host and a site with real traffic.

A fifth driver, `Driver/CSV.pm`, ships in the tree and cannot load -- it
declares the wrong package name, never inherits from the driver base class,
and has no constructor, and the installer does not offer it. It is a 33 KB
fossil of the previous storage engine, and it is the largest piece of dead code
in the product.

**Templates compiled to Perl.** A skin is a set of `.cfg` files holding HTML,
which the admin control panel compiles into `.pm` files full of subs that
`return qq~ ... ~`. The board loads only the compiled form. There is no
template parser at runtime -- the template *is* Perl by the time it executes.

---

### 1.2 Why this copy is worth a teardown

Two reasons, one about the software and one about this particular download.

Ikonboard 3 is the point where the Perl-CGI forum tried to become a real
application. It has server-side sessions, hashed passwords, member groups with
permission masks, a database abstraction layer, a compiled skin engine,
translatable strings, mod_perl support, and importers for rival forum software.
Version 2 had none of those; it had plaintext passwords in flat files and
English hardcoded into forty scripts. Version 3 is what the category looked
like just before PHP and MySQL took it away entirely, and it is a better piece
of engineering than its reputation suggests.

It is also not an abstract artifact here. **ffoncrack.com ran this release**,
and its member table survives in a personal backup -- a Berkeley DB hash file
whose values are the pipe-delimited records specified in section 5. That board
is the reason several statements in this document could be checked against a
real installation rather than only against the code. Section 12.5 sets out what
it settles.

The second reason is that this distribution ships its source inside six tar
archives, and a tar header preserves a modification time for every file it
holds. Zip timestamps are unreliable; tar timestamps are epoch seconds and
survive intact. So this download carries the vendor's own build clock, file by
file, from a machine that stopped existing two decades ago -- and the tree was
never installed, so nothing an operator did has overwritten it.

That is enough to reconstruct how the release was actually made. Development
ran in a nine-week burst through June and July 2002 and stopped on 07/15/2002.
Four months later, on 11/24/2002, three of the six archives were rebuilt within
fourteen minutes of each other. Exactly one file inside them postdates July: the
admin control panel's navigation menu, dated 11/25/2002, edited by hand -- and
the edit is confined to a block of promotional links pointing at the company's
hosting business and its affiliate sites. The last change ever made to
Ikonboard 3.1.1 was an advertisement.

---

### 1.3 What is in the box

The download is a zip. Unpacked, it is 97 files: four HTML guides, a folder of
installer screenshots, a small toolbox, three upgrade kits, and `Upload_Files/`
-- which is what you FTP to the server. The Perl is inside six tarballs in
`Upload_Files/cgi-bin/`, because untarring server-side avoided the era's single
worst support problem, uploading source in the wrong FTP transfer mode.

| Archive | Contents | Files |
|---|---|---|
| `Sources.tar` | the entire application | 118 |
| `Skin.tar` | the Default skin, templates and compiled views | 61 |
| `non-cgi.tar` | images, emoticons, avatars, CSS, mail templates | 262 |
| `Languages.tar` | the English string tables | 33 |
| `Database.tar` | 29 table declarations, one directory per table | 34 |
| `Data.tar` | runtime data stubs, skin registry, MIME map | 6 |

Everything in this teardown that discusses "the source tree" refers to those
archives unpacked in place, which is what an operator would have had. That
reconstruction is analysis work added here; it is not part of the download.

The application itself divides roughly like this:

| Subsystem | Files | Perl files | Perl lines |
|---|---|---|---|
| Admin control panel (`Sources/Admin/`) | 34 | 33 | 24,088 |
| Front-end controllers (`Sources/*.pm`) | 33 | 32 | 16,629 |
| Database abstraction (`Sources/iDatabase/`) | 13 | 12 | 6,530 |
| Skin (templates + compiled views) | 61 | 29 | 6,566 |
| Installer | 9 | 8 | 4,304 |
| Core library (`Sources/Lib/`) | 5 | 4 | 2,906 |
| User control panel and messenger | 6 | 5 | 2,528 |
| Language packs | 33 | 29 | 2,344 |
| Search subsystem | 6 | 6 | 1,683 |
| Small feature modules (`Sources/Misc/`) | 12 | 11 | 1,323 |
| Mail, SSI, mod_perl glue | 4 | 3 | 1,113 |
| Bundled CPAN (Tar, Zlib, MIME) | 5 | 4 | 1,949 |
| Board root (`ikonboard.cgi`, `installer.cgi`) | 7 | 2 | 838 |
| Database, seed data, web root, runtime data | 322 | 1 | 4 |
| **Total** | **550** | | **72,805** |

Two numbers in that table are worth pausing on. The admin control panel is
larger than the entire public-facing board -- 24,000 lines against 16,600 -- which
is what happens when every setting gets its own hand-written form. And the
single largest file in the product is `Sources/Admin/Options.pm` at 2,298 lines
and 107 KB: the board settings screen, for a board with 118 configurable
options.

---

### 1.4 What this document covers

The chapters are independent; the ones worth reading if you only read three are
the architecture chapter, the upgrade chapter, and the archaeology chapter.

- **History and authorship** -- the handover from Ikonboard.com to Jarvis
  Entertainment Group, what the copyright headers prove about it, Matt Mecham's
  departure to Invision, the community contributors credited in the source, and
  the licensing change.
- **Architecture** -- the dispatcher, the database abstraction layer, the skin
  engine, sessions, the language layer, and mod_perl.
- **Data formats** -- all 29 tables field by field, the storage backends,
  the schema drift between them, the skin and language formats, and notes on
  reading the data today without Ikonboard.
- **Module reference** -- every module, every endpoint, and the dead code.
- **Security** -- what improved since 2.1.9, what the front-door input filter
  does and does not cover, and the findings, rated as they would have been in
  2002 and as they read now.
- **Installation and operation** -- what running one of these actually involved,
  and whether it still runs.
- **Upgrade path** -- the three migrations that shipped, what the Ikonboard 2
  converter carried across and what it dropped, and a feature-by-feature
  account of what changed between 2.1.9 and 3.1.1.
- **Archaeology** -- what the timestamps, the tarballs and the copyright headers
  reveal about how this release was built and abandoned.

Then the method, the analysis scripts in full, and a note on sources,
uncertainties and corrections.

Two conventions throughout. Citations are `file:line` against the unpacked
tree. Anything quoted from the 2002 source or documentation is verbatim,
including its British spellings and its typos, because the spelling is part of
the evidence.

---

## 2. History and authorship

Ikonboard 3.1.1 is a Perl CGI bulletin board released in 2002 and copyright Jarvis
Entertainment Group, Inc. Its predecessor, Ikonboard 2.1.9 (June 2001), is copyright
Ikonboard.com and states on every page of source that "All files written by Matthew
Mecham." Between those two releases the product changed owners, changed countries,
changed legal posture, changed programming language conventions, and changed
development model -- from one author to a volunteer team working against a public bug
tracker. It also lost the author.

Almost all of that is legible in the shipped code. This chapter reads it.

---

### 2.0 How to read this chapter

The teardown tree is an unpacked distribution that was **never installed** (see
`out_provenance.txt` section 2.0: no `Boardinfo.cgi`, no `install.lock`, no `.pwd` key
file, no database rows). That is unusually good for archival purposes. It means every
file modification time in the tree is the *vendor's* clock -- the timestamps a
developer's editor and the packaging machine wrote -- and not an operator's. Nobody
CHMODed these files, nobody edited a skin, nobody's board ever ran on them.

Timestamps quoted in this chapter are **UTC**, matching the convention used by the
pre-computed analysis in `out_provenance.txt`. This matters at exactly one point: the
final file, `Sources/Admin/Menuadmin.pm`, is `2002-11-25 05:05:08` UTC, which is late
evening on 11/24/2002 in US Central time. Both readings appear in this document where
relevant, labeled.

Every claim below is tagged by what kind of evidence supports it:

| Tag | Meaning |
|-----|---------|
| **[CODE]** | Proven by a file in `ib311/cgi-bin/` or `ib219/`. Cited as `file.pm:123`. |
| **[DOC]** | Proven by a shipped document in the distribution (`ib311/`). |
| **[ARCHIVE]** | Proven by a dated Internet Archive capture of a contemporaneous web page. Timestamp given. |
| **[EXTERNAL]** | Secondary source -- encyclopedia, interview, CVE database, mailing list archive. |
| **[INFERENCE]** | My reading of the above. Not a fact. Flagged in the text. |
| **[UNVERIFIED]** | Stated in a source I could not corroborate, or a claim I could not confirm at all. |

The established framing facts for the whole teardown -- which this chapter does not
re-derive -- are: `$iB::VERSION = '3.1.1'` at `ikonboard.cgi:329`; the tree was never
installed; development ended around 07/15/2002 with tarballs repackaged 11/24/2002 and
one file dated 11/25/2002; and 3.1.1 is a rewrite rather than an evolution of 2.1.9,
sharing only 17 normalized source lines (0.49% of 2.1.9), all of them boilerplate.

---

### 2.1 Two products, two owners

Start with the bare ownership statements, side by side, as the two products make them.

**Ikonboard 2.1.9**, header of every core file **[CODE]** (`ib219/cgi-bin/ikonboard.cgi:3-13`):

```
#############################################################
# Ikonboard v2.1
# Copyright 2001 Ikonboard.com - All Rights Reserved
# Ikonboard is a trademark of Ikonboard.com
#
# Software Distributed by: Ikonboard.com
# Visit us online at http://www.ikonboard.com
# Email us on boards@ikonboard.com
#
# All files written by Matthew Mecham
#############################################################
```

**Ikonboard 3.1.1**, header of the dispatcher **[CODE]** (`ikonboard.cgi:5-20`):

```
######################################################
#| Ikonboard v3.1
#|
#| No parts of this script can be used outside Ikonboard
#| without prior consent.
#| You must keep this header intact and all copyright
#| links visable.
#|
#| (c)2002 Jarvis Entertainment Group, Inc.
#| Web: <http://www.ikonboard.com>
#| Email: ib@ikonboard.com
#| IRC Server   : irc.ikonboard.com
#|     Port     : #6667
#|     Channels : #ikonboard   #help   #support
#|
#| Please Read the licence for more information.
######################################################
```

Four things changed and one did not.

1. The **copyright holder** changed from Ikonboard.com to Jarvis Entertainment Group,
   Inc.
2. The **author attribution disappeared**. 2.1.9 names Matthew Mecham in the header of
   every file. 3.1.1's dispatcher names nobody.
3. The **trademark assertion disappeared**. 2.1.9 says "Ikonboard is a trademark of
   Ikonboard.com." No file in 3.1.1 makes a trademark claim. (The license does not
   either; see section 2.6.)
4. A **license-enforcement instruction appeared** in the header itself: "You must keep
   this header intact and all copyright links visable." That is not decoration; it is
   restated in the license and mechanically enforced in the code (section 2.6.4).
5. The **domain did not change**. `www.ikonboard.com` remains the product's home, the
   support address remains `@ikonboard.com`, the IRC network remains
   `irc.ikonboard.com`. The brand survived the corporate transfer intact.

Note the British spelling "visable" (sic -- for "visible") and "license" in the 3.1.1
header, both inherited from the 2.1.9-era house style. Ikonboard.com was based in
Gosport, Hampshire; JEG was based in Texas. The misspelling propagated forward into
Jarvis-copyright files unchanged, which is the first hint that the rebranding was a
find-and-replace on a name rather than a rewrite of the header text. Quotations
throughout this chapter preserve 2002 spelling and typography verbatim.

---

### 2.2 The handover, as the code records it

#### 2.1 The copyright census

The tree contains 179 Perl files (`.pm`, `.cgi`, `.pl`). Classifying each by the
copyright line in its first 4 KB gives **[CODE]**:

| Count | Copyright line |
|------:|----------------|
| 48 | `(c)2001 Jarvis Entertainment Group, Inc.` |
| 38 | `(c)2001-2002 Jarvis Entertainment Group, Inc.` |
| 3 | `(c)2002 Jarvis Entertainment Group, Inc.` |
| 1 | `(c)2001 Ikonboard.com <http://www.ikonboard.com>` |
| 1 | `(c)2001-2002 Ikonboard.com <http://www.ikonboard.com>` |
| 88 | *(no copyright line at all)* |

`out_provenance.txt` section 2.3 counts ten distinct header *variants* rather than six
copyright *strings*, because it distinguishes the comment prefix (`#` vs `#|`) and
preserves literal tab characters that got baked into some headers -- e.g. the two
separate entries `# (c)2001-2002 Jarvis Entertainment Group, Inc.` and
`# (c)2001-2002 Jarvis Entertainment\tGroup, Inc.` (`Sources/Admin/LangControl.pm`).
Those tabs are themselves evidence and are discussed in section 2.5.7.

Two observations from the census:

**First, 88 of 179 files -- 49% of the shipped Perl -- carry no copyright notice
whatsoever.** This includes the entire compiled skin (`Skin/Default/*.pm`, 30 files),
eleven of the thirty language files, the whole vendored-third-party set
(`Compress/Zlib.pm`, `Archive/Tar.pm`, `MIME/Base64.pm`, `Mail/Sendmail.pm`,
`Lib/Crypt.pm`, `Lib/MD5.pm`), and -- significantly -- the entire `iDatabase/` layer and
several first-party modules (`Sources/Profile.pm`, `Sources/LogInOut.pm`,
`Sources/Warn.pm`, `Sources/Makelog.pm`, `Sources/iTextparser.pm`). For a product whose
license makes retention of copyright headers a condition of use (section 2.6), half the
codebase has no header to retain. **[INFERENCE]** This is what an incompletely executed
branding pass looks like: someone walked the tree stamping headers onto files, and
stopped -- or never started -- on whole subdirectories.

**Second, two files still say Ikonboard.com.**

#### 2.2 The two survivals

`Sources/Sessions.pm:1-11` **[CODE]**:

```perl
package	Sessions;
use	strict;
#+------------------------------------------------------------------------------------------------------
#| Ikonboard [ v3.0 ]
#|
#| No parts of this script can be used outside Ikonboard without prior consent.
#| You must keep this header intact and all copyright links visable.
#| (c)2001 Ikonboard.com <http://www.ikonboard.com>
#|
#| Please Read the licence for more information.
#+------------------------------------------------------------------------------------------------------
```

`Sources/UserCP/Menu.pm:1-13` **[CODE]** (tabs preserved as they appear in the file):

```perl
package	UserCP::Menu;
use	strict;
#+------------------------------------------------------------------------------------------------------
#| Ikonboard [ v3.1	]
#|
#| No parts	of this	script can be used outside Ikonboard without prior consent.
#| You must	keep this header intact	and	all	copyright links	visable.
#| (c)2001-2002	Ikonboard.com <http://www.ikonboard.com>
#|
#| This	is to generate the interface for the member	profiles and everything	in their control panel.
#|
#| Please Read the licence for more	information.
#+------------------------------------------------------------------------------------------------------
```

These are the last two places in the shipped product that name the previous owner. They
are worth reading closely, because the naive interpretation -- "these files were never
touched after the handover" -- is **wrong**, and the correct interpretation is more
interesting.

Their modification times **[CODE]**:

| File | mtime (UTC) | Version string in header | Copyright year in header |
|------|-------------|--------------------------|--------------------------|
| `Sources/Sessions.pm` | 2002-07-13 21:20:54 | `[ v3.0 ]` | `2001` |
| `Sources/UserCP/Menu.pm` | 2002-06-24 19:10 | `[ v3.1 ]` | `2001-2002` |

`Sessions.pm` is one of the last twenty-five files modified before development stopped
(`out_provenance.txt` section 2.2 lists it third from the end of the 07/2002 run). It was
being actively edited two days before the project went quiet. Its header nonetheless
still says v3.0 and still says Ikonboard.com.

`UserCP/Menu.pm` is stronger evidence still. Its version string **was** bumped from
`v3.0` to `v3.1`, and its copyright year **was** extended from `2001` to `2001-2002` --
but the entity name was left as `Ikonboard.com`. Somebody ran an editing pass over this
file that specifically updated the two numeric fields in the copyright header and did
not update the name in between them.

**[INFERENCE]** The survivals are not fossils of untouched code. They are fossils of an
*incomplete substitution rule*. Whatever pass converted `Ikonboard.com` to `Jarvis
Entertainment Group, Inc.` across the tree missed these two files, while a *different*
and later pass -- a year-bump -- hit at least one of them. The stratigraphy that matters
here is not "which files are old" but "which mechanical edits ran over which files, in
what order." The pipeline that produced Ikonboard 3.1.1 applied several such passes,
and at least one of them was broken (section 2.2.4).

#### 2.3 Mecham's byline, still present

The removal of Mecham's name from file headers was likewise incomplete, and in a way
that establishes far more than the survivals do. Thirteen files in the tree carry an
explicit `Author:` line **[CODE]**:

| File | Author line |
|------|-------------|
| `Sources/iDatabase/SQL.pm:7` | `# Author: Matthew Mecham <matt@ikonboard.com>` |
| `Sources/iDatabase/Driver/Base.pm:7` | `# Author: Matthew Mecham <matt@ikonboard.com>` |
| `Sources/iDatabase/Driver/CSV.pm:9` | `# Author: Matthew Mecham <matt@ikonboard.com>` |
| `Sources/iDatabase/Driver/DBM.pm:7` | `# Driver Author: Matthew Mecham <matt@ikonboard.com>` |
| `Sources/iDatabase/Driver/mySQL.pm:7` | `# Driver Author: Matthew Mecham <matt@ikonboard.com>` |
| `Sources/iDatabase/Driver/Oracle.pm:7` | `# Driver Author: Andrey Prokopenko <faceless@ortv.ru>` |
| `Sources/iDatabase/Admin/a_base.pm:7` | `# Author: Matthew Mecham <matt@ikonboard.com>` |
| `Sources/iDatabase/Admin/a_DBM.pm:7` | `# Author: Matthew Mecham <matt@ikonboard.com>` |
| `Sources/iDatabase/Admin/a_mySQL.pm:7` | `# Author: Matthew Mecham <matt@ikonboard.com>` |
| `Sources/iDatabase/Admin/a_pgSQL.pm:7` | `# Author: Matthew Mecham <matt@ikonboard.com>` |
| `Sources/iDatabase/Admin/a_Oracle.pm:7` | `# Author: Matthew Mecham <matt@ikonboard.com>` |
| `Sources/iPerl/mod_perl.pm:3` | `# by: Matthew Mecham` |
| `Sources/Admin/SQLclient.pm:18` | `# Script Author: Nurlan Mukhanov <webmaster@tourist.kz> (Infection)` |
| `Sources/Upgrade.pm:15` | `# Script Author: Phil Gengler (LrdChaos) <lrdchaos@codeallday.com>` |

Eleven of those thirteen are Mecham's, and ten of them are the `iDatabase` layer -- the
SQL abstraction that is the single largest architectural difference between 2.1.9 and
3.x (`out_delta.txt` section 2.4 lists `Sources/iDatabase/` first among features with no
2.1.9 counterpart). Every driver but the Oracle one is his. The base class is his. The
admin-side schema handlers are his.

And `Sources/iDatabase/Driver/CSV.pm:4-14` dates it **[CODE]**:

```perl
################################################################
#
# iDatabase v1.0 (May 2001)
#
# Developed for Ikonboard.
# Author: Matthew Mecham <matt@ikonboard.com>
#
# Accessor methods to databases
#
# CSV: Interface to text files.
#
################################################################
```

**iDatabase v1.0, May 2001.** An in-code date stamp, from the author, on the layer that
defines Ikonboard 3's architecture. All five drivers still declare `$VERSION = 1.0` in
July 2002 -- `Driver/CSV.pm:19`, `Driver/DBM.pm:29`, `Driver/mySQL.pm:26`,
`Driver/Oracle.pm:25`, `Driver/pgSQL.pm:15`. Fourteen months of iB3 development did not
produce an iDatabase 1.1.

The installer's byline is even more explicit. `installer.cgi:4-21` **[CODE]**:

```perl
####################################################
#| Ikonboard by Matthew Mecham [ v3.0 ]
#|
#| No parts of this script can be used outside Ikonboard
#| without prior consent.
#| You must keep this header intact and all copyright
#| links visable.
#|
#| (c)2001 Jarvis Entertainment Group, Inc.
#| Web: <http://www.ikonboard.com>
#| Email: ib@ikonboard.com
#| IRC Server   : irc.ikonboard.com
#|     Port     : #6667
#|     Channels : #ikonboard   #help   #support
#|
#| Please Read the licence for more information.
####################################################
```

"Ikonboard by Matthew Mecham [ v3.0 ]" over "(c)2001 Jarvis Entertainment Group, Inc."
The authorship line and the ownership line sit two lines apart and disagree about who
made the thing. This is the handover in a single header: the work is Mecham's, the
copyright is Jarvis's, and nobody reconciled the two before shipping.

One more, purely for flavor -- `Sources/Admin/SkinControl.pm:1103` **[CODE]** contains
the comment:

```perl
	# A Mecham Sanity-Saver (TM)
```

Someone else's joke about a departed colleague's coding idiom, preserved in a shipping
commercial product.

#### 2.4 The botched rebranding pass

`Sources/Admin/Index.pm:1-17` **[CODE]**:

```perl
package	Admin::Index;
use	strict;
#################################################################################
# Ikonboard v3 by Jarvis Entertainment Group, Inc.
#
# No part of this script can be used outside Ikonboard without prior consent.
#
# Mor2001-e information available from <ib-license@jarvisgroup.net>
# (c)2002 Jarvis Entertainment Group, Inc.
#
# http://www.ikonboard.com | http://www.jarvisgroup.net
#
# Please read the license for more information
#
#
#
#################################################################################
```

Line 8 should read "More information available from". It reads `Mor2001-e`. The literal
string `2001-` has been inserted between the `r` and the `e` of the word "More."

This is a mechanical edit, not a typo: no human types `Mor2001-e`. An automated
find-and-replace inserted a year fragment into an English word in the middle of a
comment block, and the result shipped. **[INFERENCE]** The most economical explanation
is a copyright-year update pass (of the kind that produced the `2001` -> `2001-2002`
change visible in `UserCP/Menu.pm`) whose search pattern was under-anchored and matched
inside prose. I cannot recover the exact rule from a single surviving instance, and I
will not guess at one. What the artifact establishes on its own is sufficient and
important:

- The Jarvis-era headers were applied and maintained by **automated bulk edits**, not by
  hand.
- Those edits were **not reviewed**. A corrupted word sat in the header of the admin
  control panel's entry-point module through at least one release and one repackaging.
- Combined with the 88 header-less files and the two Ikonboard.com survivals, this
  places the reliability of header-based provenance in this tree fairly low. Headers in
  Ikonboard 3.1.1 are evidence of *which passes ran over a file*, not of who wrote it.

#### 2.5 Two header dialects

Splitting the 179 Perl files by comment prefix **[CODE]**:

| Style | Count | Examples |
|-------|------:|----------|
| `#\|` pipe-rule box, `#+---+` delimiters | 35 | `ikonboard.cgi`, `installer.cgi`, `Sources/Lib/FUNC.pm`, `Sources/Lib/ADMIN.pm`, `Sources/Sessions.pm`, `Sources/UserCP/Menu.pm`, `Sources/Memberlist.pm`, `Sources/Help.pm`, all seven `install_modules/*.pl`, nineteen `Languages/en/*Words.pm` |
| `#` plain box, `####...####` delimiters, with `ib-license@jarvisgroup.net` | 77 | all of `Sources/Admin/*` except the survivals, `Sources/Calendar.pm`, `Sources/NotePad.pm`, `Sources/iPoll.pm`, `Sources/Massmsend.pm`, `Search/api.pm` |
| none | 67 | entire `Skin/Default/`, entire `iDatabase/`, all vendored third-party |

The pipe style is the older dialect. Both Ikonboard.com survivals use it; Mecham's
installer byline uses it; the core libraries `Lib/FUNC.pm` and `Lib/ADMIN.pm` use it.
The plain style is the later one and is the only style that carries the license contact
address `ib-license@jarvisgroup.net`.

The transition is not clean -- `install_modules/functions.pm:3-14` is a hybrid, pipe-rule
delimiters with the Jarvis license line inside **[CODE]**:

```perl
#+-----------------------------------------------------------------+
#| Ikonboard v3 by Jarvis Entertainment Group, Inc.
#|
#| No parts of this script can be used outside Ikonboard without prior consent.
#|
#| More information available from <ib-license@jarvisgroup.net>
#| (c)2001 Jarvis Entertainment Group, Inc.
#| 
#| http://www.ikonboard.com
#|
#| Please Read the license for more information
#+-----------------------------------------------------------------+
```

**[INFERENCE]** The dialect boundary correlates with, but does not cleanly separate,
pre- and post-handover authorship. Ten files carry the hybrid form, which is what you
get when a bulk pass rewrites the *content* of a header without normalizing its *frame*.
Treat the dialects as an ordering signal -- pipe is earlier -- and not as an attribution.

#### 2.6 The corporate address, and one anomaly

`license.html` states its manufacturer **[DOC]**:

```
Jarvis Entertainment Group, Inc.
Ikonboard Solutions
14435 FM 2920
Tomball, TX 77375

Phone: 434-352-9311

World Wide Web: http://www.ikonboard.com
E-mail: support@ikonboard.com
```

2.1.9's license states **[DOC]** (`ib219/license.html:172`):

```
Ikonboard.com, 69 Brockhurst Road, Gosport, Hants, PO12 3AR, UK
World Wide Web: http://www.ikonboard.com
E-mail: support@ikonboard.com
```

A residential street address in Gosport, Hampshire becomes a farm-to-market road in
Tomball, Texas, and a division name -- "Ikonboard Solutions" -- appears. The governing law
changes from the United Kingdom to the United States (section 2.6.3), and the nominal
damages cap changes currency from `U.K. GBP 1.00` to `U.S. $1.00`.

The anomaly: **the phone number is not a Texas number.** Area code 434 is central
Virginia (Lynchburg / Charlottesville / Danville), created in 2001 from a split of 804.
Tomball, Texas is in the Houston metro, area codes 281/713/832. The license lists a
Texas mailing address with a Virginia telephone number.

**[EXTERNAL]** Invision Power Services, Inc. -- the company Matt Mecham and Charles
Warner founded after leaving Jarvis Entertainment Group -- is listed in business
directories at Forest, Virginia, with a 434 telephone number. **[EXTERNAL]** Mecham
himself, in an interview published 04/22/2004, describes the arrangement at IPS as
himself in the UK and Warner in the US.

**[INFERENCE]** The Virginia number on a 12/18/2001-dated Jarvis license document is
consistent with Ikonboard's US-facing operation being run out of central Virginia by
staff who subsequently founded IPS there, with Tomball serving as the parent company's
address of record. I could **not confirm** this. I found no document tying the specific
number 434-352-9311 to any named person, and no source stating where JEG's Ikonboard
division physically sat. The area-code mismatch is a fact; the explanation is a guess,
and it should be labeled as one wherever it is repeated.

---

### 2.3 Matt Mecham's departure

#### 3.1 The primary sources in the box

Ikonboard 3.1.1 ships two documents signed by Matt Mecham. Both are datable, both are
in the distribution, and together they bracket his tenure under Jarvis ownership from
the inside.

**`Tools\HELP\read_me.txt`** -- the instructions for `perl_test.cgi`, the environment
checker a new installer runs first. Complete text **[DOC]**:

```
Ikonboard Perl Tester
---------------------

This script will be able to tell you if you have perl running, and the sufficient modules to run it.
It will also try to determine the path name, and the sendmail path for you.


Instructions
------------

Simply upload the script into the directory you wish to install Ikonboard into.
Check with your webhost for the correct CHMOD value for executing perl scripts (usually 755).
Chmod the script to that value, and call it through your web browser (http://www.yourdomain.com/perl_test.cgi)

If the script outputs as text, you do not have perl installed in that directory.

That's it!

--Matt Mecham (<matt@ikonboard.com>)

29/5/01
```

Signed and dated `29/5/01` -- day-first, British convention, i.e. **05/29/2001**. The
file's mtime is `2001-05-29 23:13` and `perl_test.cgi` alongside it is
`2001-05-29 23:10` **[DOC]**. The document's internal date and the filesystem agree to
within three minutes of each other on the same day. This is as clean a primary source as
an archive gets.

**`Upgrading\iB2-iB3_Upgrading\Read_Me.txt`** -- the iB2-to-iB3 migration guide, 139
lines, mtime `2001-08-30 04:31` **[DOC]**. It ends:

```
<< END OF FILE>>
$ Matt - 30 August 2001, 2:16am
```

The signature line reads `2:16am`; the file's UTC mtime is `04:31`. **[INFERENCE]** A
two-hour-fifteen-minute gap between "I finished writing this" and "this file was last
written" is exactly what you expect from British Summer Time (UTC+1) plus a save, or
from a copy step during packaging. It is not a discrepancy; it is a timezone.

The document's content matters as much as its date. It is written in the first person
plural of someone speaking *for the vendor*, about a product that is *not finished*
**[DOC]**:

```
Please keep in mind that Ikonboard is still in BETA, this means that there
are probably still bugs, and some features are not fully working. We are
currently using an Ikonboard 3 BETA as our support board, so we know that
there are no major issues.
```

and about vendor support channels he speaks for **[DOC]**:

```
> GETTING SUPPORT
.................................................................

The first stop for Ikonboard related help is our support board (http://forums.ikonboard.com).

We are setting up a help channel on our IRC server. You may want to visit 'irc.ikonboard.com' on
port number '6667' and join channel #support.

Alternatively, you can email us on "support@ikonboard.com" - please note that it may take up
top 48 hours to get a response during busy periods.
```

("up top 48 hours" -- sic.) The IRC server, port, and channel here are the same ones
still printed in the header of `ikonboard.cgi` fifteen months later. Mecham was
documenting Jarvis-era Ikonboard's support infrastructure on 08/30/2001.

**What this bounds.** On 08/30/2001 Matt Mecham was still writing official Ikonboard 3
documentation, speaking as the vendor, describing iB3 as a beta in production use on the
company's own support board. Whatever else is true about his departure, it had not
happened yet. The distribution also proves it in a second way: this Read_Me and its
three redirect scripts (`Tool_Box/ikonboard.cgi`, `forums.cgi`, `topic.cgi`, all
`2001-08-30 04:31`) were still shipping *inside the 3.1.1 box* in November 2002, more
than a year later, unrevised -- the vendor never rewrote the migration guide after he
left.

#### 3.2 The external record

**[EXTERNAL]** The sequence, from encyclopedia entries and one first-person interview:

- Ikonboard originated as freeware by Matt Mecham, first release 0.9 beta in **September
  1999**, written in Perl with flat-file storage, operating from ikondiscussion.com.
- A server crash in **March 2001** prompted a move to ikonboard.com.
- In **late April 2001**, "Ikonboard officially joined the Jarvis Network." Mecham sold
  the software to Jarvis Entertainment Group. The consideration is reported as **50,000
  shares of common stock**, which he later said he was unable to liquidate.
  **[UNVERIFIED]** I could not find a primary document -- SEC filing, press release, or
  statement by Mecham -- for the 50,000-share figure. It appears in encyclopedia entries
  without a citation I could follow. Treat the number as folklore until someone produces
  the paper.
- Ikonboard 3.0 was released under Jarvis. A JEG corporate news item headlined **"JEG
  Release iB3; The Leader in Community Building Software"** is dated **2001-11-05** in
  the sidebar of every ikonboard.com page captured in 2002 **[ARCHIVE]**
  (`web.archive.org` capture of `www.ikonboard.com`, 10/17/2002 and 12/10/2002).
- **February 2002**: Invision Power Services created by Charles Warner and Matt Mecham,
  both formerly of Jarvis Entertainment Group.

The last point has a first-person source. In an interview published **04/22/2004**
**[EXTERNAL]**, Mecham states that he and Warner both worked for the company that
acquired Ikonboard, were dissatisfied with its direction, left, and that "Invision Power
Services was created in February 2002." He describes starting IBForums -- later renamed
Invision Power Board -- because "I felt I still had some more to add to the bulletin
board market and as we had a lot of experience in that area it seemed the sensible thing
to do," and notes that IPS funded itself on hosting packages while IPB was under
development.

**[EXTERNAL]** Invision Power Board was PHP and MySQL from the start. Early 1.x releases
were free downloads under a proprietary license; free non-commercial releases ended in
2004. IPB "quickly gathered a community of former Ikonboard users."

#### 3.3 The bracket

Combining the shipped documents with the external record:

```
1999-09  Ikonboard 0.9 beta.  Mecham, Perl, flat files.          [EXTERNAL]
2001-03  ikondiscussion.com server crash; move to ikonboard.com. [EXTERNAL]
2001-04  Ikonboard joins the Jarvis Network. Sale to JEG.        [EXTERNAL]
2001-05  iDatabase v1.0 written.  Author: Matthew Mecham.        [CODE]  CSV.pm:6
2001-05-29  perl_test read_me signed "--Matt Mecham ... 29/5/01" [DOC]
                                                                  <-- HERE, still vendor
2001-08-30  iB2->iB3 migration guide signed "$ Matt - 30 August 2001, 2:16am"
                                                                  [DOC]
                                                                  <-- HERE, still vendor
2001-11-05  JEG announces iB3 release.                           [ARCHIVE]
2002-02  Invision Power Services founded, Warner + Mecham.       [EXTERNAL]
                                                                  <-- gone by HERE
2002-06-12  JEG releases Ikonboard 3.1.                          [ARCHIVE]
```

**The departure falls in the window 08/30/2001 to 02/2002**, a span of about five
months, bounded on the left by a document in the box and on the right by Mecham's own
account of when IPS was created. I could not narrow it further. No source I found gives
a resignation date, a last-commit date, or a public announcement.

**[INFERENCE]** The `iDatabase` timestamps offer a soft additional bound. The layer
carries a May 2001 authorship stamp and every driver still reports `$VERSION = 1.0`
fourteen months later, in a release that added a full per-backend *search* API on top of
it (`Sources/Search/API/api_DBM.pm`, `api_mySQL.pm`, `api_pgSQL.pm`, `api_Oracle.pm`).
The team built above Mecham's database layer without revising it. That is consistent
with its author no longer being available to revise it, and inconsistent with him being
around and active through 3.1. It is not proof -- plenty of stable code goes untouched --
but it is the shape you would predict.

The counter-observation, for honesty: `Sources/Sessions.pm` and `Sources/Lib/FUNC.pm`,
both pipe-dialect and both plausibly Mecham-era in origin, *were* modified on
07/13/2002. So the older strata were not frozen. Someone was editing them; that someone
just was not updating their headers.

#### 3.4 Invision Power Board as the successor

**[EXTERNAL]** Invision Power Board is the direct descendant of Mecham's work in the
sense that matters -- same author, same problem domain, same user base, same market
position -- but it is not a fork. IPB was written in PHP against MySQL; Ikonboard 3 is
Perl. The continuity is in the person and the community, not the source tree.

The chronology puts IPB and Ikonboard 3.1 in the market simultaneously in 2002, with the
Ikonboard user base as the contested ground and with IPB on the technology stack that
would win (section 2.9.3). **[EXTERNAL]** IPB's later releases: 3.0 on 06/23/2009, 4.0.0
officially 04/09/2015; the product line continues as Invision Community. The Perl
Ikonboard line ended in 2010 (section 2.9.1).

---

### 2.4 The development team of iB3

#### 4.1 What the source names

Ikonboard 3.1.1 is a *shipped commercial product* that carries inline development
commentary of a kind normally stripped before release. Harvesting every credit-shaped
comment from the tree **[CODE]**:

| File:line | Comment |
|-----------|---------|
| `ikonboard.cgi:342` | `# ( ADDED HERE BY KEVaholic00 FOR BUG FIX #168, COMMENTED OUT BELOW )` |
| `ikonboard.cgi:462` | `# Added by KEVaholic00: member notepads` |
| `ikonboard.cgi:464` | `# Added by Camil: Newest post` |
| `Database/config/member_profiles.cfg:51` | `# added by kevaholic00` |
| `Languages/en/MessengerWords.pm:23` | `# added by camil` |
| `Languages/en/MessengerWords.pm:145` | `# Added by Infection` |
| `Languages/en/ModerateWords.pm:13` | `## Added by Camil for event calendar` |
| `Languages/en/ModerateWords.pm:21` | `## Added by LrdChaos 5/7/02 for watched topic` |
| `Languages/en/ProfileWords.pm:56` | `# Added by Infection` |
| `Languages/en/TopicWords.pm:22` | `# added by camil for topic streamlining` |
| `Languages/en/TopicWords.pm:29` | `## added by LrdChaos 5/7/02 for topic watch` |
| `Languages/en/TopicWords.pm:32` | `## end added by LrdChaos 5/7/02` |
| `Languages/en/UserCPWords.pm:6` | `# added by kevaholic00 for "post font color in userCP"` |
| `Languages/en/UserCPWords.pm:10` | `# added by kevaholic00` |
| `Skin/Default/Menu.cfg:195` | `# added by Infection` |
| `Skin/Default/MenuView.cfg:369,381` | `<!-- added by kevaholic00 -->` ... `<!--/added by kevaholic00 -->` |
| `Skin/Default/MenuView.pm:8,485,497` | `# added by kevaholic00` / HTML comment pair |
| `Skin/Default/ModCPView.cfg:356` | `<!-- added by kevaholic00 -->` |
| `Skin/Default/ModCPView.pm:4,21,1077` | `# added by kevaholic00` x2, HTML comment |
| `Sources/Admin/Category.pm:535,538` | `# Added by LrdChaos 4/8/02` ... `# End added by LrdChaos` |
| `Sources/Admin/Category.pm:695,698` | `# Added by LrdChaos 4/8/02` ... `# End added by LrdChaos` |
| `Sources/Admin/Convert_ib.pm:1210` | `# Thanks to 'joshdw1' for his assistance, time and server to fix this up.` |
| `Sources/Admin/Menuadmin.pm:203-204` | `<!-- Changes for "Web Ring" -->` `<!-- by KEVaholic00 -->` |
| `Sources/Admin/ModControl.pm:447,800` | `## End added by LrdChaos 5/7/02` |
| `Sources/iDatabase/Driver/Base.pm:25` | `# Routine written by Nurlan ("infection")` |
| `Sources/iTextparser.pm:78,237,372` | `# added by kevaholic00` x3 |
| `Sources/iTextparser2.pm:78,237,372` | `# added by kevaholic00` x3 |
| `Sources/Lib/FUNC.pm:214` | `# This routine written by "Infection"` |
| `Sources/Lib/FUNC.pm:598` | `#AM/PM SUFFIX bug fix by Freakboy` |
| `Sources/ModCP.pm:1955` | `# added by kevaholic00` |
| `Sources/Register.pm:218` | `# added by Infection` |

Handles recovered from the source: **KEVaholic00** (by far the most prolific),
**Camil**, **LrdChaos**, **Infection**, **Freakboy**, **joshdw1**.

Two of the credit lines carry dates in American format -- `LrdChaos 4/8/02` and
`LrdChaos 5/7/02`, i.e. 04/08/2002 and 05/07/2002 **[CODE]**. These sit two months and
five weeks respectively before the 06/12/2002 release of 3.1, and are the only
hand-written dates anywhere in the source.

#### 4.2 Real names, recovered from module headers

Three contributors are named in full inside the shipped code **[CODE]**:

`Sources/Admin/SQLclient.pm:18` -- the in-browser SQL client:
```perl
# Script Author: Nurlan Mukhanov <webmaster@tourist.kz> (Infection)
```

`Sources/Calendar.pm:8` -- the event calendar:
```perl
# Script author: Nurlan	Mukhanov (Infection) Modif.	by Camil
```

`Sources/iDatabase/Driver/Oracle.pm:7` -- the Oracle backend:
```perl
# Driver Author: Andrey Prokopenko <faceless@ortv.ru>
```

`Sources/Upgrade.pm:15` -- the upgrade module:
```perl
# Script Author: Phil Gengler (LrdChaos) <lrdchaos@codeallday.com>
```

`Sources/Warn.pm:183-186` -- POD block, member warning system:
```
=AUTHOR

Phil Gengler
<lrdchaos@codeallday.com>
```

So:

| Handle | Real name | Contact in source | Contributions visible in tree |
|--------|-----------|-------------------|-------------------------------|
| Infection | **Nurlan Mukhanov** | `webmaster@tourist.kz` | `Admin/SQLclient.pm` (whole module, 29 KB), `Calendar.pm` (original author), `Lib/FUNC.pm:214` `htmlcut`, `iDatabase/Driver/Base.pm:25` `make_hash_ref`, `Register.pm:218`, language and skin strings |
| -- | **Andrey Prokopenko** | `faceless@ortv.ru` | `iDatabase/Driver/Oracle.pm` (whole driver, 33 KB) |
| LrdChaos | **Phil Gengler** | `lrdchaos@codeallday.com` | `Upgrade.pm`, `Warn.pm`, watched/subscribed topics (`Admin/ModControl.pm`, `Languages/en/TopicWords.pm`, `Languages/en/ModerateWords.pm`), `Admin/Category.pm` |
| KEVaholic00 | not stated | not stated | member notepads (`NotePad.pm` + dispatcher wiring), avatar upload, post font color, the admin webring, `iTextparser` changes, `ModCP.pm`, skin and language additions, bug fix #168 |
| Camil | not stated | not stated | "Newest post" (`Newest.pm` + dispatcher wiring), event-calendar modifications, topic streamlining, messenger strings |
| Freakboy | not stated | not stated | `Lib/FUNC.pm:598` AM/PM suffix bug fix |
| joshdw1 | not stated | not stated | assistance, time, and a server, for `Admin/Convert_ib.pm` avatar handling |

The `.ru` and `.kz` addresses are worth pausing on. `tourist.kz` is a Kazakhstan domain;
`ortv.ru` is Russian. Ikonboard 3's SQL admin client and its Oracle driver -- two of the
more specialized pieces of infrastructure in the product -- were written by contributors
in the former Soviet Union, contributing to a Texas company's product, in 2002, and
credited by name and email address in the shipping source. **[INFERENCE]** This is a
volunteer open-community pattern, not an employment pattern. No company ships its
employees' personal webmaster addresses in product headers.

#### 4.3 The roster, from the vendor's own site

Two archived captures of ikonboard.com's staff page confirm the handles and date them.

**Capture 06/05/2002** -- one week before the 3.1 release **[ARCHIVE]**
(`web.archive.org/web/20020605064230/http://www.ikonboard.com/?team`). The page's own
framing line:

> Here you will find our current Ikonboard Network **Volunteer** Staff, If you would
> like to be a part of our Ikonboard Network Team please E-Mail us [HERE].

The roster as captured:

```
Administrators
  Gladiator          Gladiator@Ikonboard.com
  Fender             ctraweek@ikonboard.com
  Quasi              itsmequasi@hotmail.com
  Katiou Ace         webmaster@animeglobe.com

Team Leaders
  Brush              Quattlebomb@hotmail.com
  Redbaron           andrews@ikonboard.com
  Malkavian          Malkavian.isupport@ikonboard.com
  Sly                sly.isupport@ikonboard.com
  Benjamin Liger     benjamin@stripeymaney.com
  Wedge              wedge@dualboot.net
  LrdChaos           lrdchaos.idev@ikonboard.com

Development Team
  Camil              ccollard@enter-net.com
  KEVaholic00        kevaholic00@yahoo.com
  JayLittle          N/A
  Jevon              jevon.idev@gta3.com

Support Team
  Ayin, Breadfan, Darkone, David-iB, dnekm, DragoonKain, h1995vn, LSGN,
  Semper Lizardo, Victor von Steiner, Z3RO 0, jimrobnor, Physbird

iBH Leader
  Leosite

iBH Staff
  Eergassie, jamble, omega13a, wbagent, BGF, Britishguy87
```

("Katiou Ace" is a typo for Kaitou Ace, corrected in the later capture.)

**Capture 10/17/2002** -- four months later, five weeks before the 3.1.1 repackaging
**[ARCHIVE]** (`web.archive.org/web/20021017024616/http://www.ikonboard.com/?team`):

```
Administrators:        Anarckie, Fender, Kaitou Ace, Quasi
Head of Support:       Cekkent
Team Leaders:          Malkav, Redbaron, Sly, Wedge, Victor von Steiner, Predator668
iB3 Development Team:  Jevon, KEVaholic00 <kevaholic00@yahoo.com>, Porter
                       <porter@jarvisgroup.com>, Pysbird, simonpersson,
                       Sanjeet Ganjam
iB3 Support Team:      Ayin, Breadfan, Darkone, David-iB, h1995vn, Hudzon,
                       jimrobnor, Mark64uk, SKJain, Digikoon
iB2 Support & Dev:     BGF
iBH Leader:            Eergassie, Leosite
iBH Staff:             AlanA, Bram, omega13a, Sevigon, wbagent
iB Skinner:            Akuma, neliconcept, Nimrod44
MYiSupport Team:       Chris Jenkins
```

Cross-referencing against the source-code credits:

| Handle in source | On 06/05/2002 roster | On 10/17/2002 roster |
|------------------|----------------------|----------------------|
| KEVaholic00 | Development Team | iB3 Development Team |
| Camil | Development Team | **absent** |
| LrdChaos | Team Leaders (`lrdchaos.idev@`) | **absent** |
| Infection (N. Mukhanov) | absent | absent |
| Freakboy | absent | absent |
| joshdw1 | absent | absent |

**[INFERENCE]** Of the six people credited by name inside Ikonboard 3.1.1, exactly one
was on the vendor's staff page when 3.1.1 was repackaged. Camil and LrdChaos were
listed in June 2002 and gone by October 2002; Infection, who wrote two whole modules and
two core routines, never appears on either roster. The credits in the source are
therefore not a staff list. They are a *sedimentary record of contributions accepted
over time*, from people who were sometimes on the team and sometimes not.

Note also `porter@jarvisgroup.com` on the October roster -- the only development-team
member with a corporate address. **[INFERENCE]** One paid developer, on a team of six,
in October 2002.

Wikipedia's account of this period names "Sly, Camil, and Quasi" as the community
developers who continued after Mecham left **[EXTERNAL]**. All three are on the June
2002 roster; Camil is the only one of the three credited in the source. The Wikipedia
framing is broadly corroborated by the captures but is imprecise about roles -- Sly and
Quasi were a Team Leader and an Administrator respectively, not the Development Team.

#### 4.4 The public bug tracker

`ikonboard.cgi:342` **[CODE]**:

```perl
# ( ADDED HERE BY KEVaholic00 FOR BUG FIX #168, COMMENTED OUT BELOW )
# Lets add on the skin name for ease of use.
my $images_url                   = $iB::INFO->{'IMAGES_URL'};

$iB::INFO->{'IMAGES_URL'}       .= '/' . $iB::SKIN->{'FULL_DIR'};
# ( END ADDITION )
```

A numbered bug reference in the shipped dispatcher. `out_provenance.txt` section 2.4 finds
exactly one such reference in the tree, so this is not a convention -- it is one person's
habit, applied once.

The tracker it refers to is identifiable. The 06/03/2001 capture of ikonboard.com's
navigation includes a **Bug Tracker** item linking to `http://bugs.ikonboard.com/` and
`http://bugs.ikonboard.com/index.php` **[ARCHIVE]**
(`web.archive.org/web/20010603051912/http://www.ikonboard.com/team/`). The same
navigation offers Support, Forums, Features, Requirements, Member Center,
Documentation, Bug Tracker, Extra's, BBS Convertors, Links, and Chat.

**[UNVERIFIED]** I could not retrieve bug #168 itself. `bugs.ikonboard.com` was a
database-backed PHP application; the Archive's captures of it, if any, would not contain
individual issue pages, and I found none. What #168 *was* is unknown. What the reference
proves is that in 2002 there was a public, numbered, externally addressable bug tracker
whose issue IDs a volunteer developer expected a reader of the source to be able to look
up.

**Why this matters.** A commercial software product that ships with `ADDED HERE BY
<handle> FOR BUG FIX #<n>` in its main executable is telling you its development model
outright:

1. **The bug list was public.** Otherwise the number is meaningless to the reader.
2. **Attribution was social, not legal.** Contributors signed their patches with the
   handles they used on the support forums, in a codebase whose license forbids anyone
   claiming ownership of amended files (section 2.6.2).
3. **Patches went in as patches.** The comment is a diff marker: `ADDED HERE ... ( END
   ADDITION )`. Nobody integrated the change and cleaned up after it; the seam is
   preserved. The same bracketing appears throughout --
   `Languages/en/TopicWords.pm:29,32`, `Sources/Admin/Category.pm:535,538`,
   `Skin/Default/MenuView.pm:485,497`.
4. **There was no code review pass before release.** The seams shipped. So did
   `Mor2001-e` (section 2.2.4).

#### 4.5 The hack-writing SDK, and what it produced

The distribution ships a development kit. `Tools\writing_hacks\module_template.pm`,
mtime `2001-10-30 04:03`, 197 lines, opens **[DOC]**:

```perl
################################################
#
# This is a module template.
# This is designed to give you a head start on creating
# new modules for iB3
#
################################################
```

It is a genuine tutorial -- it walks a novice through package declaration, the `BEGIN`
block that pulls in `Lib/FUNC.pm`, constructing the `FUNC::STD` / `FUNC::Member` /
`FUNC::Output` objects, loading the skin file, writing a subroutine, and wiring the
module into the dispatcher via a `%Mode` hash. It explains Perl to the reader in a
friendly register **[DOC]**:

```perl
    # Although $time_elements looks like a standard (scalar) variable - it is actually
    # "tied" to the subroutines in the module. It works along the same lines as the "new" constuctor
    # we saw.
```
```perl
    # If we wanted to print the year, all we'd need to do is..
    # print $time_elements->year; # This accesses a subroutine called "year" in Time::localtime
    # If we wanted to print the converted hour, we'd need to print...
    # print $time_elements->hour;
    # Seeing a pattern? :D
```

It ends with a stub dispatcher and a POD skeleton **[DOC]**:

```perl
sub FatalError { die "I'm working on it!" }

1;

__END__
=pod

=NAME

Module name here

=DESCRIPTION

What the module does here


=AUTHOR

Your Name Here
<email@email.com>
=cut
```

**Two modules in the shipping product were written from this template and shipped with
the template's boilerplate still in them.** `Sources/Warn.pm` -- the member warning
system, a headline 3.x feature -- is 191 lines and ends **[CODE]**:

```perl
sub	FatalError { die "I'm working on it!" }

1;

__END__
=pod

=NAME

Module name here

=DESCRIPTION

What the module does here


=AUTHOR

Phil Gengler
<lrdchaos@codeallday.com>
=cut
```

Only the `=AUTHOR` field was filled in. `=NAME` still says "Module name here."
`=DESCRIPTION` still says "What the module does here." The tutorial's placeholder error
handler, `die "I'm working on it!"`, is live code in the shipped module at
`Sources/Warn.pm:167`. And `Warn.pm` carries **no copyright header at all** -- the
template did not have one, so the module does not have one.

`Sources/Legends.pm:174` has the same fossil under a different name **[CODE]**:

```perl
sub LegendError { die "I'm working on it!" }
```

**[INFERENCE]** This is the clearest single artifact of the iB3 development model. The
vendor published a "write your own hacks" SDK; a community member wrote a feature with
it; the vendor promoted that feature into the product; and nobody normalized it on the
way in. The boundary between "third-party hack" and "shipped product" was not a boundary
at all -- it was a copy operation. The pipeline that turned a forum post into a product
feature had no editorial step.

The corollary matters for anyone reading this code: **module quality in Ikonboard 3.1.1
is not uniform, and the non-uniformity is structural.** `Sources/iDatabase/` is careful,
documented, versioned, object-oriented code by one author. `Sources/Warn.pm` is a
tutorial exercise with the tutorial comments removed and the tutorial placeholders left
in. Both ship.

#### 4.6 Volunteer work in the product's own furniture

Two further places show community contributions embedded not in a peripheral module but
in the product's core furniture.

**The database schema.** `Database/config/member_profiles.cfg:39-53` **[CODE]** -- this
file *defines the member table*:

```perl
                      "GENDER"              => [38,  'num'   ,    1    ],
                      "MEMBER_NAME_R"       => [39 , 'string',    40,  ],
# added by kevaholic00
                      "POST_FONT_COLOR"     => [40,  'string',    15   ],
# end add

             );
```

A volunteer's column, with his attribution comment, in the shipped schema definition
that every installation's member table is created from.

**The admin menu.** `Sources/Admin/Menuadmin.pm:202-206` **[CODE]**:

```html
			<!-- Changes for "Web Ring" -->
			<!-- by KEVaholic00         -->
			<br>
			<br><span style='color:red'>&gt;</span> <a href='$url?AD=1&act=webring&s=$iB::SESSION' target='BODY'>Manage Web Ring</a>
			<!-- end Changes            -->
```

The webring is a real feature with a real module -- `Sources/Admin/WebRing.pm`, 3,063
bytes, mtime `2002-06-24 05:20`, carrying a full Jarvis copyright header **[CODE]**. A
volunteer's contribution was given a menu item, a module, and a corporate copyright
notice, and his attribution comment survived in the menu markup that renders it.

**Language pack authorship as a UI concept.** `Sources/Admin/LangControl.pm:206-207` and
`Sources/Admin/SkinHandler.pm:154-155` **[CODE]** both build the same pair of required
form fields:

```perl
		$html .= $SKIN->td_input ( TEXT	=> 'Authors	Name',			NAME =>	'AUTHOR_NAME',	VALUE=>	'',	REQ	=> 1);
		$html .= $SKIN->td_input ( TEXT	=> 'Authors	Email Address',	NAME =>	'AUTHOR_EMAIL',	VALUE=>	'',	REQ	=> 1);
```

Language packs and skins are first-class contributable artifacts in Ikonboard 3, with
mandatory author name and email metadata built into the admin UI. The community
contribution model is not incidental to the product; it is a designed-in feature of it.

#### 4.7 What the team thought of the code

The tree preserves unusual candor. `Sources/iPoll.pm:20-30` **[CODE]**:

```perl
# For the future
#
# Make the posting / polling more modular. This whole module is held together with
# duct tape. It works fine, it's just ugly and carries bad coding principles.
#
# Something like:
# Postings/
#	  -- Global.pm  #Hold global posting elements such as the HTML compiler, etc
#	  -- NewPost.pm  # New post routines
#	  -- ReplyPost.pm  #ummm.
```

`Sources/iPoll.pm:138` **[CODE]**:

```perl
	# This is horrible and I hate it.
```

`Sources/iPoll.pm:158` **[CODE]**:

```perl
	# Cut off a piece of duct tape...
```

`Sources/Admin/BoardTemplates.pm:154-155` **[CODE]** -- on the license-enforcement check
described in section 2.6.4:

```perl
    #XXX No doubt someone will remove the <% IKONBOARD %> tag...
    #XXX Then post on the support board that their board has gone 'missing'.
```

`out_provenance.txt` section 2.4 counts 84 comments of this general "unfinished work"
character. The `XXX` prefix, used throughout `Boards.pm`, `Moderate.pm`, `Online.pm`,
`Admin/ForumControl.pm`, and others, is used here as a plain step-marker rather than the
conventional "this is broken" flag -- `XXX Grab all the forums in this main category`,
`XXX DONE!` -- which is itself an idiom mismatch between contributors.

#### 4.8 The tab damage

A physical artifact of multi-contributor editing runs through the tree. Many files
contain literal tab characters *inside string content and inside comment prose*, where
no tab belongs:

```
#| (c)2001 Jarvis Entertainment	Group, Inc.          <- Sources/Help.pm
# (c)2001-2002 Jarvis Entertainment	Group, Inc.      <- Sources/Admin/LangControl.pm
#| Ikonboard [ v3.1	]                                <- Sources/UserCP/Menu.pm
# Script author: Nurlan	Mukhanov (Infection) Modif.	by Camil   <- Sources/Calendar.pm
XXX Skip it if	the	category has already been printed  <- Sources/Boards.pm
```

`out_provenance.txt` reproduces several of these faithfully, which is why its list of
copyright variants has ten entries for six distinct copyright strings.

**[INFERENCE]** Runs of spaces have been converted to tabs by a tool or an editor
setting, indiscriminately, including inside comments and inside quoted HTML. It is the
signature of a file passing through an editor configured differently from the one that
wrote it -- precisely what happens when a patch travels from a contributor's machine
through a forum post or an email to a maintainer's machine. The damage is cosmetic in
Perl (whitespace outside strings is insignificant) but it is a reliable fingerprint of
the distributed authorship model, and it appears in files across every dialect and
every stratum.

---

### 2.5 Licensing

`license.html` in the 3.1.1 distribution has mtime `2001-12-18` -- six months older than
the code it ships with, and roughly six weeks after the 11/05/2001 iB3 announcement. It
is the JEG-era license, written once and reused.

#### 5.1 What 2.1.9's license said

`ib219/license.html` **[DOC]**, in summary and in its own words:

- **Price**: `Personal Use - GBP 0.00 (Free)` / `Commercial Use - GBP 0.00 (Free)`.
- **Copyright removal**: `Cost - GBP 200.00`, contact `matt@ikonboard.com`. "This will
  enable you to remove the copyright information links and link to ikonboard.com present
  in all the .cgi generated files."
- **Condition of use**: "you must ensure that the Ikonboard copyright notice, and links
  back to ikonboard.com are left intact and are visable."
- **Modification**: "You may edit the .cgi files and images as long as you do not break
  any terms of this license." One sentence. No enumerated conditions.
- **Distribution**: free redistribution permitted, with (A) no payment beyond
  distribution costs, (B) "You do not modify it in any way," (C) complete original zip
  archive including the license.
- **Ownership**: non-exclusive license; all rights remain with Ikonboard.com; may not
  modify or translate without written permission.
- **Damages cap**: "the greater of U.K. GBP 1.00."
- **Governing law**: United Kingdom.
- **Manufacturer**: Ikonboard.com, 69 Brockhurst Road, Gosport, Hants, PO12 3AR, UK.

Total document: 179 lines including HTML.

#### 5.2 What 3.1.1's license says

`ib311/license.html` **[DOC]**. The structure is inherited -- the section
headings and much of the boilerplate are the same document -- but every commercially
meaningful clause has been tightened, and four entirely new provisions appear.

**Pricing** (`license.html:13-15,41`):

```
Personal Use - $0.00 (Free)
Commercial Use - $0.00 (Free)

This enables you to use Ikonboard in accordance with this license.
```

```
License Ikonboard without the copyright information

Cost - $250.00

This will enable you to remove the copyright information

links and links to Ikonboard sites present in the output of all the program files. You must still retain all the copyright information intact in all the script

headers. Purchasing a license also entitles you to 30 days of priority technical support. Please email ib-license@jarvisgroup.net for more information or for questions about this license agreement.
```

The software remained **free of charge for both personal and commercial use.** The only
money in the license is the branding-removal fee, which rose from GBP 200 to $250 and
acquired a support entitlement.

Three substantive changes in this clause alone:

1. `link to ikonboard.com` (singular, one destination) became `links to Ikonboard sites`
   (plural, unbounded set) -- see the Copyright Notice clause below, which makes this
   explicit.
2. `present in all the .cgi generated files` became `present in the output of all the
   program files` -- 3.x has no user-visible `.cgi` files besides the dispatcher, so this
   is partly a technical correction, but it also broadens the scope from "generated
   files" to "output."
3. A new carve-out: even after paying $250, **"You must still retain all the copyright
   information intact in all the script headers."** In 2.1.9 the fee removed the links;
   in 3.1.1 the fee removes only the *visible page* branding, and the source headers stay
   regardless. Purchasing does not buy you a de-branded source tree.

**Copyright Notice** (`license.html:69-91`):

```
Ikonboard by Jarvis Entertainment Group, Inc. Copyright (c) 2001 Jarvis Entertainment Group, Inc. ("JEG").

All Rights Reserved.

Ikonboard ("Software") is licensed, not sold to you by

JEG for use only under the terms of this license

agreement.

JEG reserves any rights not expressly granted to

you.

To keep this license valid, you must ensure that the Ikonboard copyright

notice, and link back to ikonboard.com or ikondiscussion.com or any other domains existing in the software at time of download are left intact

and are visible. You must also keep all the copyright information intact in all the script

headers, and in the scripts themselves.
```

Note the clause `or any other domains existing in the software at time of download`.
This is a forward-looking hook: whatever hostnames JEG chose to embed in a future build
would automatically become protected links. In the shipped 3.1.1 that set includes eight
distinct network hosts in the admin panel alone (section 2.7). **[INFERENCE]** The license
was drafted to make the Network Links block legally load-bearing.

Note also `ikondiscussion.com` -- the pre-March-2001 home of the project **[EXTERNAL]** --
still named in a December 2001 license by a Texas company. Another survival of the
Mecham era in a document that ostensibly replaced it.

**The open-source disclaimer** (`license.html:97`), entirely new:

```
Although Ikonboard is not open source software, you are granted permissions to edit the program files and images as long as you do not break any terms of this license. Please review the Ikonboard Use section of this license for specific details on modification permissions.
```

2.1.9 never used the phrase. By late 2001 "open source" had enough market meaning that a
free-of-charge proprietary product needed to explicitly disclaim it -- and the disclaimer
sits in the *Copyright Notice* section, ahead of any grant of rights.

**Ikonboard Use** (`license.html:205-229`) -- where 2.1.9 had one sentence, 3.1.1 has a
six-clause enumeration:

```
You may use the Ikonboard on one or more

computers, or on a network, for an unlimited time, unless you break

any agreements you make in accordance with this license.

JEG reserves the right to terminate your license at any

time for any or no reason.

You are granted permission to make modifications to the Software as long as:

  * You do not modify this license in any way
  * You do not claim ownership of any ammended files
  * You do not remove any copyright notices including but not limited to; the copyright in the HTML output and the copyright in the source files
  * You do not distribute the ammended files without written permission from JEG
  * You do not change the 'ikonboard' name in any of the source files or generated URLs
  * You accept that modifying the code does not grant you any copyrights or intellectual rights other than your own statutory rights
```

("ammended" -- sic, twice.)

Read against section 2.4 of this chapter, these six bullets describe the iB3 development
community's legal position exactly:

- *You do not claim ownership of any ammended files* -- a volunteer who writes
  `Sources/Warn.pm` does not own `Sources/Warn.pm`.
- *You accept that modifying the code does not grant you any copyrights* -- restated, in
  case the first pass was ambiguous.
- *You do not distribute the ammended files without written permission from JEG* -- the
  ibhackers.com hack database (section 2.7.2) operated at the vendor's pleasure. Every
  "hack" distributed there was, strictly read, a licensed-file derivative requiring
  written permission.
- *You do not change the 'ikonboard' name in any of the source files or generated URLs*
  -- no rebranding, no forks-by-rename.

Also new: **"JEG reserves the right to terminate your license at any time for any or no
reason."** 2.1.9 said "Ikonboard.com reserve the right to terminate your license at any
time." The addition of "for any or no reason" is a deliberate strengthening.

**Distribution** (`license.html:147-185`) -- 2.1.9's clause (B) was `You do not modify it
in any way`. 3.1.1's is:

```
 B) You do not distribute a version of the Software modified in any way.

 C) You distribute it with its complete, original, unmodified archive, including this unmodified license file.
```

The prohibition moved from *modifying* to *distributing a modified version*, which is
the correct drafting -- modification is separately permitted under Ikonboard Use -- and
"original zip archive" became "original, unmodified archive," matching a distribution
that ships `.tar` files rather than zips.

**Ownership** (`license.html:265-287`), with a new final sentence:

```
Ikonboard reserves the right to publish the users of its software at its discretion without prior notification to the user.
```

No equivalent exists in 2.1.9. **[INFERENCE]** This is a marketing-reference clause: it
lets JEG name your board as a customer without asking. Read alongside the archived
ikonboard.com front page's boast of "well over 1 million active boards operating across
the web" **[ARCHIVE]**, its purpose is legible.

**Warranty and law** (`license.html:333-345,457`):

```
In cases where the above limitations do not apply by law

JEG's liability to you for actual damages for any cause

whatsoever, and regardless of the form of the action, will be

limited to the greater of U.S. $1.00.
```

```
This license agreement shall be governed by the laws of the United States of America.
```

2.1.9's damages cap was `U.K. GBP 1.00` and the governing law was the United Kingdom. Note
also what was *dropped*: 2.1.9 contained a consumer-protection acknowledgement -- "Because
some countries do not allow the exclusion of limitation of liability for consequential or
incidental damages, the above limitations may not apply to you" -- which does not appear
in 3.1.1.

**Term** (`license.html:367-375`), with a new final sentence:

```
This license is only applicable to the software it was packaged with. Future or previous versions of Ikonboard may have ammended license agreements and will fall under that agreement.
```

A version-pinning clause. Nothing in this license binds JEG's future terms -- which
matters, given what IPS would do to its own license in 2004 **[EXTERNAL]** and given
Ikonboard's own subsequent ownership churn (section 2.9.1).

#### 5.3 The comparison, tabulated

| Provision | 2.1.9 (06/2001) | 3.1.1 (license dated 12/18/2001) |
|-----------|-----------------|----------------------------------|
| Owner | Ikonboard.com | Jarvis Entertainment Group, Inc. ("JEG") |
| Address | Gosport, Hampshire, UK | Tomball, Texas, USA ("Ikonboard Solutions") |
| Governing law | United Kingdom | United States of America |
| Damages cap | U.K. GBP 1.00 | U.S. $1.00 |
| Personal use | GBP 0.00 | $0.00 |
| Commercial use | GBP 0.00 | $0.00 |
| De-branding fee | GBP 200.00 | $250.00 |
| De-branding contact | `matt@ikonboard.com` | `ib-license@jarvisgroup.net` |
| De-branding includes support | no | yes, 30 days priority |
| De-branding removes source headers | (silent) | **no, explicitly** |
| Protected links | "links back to ikonboard.com" | ikonboard.com, ikondiscussion.com, **"or any other domains existing in the software at time of download"** |
| "Not open source" stated | no | **yes** |
| Modification permissions | one sentence | **six enumerated conditions** |
| Ownership of contributed code | (silent) | **assigned away -- "You do not claim ownership of any ammended files"** |
| Redistribute modified files | (implicitly barred) | **barred without written permission from JEG** |
| Rename the product | (silent) | **barred, source and URLs** |
| Termination at will | "at any time" | "at any time **for any or no reason**" |
| Publish list of users | (silent) | **yes, without notification** |
| Cross-version applicability | (silent) | **this license binds only this package** |
| Consumer-protection carve-out | present | **removed** |
| Document length | 179 lines | 508 lines |

**Assessment.** The headline economics did not change: Ikonboard 3.1.1 was free, for
everyone, forever, exactly as 2.1.9 was. What changed is the *legal architecture around*
the free product. The 3.1.1 license is a professionally hardened proprietary
freeware license with a paid branding-removal tier, an at-will termination right,
assignment of contributor rights, a publicity right over its users, and a hook that
automatically extends its link-retention obligations to whatever hostnames the vendor
embeds in a build. 2.1.9's license reads as written by a developer; 3.1.1's reads as
written for a company that intended to monetize an installed base.

The commercial posture is real but it is a *posture*, not revenue from licensing. The
money in the JEG Ikonboard business was elsewhere: hosting, a hosted-forum service, and
merchandise (section 2.7).

#### 5.4 How the license is enforced in code

The license's central obligation -- visible copyright, intact links -- is not left to
honor. It is implemented.

**The copyright emitter.** `Sources/Lib/FUNC.pm:893` **[CODE]** -- one line, in the core
output library, that every page passes through:

```perl
	my $ib_copy = qq~<!-- iB Copyright Information -->\n\n<p><table width='80%' align='center' cellpadding='3' cellspacing='0'><tr><td align='center' valign='middle' id='copyright'>$BCopy<br>Powered by <a href="http://www.ikonboard.com" class="copyright" target='_blank'>Ikonboard</a> $iB::VERSION &copy; 2002 <a href='http://www.ikonboard.com' target='_blank'>Ikonboard</a></td></tr></table><p>~;
```

Two links to ikonboard.com and the version string, in a table with a dedicated CSS id
(`id='copyright'`) so a skinner can style it but not easily excise it. `$BCopy`, built at
`FUNC.pm:847`, is the *board owner's* copyright line, placed above the vendor's -- a small
courtesy that also makes the block harder to justify deleting.

Compare 2.1.9 **[CODE]** (`ib219/cgi-bin/ikon.lib:862`):

```
        Powered by <a href="http://www.ikonboard.com">Ikonboard $versionnumber</a><br>&copy; 2001 Ikonboard.com
```

Same idea, no CSS hook, and -- telling -- `ikon.lib:874` and `settemplate.cgi:187` still
carry a `&copy; 2000 Ikonboard.com` variant, so 2.1.9 emitted two different copyright
years from three different places. 3.1.1 emits one string from one function.

**The template guard.** The admin panel lets an operator write the whole page wrapper
(`Sources/Admin/BoardTemplates.pm`). To stop that from being used to drop the board -- and
its copyright block -- out of the page, the code requires a placeholder token
`<% IKONBOARD %>` in any submitted template and refuses to save without it
(`BoardTemplates.pm:154-158`) **[CODE]**:

```perl
    #XXX No doubt someone will remove the <% IKONBOARD %> tag...
    #XXX Then post on the support board that their board has gone 'missing'.
    #XXX Here goes nothing...
    unless ($template =~ m#<% IKONBOARD %>#) {
        $ADMIN->Error( DB=>,"", STD=>"", MSG => "You need the &lt;% IKONBOARD %&gt; tag!");
```

The check exists for functional reasons -- without the token there is nowhere to inject
the board -- but its effect is that the license's "left intact and are visible"
requirement has a mechanical floor. **[INFERENCE]** The comments make clear the authors
were thinking about operators removing it, not about license compliance; the compliance
effect is a by-product.

**What the $250 buys.** Since the fee removes only page-output branding, and the source
headers must stay regardless, the practical deliverable of a paid license is: edit
`Sources/Lib/FUNC.pm:893` and delete the `$ib_copy` table. That is the transaction. The
sum of the commercial licensing product, at the code level, is one line.

---

### 2.6 The commercial network

#### 6.1 The block

`Sources/Admin/Menuadmin.pm:112-144` **[CODE]** -- verbatim, indentation as it appears in
the file:

```perl
	my $nineteen = $open_menus->{'19'} ? undef : qq(#19);
	$html .= qq~
			 <tr>
			  <a href='$url?AD=1&act=menuadmin&s=$iB::SESSION&CODE=edit&m=19$nineteen' class='title' name='19'>
			  <td id='title' valign='middle' align='left' width='100%'>
			  <a href='$url?AD=1&act=menuadmin&s=$iB::SESSION&CODE=edit&m=19$nineteen' class='title' name='19'>Network Links
			  </td>
			  </a>
			 </tr>
	~;

	if ($open_menus->{'19'}) {
		$html .= qq~
		   <tr>
			<td valign='middle' align='left' width='100%'>
			<font class='item'>
			<br><span style='color:red'>&gt;</span> <a href='http://members.ikonboard.com/admin_guide/' target='BODY'>iB AdminCP Help</a>
			<br><span style='color:red'>&gt;</span> <a href="http://help.ikonboard.com/popup/faq.php" target='BODY'>ikonboard FAQ</a>
			<br><span style='color:red'>&gt;</span> <a href='http://forums.ikonboard.com' target='BODY'>iB Support Forums</a>
	<br><span style='color:red'>&gt;</span> <a href='http://members.ikonboard.com' target='BODY'>iB Member Center</a>
		
<br>
<br><span style='color:red'>&gt;</span> <a href='http://hosting.jarvisgroup.net' target='_blank'>Jarvis Hosting</a>

	<br><span style='color:red'>&gt;</span> <a href='http://ibskins.ikonboard.com' target='_blank'>iBSkins</a>
	<br><span style='color:red'>&gt;</span> <a href='http://www.ibhackers.com' target='_blank'>iBHackers</a>
	<br><span style='color:red'>&gt;</span> <a href='http://www.myikonboard.com' target='_blank'>myIkonboard</a>

</font>
			</td>
		   </tr>
		~;
	}
```

Menu group 19 -- the highest-numbered group in the admin sidebar, and rendered *first*,
above Moderate (group 1). The vendor's network occupies the top of every administrator's
control panel.

Look at the indentation. Lines 113-131 are tab-indented consistently with the rest of the
file. Line 132 is a stray tab and two spaces. Lines 133-140 begin at **column zero** --
`<br>`, `<br><span...>Jarvis Hosting`, then three more at one tab. The heredoc's contents
were hand-edited by someone pasting links in, without matching the surrounding style, in
at least two separate sittings (the four `.ikonboard.com` support links are one
indentation cohort; Jarvis Hosting, iBSkins, iBHackers, and myIkonboard are another).

Note the mixed quoting on line 129 too -- `href="http://help.ikonboard.com/popup/faq.php"`
uses double quotes where every other link in the block uses single quotes. Another
paste.

**This file is the newest thing in the distribution**: mtime `2002-11-25 05:05:08` UTC
(late evening 11/24/2002 US Central), against a `Sources.tar` archive mtime of
`2002-11-24 06:38:48` -- the content is *newer than the archive that contains it* by 22h
26m 20s (`out_provenance.txt` section 2.1). The tarball was built, then this one file was
replaced inside it, then the box shipped. **[INFERENCE]** Whatever this last edit was, it
was worth breaking a completed build for, four months after development had otherwise
stopped. Given that the file's distinguishing content is the network-links block, and
given that the block's indentation shows it was assembled by paste in multiple passes,
the most economical reading is that the final act of Ikonboard 3.1.1's production was
updating the vendor's cross-promotion links. I cannot prove it -- there is no second copy
of `Menuadmin.pm` in the tree to diff against.

For completeness, `Menuadmin.pm` is also listed in `Upgrading\to_3.1.1 from 3.1.0\readme.txt`
among the files updated between 3.1.0 and 3.1.1 **[DOC]**, so the file was already a
moving part.

#### 6.2 The eight destinations

What each host was, with evidence:

**`members.ikonboard.com` -- iB Member Center.** A registration-gated download and support
portal. Archived 12/13/2002 **[ARCHIVE]**:

> Welcome to the Ikonboard Member Center. This service uses the Ikonboard Support Forums
> database for your convenience. Before attempting to login, you must have an account
> registered on the forums. [...] **Download without Registering** -- If you wish to
> download Ikonboard without registering you may do so here. If you do not register, you
> will not be able to take advantage of the support tickets or other special services
> available by registering on the community forums and logging into this member center.

A lead-capture funnel around a free product, with an explicit opt-out. Single sign-on
against the support forum's member table -- which is to say, against an Ikonboard.
`members.ikonboard.com/admin_guide/` (the first link in the block) was the admin control
panel documentation; `Upgrading\to_3.1.1 from 3.1.0\readme.txt` step 4 instructs the
operator to unpack `admin_CP.zip` into `iB_html/non-cgi/help_admin` **[DOC]**, so the
admin guide also shipped locally.

**`help.ikonboard.com/popup/faq.php` -- ikonboard FAQ.** A PHP application. Ikonboard's
own support FAQ ran on PHP while the product it documented was Perl. **[INFERENCE]** Not
significant on its own -- plenty of shops mix -- but see section 2.9.3, and see the
06/12/2002 JEG headline "Jeg releases ib 3.1; **ibp php releases to follow**"
**[ARCHIVE]**.

**`forums.ikonboard.com` -- iB Support Forums.** The community support board, running
Ikonboard. Named as the first line of support in Mecham's 08/30/2001 Read_Me **[DOC]** and
in `Install_Guide.html` **[DOC]**, which also mentions "live support" and "fill out a
support ticket."

**`hosting.jarvisgroup.net` -- Jarvis Hosting.** The parent company's web hosting
business, cross-sold in the admin panel of its free forum software. On the ikonboard.com
front page it appears as a rotating page sponsor: "Page Sponsor: Looking for reliable web
hosting? Try J-HOST!" **[ARCHIVE]** (12/10/2002). This is the only non-`ikonboard.com`
host in the block that is unambiguously a revenue product.

**`ibskins.ikonboard.com` -- iBSkins.** Skin distribution. The 12/10/2002 ikonboard.com
front page describes it **[ARCHIVE]**:

> IB Skins - Customize your Ikonboard with a variety of different looks from gaming skins
> to professional skins. Our users and our dedicated staff create some of the most amazing
> skins on the 'Net for your Ikonboard.

The 10/17/2002 staff roster has a dedicated "iB Skinner" group (Akuma, neliconcept,
Nimrod44) **[ARCHIVE]**. My attempt to retrieve an `ibskins.ikonboard.com` capture
returned an IIS 404 page, so I have the vendor's description but not the site itself.

**`www.ibhackers.com` -- iBHackers.** The modification community, on a separate domain.
Archived 10/10/2003 **[ARCHIVE]**, tagline "iB Hackers [ the place ib developers come for
answers ]". It ran a categorized **Hack Database** -- for 3.1.x, 3.0.x, and 2.x.x, split
into Addons, Design/Layout, Admin CP, Bug Fixes/Patches, Converters, Languages, Skins --
plus a Graphic Database (Avatars, Pips, Post Buttons, Smilies), and a forum with 5,914
topics and 20,147 replies (26,061 total posts) at time of capture. The vendor's own
description **[ARCHIVE]** (12/10/2002):

> IB Hackers - An improved front page, with the addition of plug-in support, improved
> features, flexible database abstraction layer, themes and a new script database. This
> is where our users come to showcase their technical knowledge of Ikonboard.

The Ikonboard staff page carried "iBH Leader" and "iBH Staff" groups on both the
06/05/2002 and 10/17/2002 rosters **[ARCHIVE]** -- iBHackers was a vendor-operated
property on an arm's-length domain, not an independent fan site.

**[INFERENCE]** This is the institutional counterpart to `Tools\writing_hacks\`
(section 2.4.5) and to the license's modification clauses (section 2.5.2). The vendor
supplied an SDK, hosted the resulting hacks in a categorized database, staffed the site,
and reserved the legal right to absorb any of it. `Sources/Warn.pm` is what that pipeline
looks like from the far end.

**`www.myikonboard.com` -- myIkonboard.** A hosted-forum SaaS. Archived 12/04/2003
**[ARCHIVE]**:

> MyIkonboard is a remotely hosted interactive community version of industry leader
> Ikonboard, the forum software used by millions on web sites across the globe. Don't have
> your own server? No problem! [...] (Average setup 2min.)
>
> **Proudly Hosting 31353 PHP MyIkonboards!**

Tiering: free ad-supported; "MyIB - Economy" at $5.99/month to remove ads and unlock
skinning; "MyIB - Pro" at $14.95/month for "a fully-featured **Ikonboard v3.0.2** on your
own iB Pro server."

Two things in that copy are important. First, this -- not license fees -- is where the
recurring revenue was. Second, **"31353 PHP MyIkonboards"**: the free tier of the
vendor's own hosted service was running a **PHP** codebase in December 2003, while the
paid tier ran Perl Ikonboard 3.0.2. **[EXTERNAL]** This corresponds to "Project
Mongoose," a commissioned PHP rewrite begun in 2002-2003 which reached release-candidate
stage and whose developers departed in February 2003 without a public release; a version
of it is reported to have powered myIkonboard. The 12/04/2003 capture is consistent with
that. **[UNVERIFIED]** I could not confirm the Mongoose-to-myIkonboard connection from a
primary source; the capture proves only that myIkonboard's free tier was PHP.

**The block's absences.** The admin panel does *not* link to two properties that the
public site did: `store.ikonboard.com` ("iB Gear" -- merchandise) and `win.ikonboard.com`
(hosting "BanX" and a chat service). Both appear in the ikonboard.com navigation on
10/17/2002 and 12/10/2002 **[ARCHIVE]**. **[INFERENCE]** T-shirts did not make the
administrator's sidebar; hosting, skins, hacks, and the SaaS did.

#### 6.3 Jarvis Entertainment Group itself

The archived ikonboard.com sidebar is the best surviving portrait of the parent company,
and it is not what a reader expects behind a forum script. Every page in 2002 carried
**[ARCHIVE]** (12/10/2002 capture):

```
Stock Information
JRVE  [ news ] [ price ]

Corporate News
  Jeg releases ib 3.1; ibp php releases to follow      June 12 2002, 08:53am
  Jarvis network news 5/21/02                          May 21 2002, 08:30am
  Jarvis corporate 5/20/02                             May 20 2002, 09:49am
  Jarvis network 5/03/02                               May 03 2002, 11:15am
  Jrve news 2/19/02                                    Feb. 19 2002, 08:14am

  Jarvis Entertainment CEO Featured on Wall Street Reporter; Windows to Wall
    Street Interviews to Air Beginning December 10th             2001-12-10
  Jarvis Entertainment Group Acquires Controlling Interest in COMC; Approves
    Manufacturing Division Spin-Off and Dividend                 2001-12-06
  Jarvis Entertainment Announces Film Production Deal for ``The Turnaround'';
    Pre-Production Underway                                      2001-11-21
  JEG Release iB3; The Leader in Community Building Software     2001-11-05

Jarvis Network Traffic For Last Hour - This Site: 590 - Entire Network: 19,762
```

The stock links point to `pinksheets.com/quote/quote.jsp?symbol=jrve` **[ARCHIVE]**. JEG
was a **publicly traded company quoted on the Pink Sheets under JRVE**, whose corporate
news in the fifteen months around Ikonboard 3's release consisted of a film production
deal, a controlling-interest acquisition with a manufacturing-division spin-off and
dividend, a CEO media appearance, and -- one item among them -- the release of a forum
script.

The myIkonboard capture a year later carries a longer JEG news list **[ARCHIVE]**
(12/04/2003): "Jarvis Entertainment completes acquisition of RET," "JRVE Level II
Quotes," "Jarvis Broadband goes residential," "Lights....Camera.....Action," "Dlink
Releases Case Study On Jarvis," "Jarvis Inks Deal with Sprint," "BEACON Community Network
completed," "The Sun, the Beach, the Wi-Fi," "D-Link's AirPlus Powers Largest Texas
WiFi."

**[INFERENCE]** Jarvis Entertainment Group was a small-cap diversified holding company --
film, broadband, municipal Wi-Fi, hosting, acquisitions -- with Ikonboard as one asset in
a portfolio, valued substantially for the traffic and the installed base it represented.
The "Jarvis Network Traffic For Last Hour" counter on every Ikonboard page (19,762
network-wide, 590 for ikonboard.com in the 12/10/2002 capture) is the metric of a company
that thought of its properties as an audience. The 50,000-shares-of-common-stock story
about Mecham's sale (section 2.3.2, unverified) is at least *consistent* with this profile:
a company that paid in paper. **[EXTERNAL]** Mecham is reported to have said he could not
liquidate the shares.

**[EXTERNAL]** John D. Jarvis was president and CEO. JEG is reported to have been founded
in 1999.

#### 6.4 The block's real function

Assembling sections 2.5 and 6: the license makes the retention of "any other domains
existing in the software at time of download" a condition of the free license
(`license.html:87`); the admin panel embeds eight such domains at the top of every
administrator's sidebar (`Menuadmin.pm:128-138`); those domains lead to a hosting
business, a paid SaaS, a merchandise-adjacent community, and a lead-capture download
portal; and removing the visible branding costs $250.

**[INFERENCE]** The Network Links block is the business model, rendered. Ikonboard 3.1.1
was not sold. It was distributed free to acquire administrators, and administrators were
the funnel into hosting, myIkonboard subscriptions, and network traffic for a public
company that reported traffic to shareholders. That the last edit anyone made to the
product was to this block is, on this reading, exactly right.

---

### 2.7 Vulnerabilities and reputation

#### 7.1 The premise, corrected

The best-known remote code execution issue in Ikonboard 3.1.1 is **not** in the search
functionality and **not** in the DBM backend. It is in the *language-file loader*, in the
core library, and it is triggered by a cookie. There is a separate, later, and less
severe SQL injection issue in search (section 2.7.5). I checked for a search/DBM RCE
specifically and found none; the searches returned only the cookie issue and the SQL
injection. **[UNVERIFIED]** If a search/DBM RCE advisory for Ikonboard exists, I did not
locate it, and nothing in this chapter should be read as confirming one.

#### 7.2 The bug, in the shipped source

`Sources/Lib/FUNC.pm:180-209` **[CODE]**, complete and verbatim with line numbers:

```perl
180	############################################################
181	# LoadLanguage:
182	# Simply loads the required language file from disk, based on
183	# the users language choice
184	############################################################
184	sub	LoadLanguage {
185		my ($obj, $area) = @_;
186		my ($lang, $default);
187		local $@;
188	
189		# Make sure the cookie data is legal
190		if ($iB::COOKIES->{$iB::INFO->{'COOKIE_ID'}.'lang'}) {
191			$iB::COOKIES->{$iB::INFO->{'COOKIE_ID'}.'lang'} =~ s/^([\d\w]+)$/$1/;
192		}
193	
194		$default = $iB::COOKIES->{$iB::INFO->{'COOKIE_ID'}.'lang'}
195				|| $iB::INFO->{'DEFAULT_LANGUAGE'}
196				|| 'en';
197	
198		# Quick check to make sure the directory exists
199	
200		unless (-d $iB::INFO->{IKON_DIR}."Languages/$default") {
201			$default = 'en';
202		}
203	
204		my $code = 'require '. "\"$default/" .$area. '.pm"; $lang ='. $area. '->new();';
205		eval $code;
206	
207		$obj->cgi_error("Could not access the language file: $@") if $@;
208		return $lang;
209	}
```

Line 191 is the defect, and the comment above it at line 189 states the intent that the
code does not achieve:

```perl
	# Make sure the cookie data is legal
		$iB::COOKIES->{...'lang'} =~ s/^([\d\w]+)$/$1/;
```

`s/^([\d\w]+)$/$1/` is a **substitution that replaces a matched string with itself.** If
the cookie contains only word characters, the pattern matches and the value is replaced
by an identical value: no change. If the cookie contains anything else -- a slash, a null
byte, a semicolon, a quote -- the pattern **fails to match**, the substitution does
nothing, and the original hostile value survives untouched. The author appears to have
believed that a `s///` with an anchored capture acts as a filter. It does not. It is a
no-op in both branches. Perl reports the failure only through the operator's return
value, which is discarded.

The tainted value then flows, with no further validation, into:

- line 194: `$default`, unconditionally, because a non-empty string is truthy;
- line 200: a filesystem path -- the only real check in the routine, an `-d` directory
  test, which is bypassable in Perl 5 of this era by embedding a null byte so that the
  C-level `stat()` sees a truncated path;
- line 204: **a string that is passed to `eval`** at line 205.

`$default` is interpolated into `'require ' . "\"$default/" . $area . '.pm"; $lang =' ...`
and the resulting Perl source is executed. Attacker-controlled bytes reach a Perl string
`eval`. That is arbitrary code execution as the web server user, from an unauthenticated
HTTP request, requiring only a crafted `Cookie:` header.

The same idiom appears a second time in the same file, ninety lines earlier --
`Sources/Lib/FUNC.pm:99-104` **[CODE]**:

```perl
sub	LoadSkin {
	my $obj = shift;

	my $sid = $iB::IN{'sid'} || $iB::COOKIES->{$iB::INFO->{'COOKIE_ID'}.'skin'};
	# Make sure it only contains a number
	$sid =~ s/^(\d+)$/$1/;
```

Identical mistake, identical comment structure ("Make sure it only contains a number"),
identical no-op. **[INFERENCE]** This was not a slip. It was a *believed-correct
sanitization idiom* used by whoever wrote this library, applied at least twice, and
neither instance was caught in review -- because, per section 2.4.4, there was no review.

`Sources/Lib/FUNC.pm` was last modified `2002-07-13 23:47:08` **[CODE]**, two days before
the last development activity in the tree. The vulnerable line shipped in that state.

#### 7.3 Disclosure

**[EXTERNAL]** The timeline, from the mailing-list archives themselves:

| Date | Event |
|------|-------|
| **01/26/2003** | **Nick Cleaton** notifies the vendor ("The Jarvis Group / ikonboard.com"). |
| **04/02/2003** | Cleaton discloses publicly on the **vuln-dev** list: "IkonBoard v3.1.1: arbitrary command execution." Fix status: **"None available."** Affected: "IkonBoard 3.1.1 and likely earlier versions." He includes a proof-of-concept that triggers a syntax error in the `eval` -- demonstrating injection -- and deliberately withholds a working exploit. |
| **04/03/2003** | **Adam Gilmore** follows up on vuln-dev. He had previously expressed skepticism, noting that "a require fails on a directory and causes an error, preventing the rest of the code being evalled." He now reports having found a bypass and a working proof of concept that "can execute arbitrary perl." |
| 05/13/2003 | **Ikonboard 3.1.2 released** -- *not* a fix for this issue (section 2.7.6). |
| **09/2003** | Cleaton re-reports on **Bugtraq**: "IkonBoard 3.1.2a arbitrary command execution." Same vulnerability, still present in the successor release. He publishes the patch (below) himself, plus an `ibfix.cgi` to apply it and a detector script. |
| 09/2003 | A **working proof-of-concept exploit** is posted to Bugtraq for 3.1.1 and 3.1.2a, dumping the target server's environment variables. The payload uses null bytes and URL encoding to escape the expected context and inject Perl. |
| **09/22/2003** | **CVE-2003-0770** published. |

Dates in this table are the coordinator-verified figures checked against NVD and the
original advisories. Note that the archived list postings display in the poster's local
time: the seclists rendering of Cleaton's disclosure reads "April 1, 2003" and Gilmore's
follow-up reads "Friday, April 4, 2003, 07:48:50 **+1000**" -- Australian eastern time,
i.e. 04/03/2003 21:48 UTC. The verified 04/02 and 04/03 dates are the ones to cite; the
one-day spreads are timezone rendering, not disputed facts.

Cleaton's September patch **[EXTERNAL]** targets precisely the two lines identified in
section 2.7.2 -- and the correspondence with this tree is exact:

- *Line 104*: replace `$sid =~ s/^(\d+)$/$1/;` with
  `$sid =~ s/^(\d+)$/$1/ or die 'invalid sid value';`
- *Line 192*: replace with
  `$iB::COOKIES->{$iB::INFO->{'COOKIE_ID'}.'lang'} =~ s/^([\d\w]+)$/$1/ or die 'invalid lang cookie value';`

The fix is to *use the return value of the substitution* -- exactly the thing the original
code discards.

**Line 104 in Cleaton's 3.1.2a patch is line 104 in this 3.1.1 tree**, character for
character. The `lang` line is 192 in his 3.1.2a and 191 here -- a one-line offset.
**[INFERENCE]** Between 3.1.1 and 3.1.2a, `Sources/Lib/FUNC.pm` gained exactly one line
somewhere between line 104 and line 191, and nothing in that region was otherwise
restructured. The security-relevant part of the core library was, functionally,
unchanged across two releases and eight months.

#### 7.4 CVE-2003-0770

**[EXTERNAL]** Confirmed identifier and details:

| Field | Value |
|-------|-------|
| CVE | **CVE-2003-0770** |
| Published | **09/22/2003** |
| Description | `FUNC.pm` in IkonBoard does not properly cleanse the `lang` cookie when it contains illegal characters, allowing remote attackers to execute arbitrary code when the cookie is inserted into a Perl `eval` statement. |
| Affected | IkonBoard 3.1.2a and earlier, explicitly including 3.1.1 |
| CVSS v2 | 7.5 |
| Exploit | Exploit-DB 22499 and 22500, "IkonBoard 3.1 - Lang Cookie Arbitrary Command Execution (1)" and "(2)" |
| Bugtraq | `marc.info/?l=bugtraq&m=106381136115972&w=2` |

The vulnerability also acquired an IDS signature. **[EXTERNAL]** Juniper's IPS signature
database carries `HTTP:CGI:IKONBOARD-BADCOOKIE`, describing an IkonBoard 3.1.1 arbitrary
command execution issue "due to insufficient sanitization performed on user supplied
cookie data." A vulnerability with a vendor IDS signature named after it is a
vulnerability that was being exploited in volume.

#### 7.5 The rest of the record

**[EXTERNAL]** Other identifiers associated with Ikonboard 3.1.x, with the confidence I
can attach to each:

| CVE | Confirmed | Detail |
|-----|-----------|--------|
| **CVE-2003-0770** | **yes** | The `lang` cookie RCE. `FUNC.pm` does not cleanse the `lang` cookie before it reaches a Perl `eval`. Affects **"IkonBoard 3.1.2a and previous versions,"** so 3.1.1 included. Published 09/22/2003. Exploit-DB 22499, 22500. See above. |
| **CVE-2002-0328** | **yes** | Cross-site scripting via JavaScript in an `[img]` tag. Originally reported against **Ikonboard 3.0.1**; **also reported against 3.1.1**. This is the parent of CVE-2002-2230. |
| **CVE-2002-2230** | **yes** | A variant of CVE-2002-0328: XSS via a **private message** containing a `javascript:` URL inside an IMG tag where the URL ends in `.gif` or `.jpg` -- defeating naive extension-based filtering of image URLs. **Affects 3.1.1.** Severity medium. **[UNVERIFIED]** publication date. |
| **CVE-2002-2231** | **partly** | XSS in **Ikonboard 3.1.1** -- arbitrary script or HTML injected via a `javascript:` URL in a **photo URL** or in an **X-Forwarded-For header**. I have the description from a CVE aggregator; **I could not confirm the publication date** or the original advisory. |
| **CVE-2004-1406** | **yes** | SQL injection in `ikonboard.cgi` in **Ikonboard 3.1.0 through 3.1.3**, via the `st` or `keywords` parameter. Published 12/31/2004 by NVD. High severity. References Secunia advisory 13513 and SecurityFocus BID 11982. **This is the search-related issue** -- `keywords` is the search input -- and it is SQL injection, not code execution, and it postdates 3.1.1's life by two years. |
| **CVE-2001-0076** | context only | `register.cgi` in Ikonboard **2.1.7b and earlier**. Predecessor product; noted for completeness. |

The `X-Forwarded-For` vector in CVE-2002-2231 is directly visible in this tree.
`ikonboard.cgi:317` **[CODE]**:

```perl
$iB::IN{'IP_ADDRESS'} = $ENV{'HTTP_X_FORWARDED_FOR'} || $ENV{'REMOTE_ADDR'};
```

followed at line 324 by:

```perl
($iB::IN{'IP_ADDRESS'}) = $iB::IN{IP_ADDRESS} =~ /^(.+?)(,|$)/;
```

A client-controlled header is preferred over the socket peer address, and the only
processing applied is splitting on the first comma -- `(.+?)` matches anything. The
resulting "IP address" is displayed to moderators and administrators. That is a plausible
mechanism for the reported XSS, and it is present at `ikonboard.cgi:317,324` in the
shipped 3.1.1 dispatcher. **[INFERENCE]** -- I am matching a CVE description to code, not
citing an advisory that names these lines.

#### 7.6 3.1.2, and the end of 3.1.1's life

**[ARCHIVE]** The release date of 3.1.2 comes from the iBHackers front page, captured
10/10/2003, in a news post by staff member David-iB:

> **New staff member!**
> Posted on May 14 2003, 04:14 by David-iB
> I would like to welcome skelm to the iBH team. He will be involved in a specialised
> project for the ibh community, althrough details will not be released at this moment in
> time. For thoes of you who don't know, **ib3.1.2 was released yesterday!**

Posted 05/14/2003 in board-local time, referring to "yesterday" -- **Ikonboard 3.1.2 was
released on 05/13/2003**. This is the operational end of 3.1.1's life: after this date,
3.1.1 is the superseded version.

**3.1.2 was not a fix for CVE-2003-0770, and nothing in this chapter should be read as
saying it was.** That is worth stating flatly, because it is the natural assumption -- a
release six weeks after a public RCE disclosure looks like a response -- and it is false.
Cleaton's September 2003 Bugtraq post is titled "IkonBoard 3.1.2a arbitrary command
execution," names 3.1.1 **and** 3.1.2a as affected, and supplies the two-line patch
himself **[EXTERNAL]**. The CVE's own affected-version string is "IkonBoard 3.1.2a and
previous versions." The successor release, and the point release after *that*, both
shipped the vulnerable `FUNC.pm`.

The interval, laid out:

```
01/26/2003   vendor privately notified                        day 0
04/02/2003   public disclosure; fix status "None available"   +  66 days
05/13/2003   3.1.2 ships -- still vulnerable                  + 107 days
09/2003      3.1.2a still vulnerable; the REPORTER publishes
             the two-line patch, an ibfix.cgi to apply it,
             a detector script, and a working exploit         + ~225 days
09/22/2003   CVE-2003-0770 assigned                           + 239 days
```

**Eight months from private notification to a public fix -- and the fix was written by the
person who reported the bug, not by the vendor.** In that window the vendor shipped two
releases and carried the patch in neither.

**[INFERENCE]** Read against sections 2.4 and 6, this is what a volunteer development
organization with an absentee corporate owner looks like under security pressure. All the
apparatus of a supported commercial product was in place -- a staffed support forum, a
hack database, a roster of a dozen, a public bug tracker at `bugs.ikonboard.com`, live
support and support tickets advertised in `Install_Guide.html`, and a $250 license tier
that explicitly bundled "30 days of priority technical support." None of it converted a
private, correct, reproducible pre-authentication RCE report into a patched release in
eight months. On the October 2002 roster, one of six developers had a company email
address (section 2.4.3); whether anyone still held responsibility for the core library by
2003 is not something the surviving record answers.

The consequences compound. A cookie-triggered pre-auth RCE, with a published working
exploit and a vendor IDS signature named after it
(`HTTP:CGI:IKONBOARD-BADCOOKIE`, section 2.7.4), in a product whose entire value
proposition was *free and easy to install on cheap shared hosting*, attacks the product
exactly where it lives: shared-host operators blacklist software that gets their servers
rooted, and an IDS signature makes that blacklisting automatic and durable. This is the
most damaging single event in the product's reputation, and it lands in 2003 -- the year
*before* the corporate reorganization, litigation, outages, and team departures that
section 2.8 describes. By the time the ownership churn began, the software's security
standing was already spent.

#### 7.7 Why the architecture invited it

Two design decisions in Ikonboard 3 made this class of bug likely, and both are visible
in the tree:

**1. Dynamic `require` driven by user input.** Ikonboard 3's skin, language, and module
loading are all string-built and `eval`'d or `require`'d at runtime. `LoadLanguage`
(`FUNC.pm:204`) builds Perl source from `$default`. The dispatcher does the same thing
by design -- `ikonboard.cgi:475` region, described in its own comment **[CODE]**:

```perl
    # Nice little hack to save writing loads of subroutines for each action.
    # It builds the code from the %Mode hash, depending on the contents of 'act'
    # For example, the eval'd code may look like:

    #      use Topic;
```

The dispatcher's version is safe because `%Mode` is a fixed hash and unknown `act` values
are forced to `BoardIdx` (`ikonboard.cgi:473-474`). `LoadLanguage`'s version is unsafe
because its input is a cookie. Same idiom, opposite outcomes, in the same codebase -- the
pattern was house style, and its safety depended entirely on whoever applied it
remembering to constrain the input.

**2. `use strict` at 74%.** `out_delta.txt` section 2.1 records the jump from 0% of 2.1.9's
files to 74% of 3.1.1's. That is a large improvement and it is genuine. But `FUNC.pm` --
the file with both no-op sanitizers -- does declare `use strict`, which is the point:
`use strict` catches typos and symbolic references. It does not catch a substitution
whose return value you ignore. The modernization of Ikonboard 3 was real at the level of
Perl hygiene and did not extend to input validation. `out_taint.txt` in this teardown
examines that gap systematically; this chapter only notes that the most-cited
vulnerability in the product's history sits in a `use strict` file, three lines below a
comment asserting that the data has been made legal.

---

### 2.8 The end

The decline has two clocks running at once, and they should be read together. The
**technical** clock is section 2.7: by September 2003 the vendor had been sitting on a
reported pre-authentication remote code execution bug in its flagship product for eight
months, had shipped two releases without fixing it, and had been overtaken by the
reporter, who published the patch and then the exploit. The **corporate** clock starts
five months later, in February 2004, and is the subject of this section. The order
matters: Ikonboard's security reputation was spent *before* the ownership churn began,
not because of it. What follows compounded an existing problem rather than creating one.

#### 8.1 Ikonboard after 2002

**[EXTERNAL]** The ownership history, from encyclopedia sources. I flag it as such
because the primary documentation for most of it is gone with the sites:

| Date | Event |
|------|-------|
| 02/2004 | Jarvis Entertainment Group renamed / reorganized as **Westlin Corporation**. |
| 2004-2005 | Litigation: former JEG chairman **John Jarvis** moves to reclaim ownership of Ikonboard. Server outages and staff departures follow. The development team leaves to pursue **Infinite Core Technology**. |
| 10/2005 | "Ikonboard releases were no longer available for download." |
| 10/28/2005 | Ownership formally transferred to **John Jarvis**; parent company becomes **Pitboss Entertainment**; site down until December. |
| 01/30/2006 | **Ikonboard 3.1.3** released -- the first release in nearly three years. Adds a "Human Readable Image" CAPTCHA on registration and an admin update center. |
| 03/2006 | Assets consolidated under **Level 6 Studios**. |
| 02/2006 | 3.1.4, bug fixes. |
| 05-06/2006 | 3.1.5. (Sources differ: 05/30/2006 or 06/02/2006. **[UNVERIFIED]**) |
| 09/10/2006 | "The Ikonboard Team" -- the volunteers behind 3.1.3-3.1.5 -- depart en masse and found **IkonForums**, releasing IkonForums 1.0.0 in September 2006. Ikonboard 3.2, in development since 2003, is abandoned. |
| 09/2009 | Ownership passes to **Joshua Johnson**, via **Ikonboard Services Inc.** Johnson had been managing the site on Jarvis's behalf. |
| 06/2010 | **3.1.5a** -- minor updates and bug fixes. The last release of the Perl Ikonboard. |
| 2017 | "As of January 2017 no new software releases have been made, and their website has ceased to exist." |

The pattern from 2004 onward is the same one twice: a volunteer team carries the product,
the corporate layer above it destabilizes, the team leaves and forks. Infinite Core
Technology in 2004-2005; IkonForums in 2006. Each time, the people who actually wrote the
code walked away from the company that owned it -- which is, note, precisely what Mecham
and Warner did in February 2002. Ikonboard lost its development capability three times in
five years for the same structural reason.

**[EXTERNAL]** A PHP successor was promised repeatedly and never publicly delivered. The
12/10/2002 JEG headline read "Jeg releases ib 3.1; **ibp php releases to follow**"
**[ARCHIVE]**; "Project Mongoose" (2002-2003) produced "Ikonboard PHP" and "iBPLite"
release candidates whose developers left in February 2003; myIkonboard's free tier ran a
PHP codebase by December 2003 **[ARCHIVE]**; no PHP Ikonboard was ever released to the
public. The company announced the transition to the winning platform in mid-2002 and
spent the rest of its existence not completing it.

#### 8.2 The domain, decaying

The Internet Archive's index for `ikonboard.com` gives a mechanical picture of the end
**[ARCHIVE]** (CDX query, `url=ikonboard.com`, collapsed to one capture per month):

```
2001-03 .. 2007-12   HTTP 200, regular monthly captures     -- live site
2008                  (gap)
2009-09 .. 2014-12   HTTP 200, intermittent                  -- live site
2015-01-01 10:28:32  HTTP 200                                -- LAST 200 with content
2015-02 .. 2017-12   HTTP 302 / warc revisit, every capture  -- redirecting
2018-01 .. 2018-05   HTTP 200                                -- content again
2018-07 .. 2018-09   HTTP 500                                -- broken
2018-10 .. 2021-03   HTTP 302 / revisit                      -- redirecting
```

The 01/01/2015 capture is still a real Ikonboard site **[ARCHIVE]**:

> **Ikonboard** -- Comprehensive web bulletin board system.
>
> Ikonboard is a comprehensive web bulletin board system. It transcends the limits imposed
> by other bulletin board software, allowing you to create a truly unique community on
> your website. From it's extensive template and skins features to it's complete
> multi-lingual support, your members will keep coming back for more.
>
> With well over 1 million active boards operating across the web and over 50,000
> downloads in the past few months, Ikonboard is truly one of the most popular systems
> available.
>
> (c) 2014 Ikonboard

Three navigation items -- About, Download, Features -- and a "No categories" stub from an
empty CMS. The marketing copy claiming a million active boards and 50,000 recent downloads
was, by 2015, thirteen years stale; the last release had been in 2010 and the last major
one in 2006. The 01/2018 capture is the identical page with the copyright bumped to
"(c) 2018 Ikonboard" **[ARCHIVE]**, then the site 500s in mid-2018 and stops resolving to
content thereafter.

**[INFERENCE]** The site did not die; it was left running. A hosting bill kept a
three-page brochure and a copyright-year script alive for years after the software behind
it had stopped existing. The 302s from 02/2015 and again from late 2018 indicate the
domain being pointed elsewhere; the brief 2018 return of the same page suggests it
changed hands and was restored, not revived. **[EXTERNAL]** The domain is reported to
have moved away from Ikonboard Services Inc. and Joshua Johnson's control after 2017.

#### 8.3 The extinction of the Perl-CGI forum

Ikonboard 3.1.1 was released into the last months of its entire software category's
viability. The dates line up almost too neatly:

| Date | Event | Stack |
|------|-------|-------|
| 1997 | WWWThreads first written | Perl **[EXTERNAL]** |
| 1999 | Ikonboard 0.9 beta | Perl, flat file **[EXTERNAL]** |
| 1999 | Infopop UBB.classic dominant on large sites | Perl, flat file **[EXTERNAL]** |
| 02/2000 | Limm and Percival begin **vBulletin** -- explicitly because UBB, "written in Perl using a flat-file database, could not always cope with the number of users they had" | PHP + MySQL **[EXTERNAL]** |
| 2000 | WWWThreads PHP version | PHP **[EXTERNAL]** |
| 2000 | **YaBB** released | Perl **[EXTERNAL]** |
| 2000 | vBulletin 1 released commercially by Jelsoft after Infopop declines to buy it | PHP + MySQL **[EXTERNAL]** |
| 2001 | Infopop acquires WWWThreads, renames it **UBB.threads** | PHP **[EXTERNAL]** |
| 04/2001 | Ikonboard sold to JEG | Perl **[EXTERNAL]** |
| **04/04/2002** | **phpBB 2.0.0 "Super Furry"** released after 14 months of work | PHP + MySQL **[EXTERNAL]** |
| **02/2002** | Invision Power Services founded; IPB written from scratch | PHP + MySQL **[EXTERNAL]** |
| **06/12/2002** | **Ikonboard 3.1** released; "ibp php releases to follow" | **Perl** **[ARCHIVE]** |
| 2002 | **YaBB forked to YaBB SE**, a complete PHP rewrite, later renamed **Simple Machines** | Perl -> PHP **[EXTERNAL]** |
| 07/15/2002 | Ikonboard 3.1.1 development ends | Perl **[CODE]** |
| 11/24/2002 | Ikonboard 3.1.1 repackaged | Perl **[CODE]** |

Every serious competitor either started on PHP+MySQL or migrated to it between 2000 and
2002. vBulletin exists *because* Perl+flatfile did not scale. YaBB's own community forked
it to PHP in 2002 and that fork became Simple Machines Forum. Infopop moved UBB to PHP.
phpBB 2.0 -- free, PHP, MySQL -- shipped ten weeks before Ikonboard 3.1.

Ikonboard 3 is, in this light, a very good answer to the wrong question. Its
architectural achievements are exactly the ones the category needed and it delivered them
in the losing language: a real database abstraction with five backends including MySQL,
PostgreSQL, and Oracle (`Sources/iDatabase/`); server-side sessions (`Sources/Sessions.pm`);
templates compiled to Perl for speed (`Skin/*.cfg` -> `*.pm`); mod_perl support
(`Sources/iPerl/mod_perl.pm`); a per-backend search API (`Sources/Search/API/`);
attachments, member groups with permission masks, ARC4-encrypted stored credentials
(`out_delta.txt` sections 2.4 and 5). It went from 43 files to 179, from 15,629 lines to
72,943, from 88 subroutines to 1,624, and from 0% to 74% `use strict`. It was a serious
rewrite by serious people.

The structural problem it could not escape:

- **Deployment.** PHP ran as a module in the web server. Perl CGI forked an interpreter
  per request. `Install_Guide.html` requires the operator to identify their cgi-bin,
  CHMOD files to 755, transfer `.pm`/`.cfg`/`.html` in ASCII mode and images in binary,
  run `perl_test.cgi` to discover their sendmail path, and unpack six `.tar` archives
  **[DOC]**. `Upgrading\to_3.1.1 from 3.1.0\readme.txt` is a five-step manual file-copy
  procedure **[DOC]**. phpBB was: upload, visit a URL.
- **Hosting economics.** Shared hosts sold PHP+MySQL by default and restricted CGI. The
  installer defaults in this tree assume DB_File and flat files precisely because MySQL
  could not be assumed -- `Install_Guide.html` **[DOC]**: "A host that has installed Perl 5
  or better with the DB_file module installed. Please note that although we offer MySQL
  databases for Ikonboard, that it is not a requirement."
- **The modification community.** Forum software of this era lived on user
  customization. PHP was the language the customizers already knew. iBHackers had 26,061
  posts in 2003 **[ARCHIVE]**; phpBB's MOD community was an order of magnitude larger.
- **Its own vendor said so.** "ibp php releases to follow," 06/12/2002 **[ARCHIVE]**.

**[INFERENCE]** Ikonboard 3.1.1 is the high-water mark of the Perl CGI forum: the most
architecturally ambitious thing the category produced, shipped in the year the category
lost. The line that survived was not the code -- it was the author, working in PHP under a
different company name, on a product that quickly gathered the users of the one he left
behind.

---

### 2.9 Timeline

Every dated event established in this chapter, with its evidence class. Dates are
mm/dd/yyyy. Tree timestamps are UTC.

| Date | Event | Evidence |
|------|-------|----------|
| 09/1999 | Ikonboard 0.9 beta. Matt Mecham, Perl, flat file. Operating from ikondiscussion.com. | **[EXTERNAL]** |
| 1999 | Jarvis Entertainment Group founded. John D. Jarvis, president and CEO. | **[EXTERNAL]** |
| 02/2000 | vBulletin begun as a PHP+MySQL rewrite of Perl UBB, because UBB "could not always cope." | **[EXTERNAL]** |
| 2000 | YaBB released (Perl). WWWThreads PHP version. vBulletin 1 released by Jelsoft. | **[EXTERNAL]** |
| 03/2001 | ikondiscussion.com server crash; project moves to ikonboard.com. | **[EXTERNAL]** |
| late 04/2001 | "Ikonboard officially joined the Jarvis Network." Mecham sells Ikonboard to JEG; consideration reported as 50,000 shares of common stock. Current release 2.1.8; 3.0 in beta. | **[EXTERNAL]**, share count **[UNVERIFIED]** |
| 05/2001 | **iDatabase v1.0** written. "Developed for Ikonboard. Author: Matthew Mecham." | **[CODE]** `iDatabase/Driver/CSV.pm:6-9` |
| 05/29/2001 | Mecham writes `perl_test.cgi` and its read_me; read_me signed `--Matt Mecham (<matt@ikonboard.com>)` / `29/5/01`. | **[DOC]** `Tools\HELP\read_me.txt` |
| 05/29/2001 | Oldest file in the board tree: `cgi-bin/Data/.htaccess`. | **[CODE]** |
| 06/2001 | **Ikonboard 2.1.9** era. "Copyright 2001 Ikonboard.com." "All files written by Matthew Mecham." Manufacturer: Gosport, Hants, UK. License governed by UK law, damages capped at GBP 1.00, de-branding GBP 200.00. | **[CODE]**, **[DOC]** |
| 06/02/2001 | `cgi-bin/Data/index.html` and the placeholder `index.html` files -- earliest dated files in `out_provenance.txt`'s bracket. | **[CODE]** |
| 06/03/2001 | ikonboard.com navigation shows "Ikonboard v3.0", a **Bug Tracker** at `bugs.ikonboard.com`, Member Center, Documentation, BBS Convertors, Chat. Team photo page: Matt, Heartcall (Ken), Soeren, Joe, Peter, Camikaze (Stewart). | **[ARCHIVE]** |
| 07/19/2001 | `cgi-bin/Data/MemberTitles.pm` -- oldest Perl file in the tree. | **[CODE]** |
| 08/13/2001 | `cgi-bin/Data/ib_data_file.dat`. | **[CODE]** |
| **08/30/2001** | **Mecham signs the iB2->iB3 migration guide**: `$ Matt - 30 August 2001, 2:16am`. Describes iB3 as still in BETA and in use on the company's own support board. Ships unchanged in the 3.1.1 box. Redirect scripts in `Tool_Box/` same timestamp. **Left bound on his departure.** | **[DOC]** |
| 10/26/2001 | `Tools\mod_perl\start_up.pl`. | **[DOC]** |
| 10/30/2001 | `Tools\writing_hacks\module_template.pm` -- the hack-writing SDK. | **[DOC]** |
| **11/05/2001** | JEG press item: "JEG Release iB3; The Leader in Community Building Software." | **[ARCHIVE]** |
| 11/21/2001 | JEG announces a film production deal for "The Turnaround." | **[ARCHIVE]** |
| 12/06/2001 | JEG acquires controlling interest in COMC; announces manufacturing spin-off and dividend. | **[ARCHIVE]** |
| 12/10/2001 | JEG CEO featured on Wall Street Reporter / Windows to Wall Street. | **[ARCHIVE]** |
| **12/18/2001** | **`license.html` written** -- the JEG license shipped with 3.1.1. Free personal and commercial; $250 de-branding; "not open source"; six modification conditions; contributor ownership assigned away; at-will termination "for any or no reason"; right to publish users; US law; Tomball, TX; phone 434-352-9311 (a Virginia area code). | **[DOC]** |
| **02/2002** | **Invision Power Services founded** by Charles Warner and Matt Mecham, both ex-JEG. IBForums (later Invision Power Board) begun. PHP + MySQL. **Right bound on Mecham's departure.** | **[EXTERNAL]**, Mecham's own statement in a 04/22/2004 interview |
| 02/19/2002 | JEG corporate news item "Jrve news 2/19/02." | **[ARCHIVE]** |
| **04/04/2002** | **phpBB 2.0.0 released** after 14 months of work. Free, PHP, MySQL. | **[EXTERNAL]** |
| 04/08/2002 | `# Added by LrdChaos 4/8/02` -- `Sources/Admin/Category.pm:535,695`. Earliest hand-dated contribution in the tree. | **[CODE]** |
| 05/03, 05/20, 05/21/2002 | Jarvis network / corporate news items. | **[ARCHIVE]** |
| 05/07/2002 | `## Added by LrdChaos 5/7/02 for watched topic` / `for topic watch` -- `Languages/en/ModerateWords.pm:21`, `Languages/en/TopicWords.pm:29`, `Sources/Admin/ModControl.pm:447,800`. | **[CODE]** |
| 05/20/2002 | `cgi-bin/Data/MimeTypes.cfg`. | **[CODE]** |
| 06/02/2002 | `cgi-bin/Data/SkinList.cfg`. | **[CODE]** |
| **06/05/2002** | **Ikonboard staff roster captured.** "Ikonboard Network **Volunteer** Staff." Development Team: **Camil** (`ccollard@enter-net.com`), **KEVaholic00** (`kevaholic00@yahoo.com`), JayLittle, Jevon. Team Leaders include **LrdChaos** (`lrdchaos.idev@ikonboard.com`), Sly, Wedge, Redbaron, Malkavian, Brush, Benjamin Liger. Administrators: Gladiator, Fender, Quasi, Kaitou Ace. | **[ARCHIVE]** |
| **06/12/2002** | **Ikonboard 3.1 released.** JEG headline: "Jeg releases ib 3.1; **ibp php releases to follow**" (08:53am). `INSTALL_DATA/board_rules.dat`, `cgi_path.html`, `cgi_url.html` all timestamped 06/12/2002 21:34. | **[ARCHIVE]**, **[CODE]** |
| 06/13/2002 | `Data.tar` built (02:41:32). | **[CODE]** |
| **06/14/2002** | `Upgrading\to_3.1.1 from 3.1.0\readme.txt` written -- the 3.1.0->3.1.1 manual upgrade procedure, listing 25 updated `Sources` files including `Menuadmin.pm`. **Places the 3.1.1 patch release in mid-June 2002.** | **[DOC]** |
| 06/14/2002 | `Languages.tar` built (06:47:30) -- later overwritten in place; see 07/14. | **[CODE]** |
| 06/16-17/2002 | `installer.cgi` (06/17 03:50 UTC). | **[CODE]** |
| 06/20/2002 | `install_modules/*` (23:15); `Tools\rm_*.cgi`. | **[CODE]** |
| 06/21/2002 | `Sources/ARC4.pm`; `Database.tar` contents. | **[CODE]** |
| 06/22/2002 | `Database.tar` built (07:05:32). `Sources/iDatabase/Driver/Oracle.pm` (01:40). `Glossary.html`, `Install_Guide.html`, `Installer_Guide.html` all dated 06/22/2002. | **[CODE]**, **[DOC]** |
| 06/23/2002 | `Sources/Calendar.pm` (16:28), `Sources/Legends.pm` (16:38), `Sources/NotePad.pm` (18:09), `Sources/Upgrade.pm` (18:52), `Sources/Warn.pm` (18:52). Four contributor modules in one day. `Tools\create_indexes.cgi`, `restore_admin.cgi`. | **[CODE]**, **[DOC]** |
| 06/24/2002 | `Sources/Admin/WebRing.pm` (05:20) -- KEVaholic00's webring. `Sources/UserCP/Menu.pm` (19:10) -- the file whose copyright year was bumped but whose owner name was not. `Upgrading\from_3.0.x_to_3.1.1\mysql_table_updater\alter_table.cgi`. | **[CODE]**, **[DOC]** |
| 06/26/2002 | `Upgrading\from_3.0.x_to_3.1.1\upgrade_info_mySQL.txt`. | **[DOC]** |
| 06/27/2002 | Newest content inside `non-cgi.tar`. | **[CODE]** |
| 07/01/2002 | `Sources/Admin/SQLclient.pm` (15:15) -- Nurlan Mukhanov's SQL client. | **[CODE]** |
| 07/12/2002 | Skin `.cfg`/`.pm` sweep -- `PostView`, `PostersView`, `ProfileView`, `ReportView`, `SearchView`, `Universal`, `WarnView`, `PrintPageView`, `TopicView`. | **[CODE]** |
| 07/13/2002 | `ikonboard.cgi` (01:33:34). `Sources/Admin/Tools.pm`. **`Sources/Sessions.pm` (21:20:54)** -- still stamped `(c)2001 Ikonboard.com`, `[ v3.0 ]`. `Skin/Default/RegisterView.*`. **`Sources/Lib/FUNC.pm` (23:47:08)** -- containing the no-op sanitizers at lines 104 and 191 that become CVE-2003-0770. | **[CODE]** |
| 07/14/2002 | `Languages/en/PostWords.pm` (00:23:03) -- **newer than the `Languages.tar` that contains it by 29d 17h**. `INSTALL_DATA/tiker.html`, `news.html`. `Sources/Register.pm` (22:26:36). | **[CODE]** |
| **07/15/2002** | **Development ends.** `Sources/SSI/Parser.pm` (00:20:14), `Skin/Default/Styles.pm` (00:39:40), **`Sources/Admin/ModControl.pm` (13:47:12)** -- the last file of the development run. | **[CODE]** |
| **10/17/2002** | **Ikonboard staff roster captured again.** iB3 Development Team: Jevon, **KEVaholic00**, **Porter** (`porter@jarvisgroup.com`), Pysbird, simonpersson, Sanjeet Ganjam. Camil and LrdChaos no longer listed. Separate iB Skinner, iBH, and MYiSupport groups. | **[ARCHIVE]** |
| **11/23/2002** | `readme.txt` written -- the top-level "Ikonboard v3.1.1" package inventory. ("Their Are 4 Directories and 4 files in this Package" -- sic.) | **[DOC]** |
| **11/24/2002** | **Repackaging.** `Sources.tar` (06:38:48), `Skin.tar` (06:49:14), `non-cgi.tar` (06:52:12) all rebuilt. | **[CODE]** |
| **11/24-25/2002** | **`Sources/Admin/Menuadmin.pm` (11/25 05:05:08 UTC = evening of 11/24 US Central)** -- the newest file in the distribution, 22h 26m *newer than the `Sources.tar` that contains it*. Its distinguishing content is the "Network Links" block (`:112-144`): `members.ikonboard.com/admin_guide/`, `help.ikonboard.com`, `forums.ikonboard.com`, `members.ikonboard.com`, `hosting.jarvisgroup.net`, `ibskins.ikonboard.com`, `ibhackers.com`, `myikonboard.com` -- with the paste-damaged indentation of a hand edit. **The last thing anyone did to Ikonboard 3.1.1.** | **[CODE]** |
| 12/10/2002 | ikonboard.com front page captured: "Ikonboard 3.1 Services," JRVE pink-sheets ticker, network description of Ikonboard / IB Hackers / IB Skins / My Ikonboard, "J-HOST" page sponsor, "Jarvis Network Traffic For Last Hour - This Site: 590 - Entire Network: 19,762." | **[ARCHIVE]** |
| 12/13/2002 | members.ikonboard.com captured: registration-gated download with an explicit "Download without Registering" opt-out; single sign-on against the support forum database. | **[ARCHIVE]** |
| 2002 | **CVE-2002-0328** -- cross-site scripting via JavaScript in an `[img]` tag. Originally against Ikonboard 3.0.1, **also reported against 3.1.1**. **CVE-2002-2230**, its variant: XSS via a private message containing a `javascript:` URL in an IMG tag ending `.gif` or `.jpg`, **affecting 3.1.1**. **CVE-2002-2231** -- XSS via a `javascript:` URL in a photo URL or an `X-Forwarded-For` header, 3.1.1. Exact publication dates **[UNVERIFIED]**; placed here by CVE year. | **[EXTERNAL]** |
| **01/26/2003** | **Nick Cleaton privately notifies the vendor** of the `lang` cookie arbitrary command execution issue in 3.1.1. | **[EXTERNAL]** |
| **04/02/2003** | **Cleaton discloses publicly** on vuln-dev. Vendor fix status: **"None available."** PoC triggers a syntax error in the `eval`; working exploit withheld. | **[EXTERNAL]** |
| **04/03/2003** | Adam Gilmore reports on vuln-dev that he has bypassed the `require`-on-a-directory constraint and has working arbitrary Perl execution. (Archived rendering shows 04/04 +1000, i.e. Australian eastern time.) | **[EXTERNAL]** |
| **05/13/2003** | **Ikonboard 3.1.2 released** -- 15 weeks after the vendor was notified, and **not a fix for the RCE**; 3.1.2 and 3.1.2a both remain vulnerable. (Reported by iBHackers staff on 05/14/2003: "ib3.1.2 was released yesterday!") **End of 3.1.1's life as the current version.** | **[ARCHIVE]** |
| **09/2003** | **Cleaton re-reports on Bugtraq** -- "IkonBoard 3.1.2a arbitrary command execution" -- and **publishes the two-line patch himself**, targeting `Sources/Lib/FUNC.pm` lines 104 and 192 in 3.1.2a (lines 104 and 191 in this 3.1.1 tree). Ships an `ibfix.cgi` to apply it and a detector script. Confirms 3.1.1 and 3.1.2a both vulnerable. A working exploit -- crafted `lang` cookie, null bytes, URL-encoded, dumping the server's environment -- is posted the same month. | **[EXTERNAL]** |
| **09/22/2003** | **CVE-2003-0770 published.** CVSS v2 7.5. "FUNC.pm in IkonBoard **3.1.2a and previous versions** does not properly cleanse the 'lang' cookie... allows remote attackers to execute arbitrary code." Exploit-DB 22499, 22500. **Eight months, two releases, and no vendor patch.** | **[EXTERNAL]** |
| 10/10/2003 | ibhackers.com captured: 5,914 topics / 20,147 replies / 26,061 posts, categorized Hack Database for 3.1.x, 3.0.x, 2.x.x. | **[ARCHIVE]** |
| 10/2003 | John Jarvis ousted as CEO. | **[EXTERNAL]** |
| 12/04/2003 | myikonboard.com captured: "Proudly Hosting **31353 PHP** MyIkonboards"; MyIB-Economy $5.99/mo, MyIB-Pro $14.95/mo with "Ikonboard v3.0.2." | **[ARCHIVE]** |
| 02/2004 | JEG becomes **Westlin Corporation**. | **[EXTERNAL]** |
| 04/22/2004 | Mecham interview published: "Invision Power Services was created in February 2002." | **[EXTERNAL]** |
| **12/31/2004** | **CVE-2004-1406 published** -- SQL injection in `ikonboard.cgi` in 3.1.0-3.1.3 via the `st` or `keywords` parameter. Secunia 13513, SecurityFocus BID 11982. **The search-related issue.** | **[EXTERNAL]** |
| 2004-2005 | Litigation over ownership; outages; the development team leaves for Infinite Core Technology. | **[EXTERNAL]** |
| 10/2005 | Ikonboard releases no longer available for download. | **[EXTERNAL]** |
| 10/28/2005 | Ownership transferred to John Jarvis; parent becomes **Pitboss Entertainment**. | **[EXTERNAL]** |
| 01/30/2006 | **Ikonboard 3.1.3** -- HRI CAPTCHA on registration, admin update center. | **[EXTERNAL]** |
| 03/2006 | Assets to **Level 6 Studios**. | **[EXTERNAL]** |
| 02/2006 | 3.1.4, bug fixes. | **[EXTERNAL]** |
| 05-06/2006 | 3.1.5. Exact date disputed between sources. | **[EXTERNAL]** / **[UNVERIFIED]** |
| 09/10/2006 | "The Ikonboard Team" departs; founds **IkonForums**, releases 1.0.0. Ikonboard 3.2 abandoned. | **[EXTERNAL]** |
| 09/2009 | Ownership to Joshua Johnson via **Ikonboard Services Inc.** | **[EXTERNAL]** |
| 06/2010 | **3.1.5a** -- the last release of the Perl Ikonboard. | **[EXTERNAL]** |
| 01/01/2015 | Last ikonboard.com capture serving real content -- a three-page brochure, "(c) 2014 Ikonboard," still claiming "well over 1 million active boards" and "50,000 downloads in the past few months." | **[ARCHIVE]** |
| 02/2015 - 12/2017 | Every capture a 302 or a revisit. | **[ARCHIVE]** |
| 01/2018 - 05/2018 | Content returns; identical page, "(c) 2018 Ikonboard." | **[ARCHIVE]** |
| 07-09/2018 | HTTP 500. | **[ARCHIVE]** |
| 10/2018 - 03/2021 | 302s and revisits; no content. | **[ARCHIVE]** |
| 01/2017 | "No new software releases have been made, and their website has ceased to exist." Domain later changes hands. | **[EXTERNAL]** |

---

### 2.10 What could not be verified

Stated plainly, so that later researchers know where to dig:

1. **The exact date Matt Mecham left Jarvis Entertainment Group.** Bounded to 08/30/2001
   - 02/2002 by a shipped document and Mecham's own account. No resignation date,
   announcement, or last-commit date located.
2. **The consideration for the Ikonboard sale.** "50,000 shares of common stock" appears
   in encyclopedia sources without a citation I could follow. No SEC filing, press
   release, or statement by Mecham located. JEG's Pink Sheets ticker (JRVE) is confirmed
   **[ARCHIVE]**, so filings may exist.
3. **Bug #168.** `bugs.ikonboard.com` is confirmed to have existed **[ARCHIVE]** but no
   capture of an individual issue was found. What KEVaholic00 fixed at
   `ikonboard.cgi:342` is unknown beyond what the code does (appending the skin directory
   to `IMAGES_URL`).
4. **The exact rule that produced `Mor2001-e`** in `Sources/Admin/Index.pm:8`. One
   instance survives; the rule cannot be recovered from it. That it was an automated bulk
   edit is certain; what the pattern was is not.
5. **What changed in `Sources/Admin/Menuadmin.pm` on 11/24-25/2002.** The tree contains
   one copy. The network-links attribution is an inference from the block's paste-damaged
   indentation and its subject matter, not a diff.
6. **The 434 telephone number** in `license.html`. The Virginia/Texas mismatch is a fact;
   the connection to IPS's later Forest, Virginia location is a guess and is labeled as
   one throughout.
7. **Publication dates for the 2002 XSS issues.** CVE-2002-0328 (JavaScript in an `[img]`
   tag; originally 3.0.1, also reported against 3.1.1), CVE-2002-2230 (its private-message
   `javascript:`-URL-ending-in-`.gif`/`.jpg` variant, 3.1.1), and CVE-2002-2231
   (`javascript:` URL in a photo URL or `X-Forwarded-For` header, 3.1.1) all have
   confirmed descriptions and confirmed affected versions, but I could not locate the
   original advisories or their publication dates. They are placed in the timeline by CVE
   year only.
8. **A "search functionality / DBM backend" RCE** in Ikonboard 3.1.1. Searched for
   directly; not found. The RCE of record is the `lang` cookie / `LoadLanguage` `eval`
   issue (CVE-2003-0770); the search-related issue of record is a 2004 SQL injection
   (CVE-2004-1406) affecting 3.1.0-3.1.3.
9. **The exact release date of Ikonboard 3.1.5** -- sources give 05/30/2006 and
   06/02/2006.
10. **The Project Mongoose -> myIkonboard connection.** The 12/04/2003 capture proves
    myIkonboard's free tier ran PHP; that this PHP was Mongoose is reported but not
    confirmed from a primary source.
11. **Identities behind the handles KEVaholic00, Camil, Freakboy, and joshdw1.** Email
    addresses recovered from archived rosters (`kevaholic00@yahoo.com`,
    `ccollard@enter-net.com`) but no real names. Nurlan Mukhanov, Andrey Prokopenko, and
    Phil Gengler are named in the source itself.
12. **`ibskins.ikonboard.com`.** Described by the vendor **[ARCHIVE]** but the attempted
    capture retrieved an IIS 404 page. The site's actual contents are not documented here.

---

### 2.11 Summary

What the code proves, on its own, without any external research:

- Ikonboard 3.1.1 belongs to **Jarvis Entertainment Group, Inc.**, and 2.1.9 belonged to
  **Ikonboard.com**. The transfer was executed by automated bulk edits over the source
  tree, and those edits were incomplete (2 surviving Ikonboard.com copyrights, 88
  header-less files) and unreviewed (`Mor2001-e`).
- **Matthew Mecham wrote the architecture.** Eleven files carry his byline, ten of them
  the `iDatabase` layer, stamped **May 2001**. The installer's header says "Ikonboard by
  Matthew Mecham [ v3.0 ]" directly above "(c)2001 Jarvis Entertainment Group, Inc."
- He was **still writing official Ikonboard documentation on 08/30/2001**, signed and
  dated, in the box.
- The product was built by a **named volunteer community**: KEVaholic00, Camil, LrdChaos
  (Phil Gengler), Infection (Nurlan Mukhanov), Andrey Prokopenko, Freakboy, joshdw1 -- with
  inline `added by <handle>` patch seams, one reference to a public bug tracker
  (`BUG FIX #168`), a shipped hack-writing SDK, and two modules that shipped with the
  SDK's placeholder boilerplate intact.
- The **license moved from Gosport to Texas, from GBP  to $, from UK law to US law**, added a
  "not open source" disclaimer, six enumerated modification conditions, assignment of
  contributor ownership, at-will termination, and a right to publish its users -- while
  keeping the software free.
- The **business model is rendered in the admin sidebar** at `Menuadmin.pm:112-144`, and
  that block was the last thing anyone edited before the box shipped.
- The **vulnerability that defined the product's reputation is at
  `Sources/Lib/FUNC.pm:191`**, three lines under a comment that says "Make sure the cookie
  data is legal," in a file that declares `use strict`, last touched two days before
  development stopped.

What external research adds: the sale in April 2001, the founding of Invision Power
Services in February 2002, and a disclosure timeline that runs eight months from private
notification on 01/26/2003 to CVE-2003-0770 on 09/22/2003 -- during which the vendor
shipped 3.1.2 on 05/13/2003 and 3.1.2a after it, **neither of them carrying the fix**,
which was eventually written and published by the reporter. Then eight more years of
ownership churn, ending in a brochure page that outlived its software by a decade.

Ikonboard 3.1.1 is a 73,000-line Perl rewrite, built by a volunteer team for a Pink
Sheets holding company, in the last year that Perl CGI forums were a viable product
category, under a license that made its own cross-promotion links legally load-bearing,
carrying a one-line remote code execution bug in its core library. Every one of those
clauses is legible from the box.

---

## 3. Architecture

Ikonboard 3.1.1 was released in July 2002 by Jarvis Entertainment Group, Inc.
It is not an evolution of Ikonboard 2.1.9; it is a ground-up rewrite that kept
the name and the URL scheme and almost nothing else. Seventeen source lines are
shared between the two codebases -- 0.49% of 2.1.9, 0.11% of 3.1.1 -- and most of
those seventeen are HTML comments and a date-formatting idiom. The file count
goes from 43 Perl files to 179, the line count from 15,586 to 72,805, the
subroutine count from 88 to 1,624, and `use strict` coverage from 0% to 74%.

This chapter describes how the 3.1.1 machine actually runs a request: what the
single CGI entry point does before it dispatches, how the dispatcher works, what
sits underneath it (a five-driver database abstraction layer, a template
compiler that emits Perl, a server-side session store, a language layer), and
where the seams are.

**A note on what is missing.** The reconstructed tree in
`teardown/board/cgi-bin/` is a *distribution* tree -- the board was never
installed. The single most important runtime file, `Data/Boardinfo.cgi`, does
not exist here. It is *generated* by the installer, not shipped. Everything
this chapter says about `$iB::INFO` is therefore reconstructed from three
sources: the shipped defaults file `ikonboard.conf`, the generator
`install_modules/functions.pm:433` (`write_boardinfo`), and the consumers
scattered through `Sources/`. Where that reconstruction is uncertain, the text
says so.

---

### 3.1 The shape

#### 1.1 One script instead of forty

Ikonboard 2.1.9 was a directory of independent CGI scripts. Thirty-one `.cgi`
files sat in `cgi-bin/`, each one a complete program: `topic.cgi`, `post.cgi`,
`search.cgi`, `admincenter.cgi`, `setforums.cgi`, and so on. Each began by
pulling in a shared library and three data files:

```perl
eval {
($0 =~ m,(.*)/[^/]+,)   and unshift (@INC, "$1");
($0 =~ m,(.*)\\[^\\]+,) and unshift (@INC, "$1");
require "ikon.lib";          # Require ikonboard ()
require "data/progs.cgi";    # Require prog names
require "data/boardinfo.cgi";# Require board info
require "data/styles.cgi";   # Require styles info
};
```
-- `ib219/cgi-bin/ikonboard.cgi:21-28`

Every hyperlink on a 2.1.9 board pointed at a different executable. Every click
started a fresh Perl interpreter, recompiled `ikon.lib` (the whole shared
library, whether the script needed it or not), reread the flat config files, and
exited. There was no database, no connection to keep, and no session: identity
came straight off two cookies, in the clear, on line 45 of the entry script.

```perl
$inmembername = cookie("amembernamecookie");
$inpassword   = cookie("apasswordcookie");
```
-- `ib219/cgi-bin/ikonboard.cgi:45-46`

Neither `ikonboard.cgi` nor `ikon.lib` in 2.1.9 contains a single `use strict`.

Ikonboard 3.1.1 collapses all of that into **one** executable --
`ikonboard.cgi`, 588 lines -- plus roughly 118 modules under `Sources/`. The
script is a bootstrap and a dispatcher; it contains no forum logic at all. Every
URL on a 3.x board is a query string against the same file:

```
ikonboard.cgi?act=ST;f=4;t=1201;st=15;s=<session-id>
```

`act` selects the module, `CODE` selects the method inside it, `AD=1` (or
`CP=1`) diverts the whole request into the admin control panel, and `s` carries
the session ID for cookie-less clients. Semicolons rather than ampersands are
the default separator (`$CGI::USE_PARAM_SEMICOLONS = 1` at
`ikonboard.cgi:161`), with an escape hatch at the top of the file for old CGI.pm
versions that could not cope (`ikonboard.cgi:41`, `153-159`).

#### 1.2 A request, end to end

```
   browser
      |
      |  GET /cgi-bin/ikonboard.cgi?act=ST;f=4;t=1201;s=<sid>
      v
+-----------------------------------------------------------------+
|  Apache  (mod_cgi, or mod_perl + Apache::Registry)              |
+-----------------------------------------------------------------+
      |
      v
+-----------------------------------------------------------------+
|  ikonboard.cgi  package iB                                       |
|                                                                  |
|  60   use lib ./Data ./Sources ./Skin ./Languages ./             |
|  77   reset $iB::COOKIES_OUT SESSION IN TEMP_COOKIE              |
|          COOKIES MEMBER ACTIVE          <-- mod_perl hygiene     |
|  91   use constant IS_MODPERL; *iB::exit = Apache::exit | CORE   |
|  102  $SIG{__WARN__} filter   (drops "uninitialized value")      |
|  109  $SIG{__DIE__} = \&catch_die                                |
|  123  require "Boardinfo.cgi";  $iB::INFO = Boardinfo->new()     |
|  135  die if installer.cgi still present and no install.lock     |
|  147  use CGI;  POST_MAX=500K; PRIVATE_TEMPFILES; HEADERS_ONCE   |
|  175  %iB::IN = map { _clean_key => _clean_value } CGI params    |
|  180  $iB::IN{AD} ||= $iB::IN{CP}                                |
|  186  read cookies whose name starts with COOKIE_ID              |
|  211  ARC4 key bootstrap -> decrypt DB_PASS                      |
|  289  $db = iDatabase::SQL->new( DB_DRIVER => ... )              |
|  307  $std = FUNC::STD->new();  $sess = Sessions->new()          |
|  317  IP_ADDRESS from HTTP_X_FORWARDED_FOR || REMOTE_ADDR        |
|  335  $std->ValidateEntry($db)   referer + /proc/loadavg         |
|  338  $iB::SKIN = $std->LoadSkin()   -> Skin/<dir>/Styles.pm     |
|  340  do  Skin/<dir>/Universal.pm                                |
|  352  $iB::MEMBER = $sess->authenticate($db)                     |
|  353  $iB::ACTIVE = $sess->active_users($db)   (conditional)     |
|  376  eval { iB::Action($db) } || $std->cgi_error($@)            |
+-----------------------------------------------------------------+
      |
      v
+-----------------------------------------------------------------+
|  iB::Action                                                      |
|  396  AD or CP  -> Admin::Functions->process($db)   [stage 3]    |
|  404  board offline? 413 force login?                            |
|  424  %Mode : 44 act= keys -> [ module, method ]                 |
|  473  unknown act is forced to BoardIdx     <-- the guard        |
|  485  build "require M; my $idx = M->new(); $idx->meth($db);"    |
|  489  eval $code                             <-- stage 1         |
+-----------------------------------------------------------------+
      |
      v
+-----------------------------------------------------------------+
|  Sources/Topic.pm   (or 43 others)                               |
|   - BEGIN { require 'Lib/FUNC.pm' }                              |
|   - $Topic::lang = $std->LoadLanguage('TopicWords')              |
|   - require $iB::SKIN->{DIR} . '/TopicView.pm'                   |
|   - per-module %Mode on $iB::IN{CODE}          <-- stage 2       |
|   - $db->query( TABLE => ..., WHERE => ... )                     |
+-----------------------------------------------------------------+
      |                                   |
      v                                   v
+---------------------------+   +------------------------------+
|  iDatabase::SQL           |   |  Skin/<dir>/TopicView.pm     |
|   AUTOLOAD -> Driver      |   |   sub RenderRow {            |
|   DBM|mySQL|pgSQL|Oracle  |   |     return qq~ <tr>...</tr> ~|
|   Driver/Base.pm defaults |   |   }                          |
+---------------------------+   +------------------------------+
      |                                   |
      +-----------------+-----------------+
                        v
+-----------------------------------------------------------------+
|  FUNC::Output::print_ikonboard  (Lib/FUNC.pm:794)                |
|   - board template row from the `templates` table                |
|   - substitute <% TITLE %> <% IKONBOARD %> <% NAVIGATION %> ...  |
|   - optional SSI expansion, optional HTML compressor             |
|   - _print_http_header  (emits @{$iB::COOKIES_OUT})              |
|   - print $it;  iB::exit()                                       |
+-----------------------------------------------------------------+
      |
      v
   HTML to browser
```

#### 1.3 Where the code lives

| subsystem | files | Perl lines |
|---|---:|---:|
| Admin control panel (`Sources/Admin/`) | 34 | 24,088 |
| Front-end controllers (`Sources/*.pm`) | 33 | 16,629 |
| Skin (compiled views + templates) | 61 | 6,566 |
| Database abstraction layer (`Sources/iDatabase/`) | 13 | 6,530 |
| Installer | 9 | 4,304 |
| Core library (`Sources/Lib/`) | 5 | 2,906 |
| User CP + messenger | 6 | 2,528 |
| Language packs | 33 | 2,344 |
| Search subsystem | 6 | 1,683 |
| Small feature modules (`Sources/Misc/`) | 12 | 1,323 |
| Bundled CPAN (Archive::Tar, Compress::Zlib, MIME::*) | 5 | 1,949 |
| mod_perl glue | 1 | 82 |

(from `out_manifest.txt` section 3.1)

A third of the Perl in Ikonboard 3.1.1 is the administration control panel.
`Sources/Admin/Options.pm` alone is 2,298 lines -- larger than any front-end
module, larger than any database driver. The board's user-facing feature set is
smaller than its configuration surface.

---

### 3.2 Startup sequence

What follows walks `ikonboard.cgi` top to bottom. Nothing here is forum logic;
it is all environment construction. Roughly 370 lines run before a single line
of `Sources/` does anything user-visible.

#### 2.1 Package and library path (lines 2, 60-68)

```perl
package iB;
$| = 1;
use strict;
```

The entry script declares `package iB` and everything the board treats as global
state lives in that package: `$iB::INFO`, `%iB::IN`, `$iB::MEMBER`,
`$iB::SKIN`, `$iB::SESSION`, `$iB::COOKIES`, `$iB::CGI`, `$iB::VERSION`.
Modules reach into `iB::` directly rather than being passed a context object.
This is the single most consequential design decision in the codebase: it makes
every module implicitly dependent on the entry script having run, and it is why
the mod_perl resets in the next section have to exist.

```perl
use lib ( './Data'   ,
          './Sources',
          './Skin'   ,
          './Languages',
          './',
        );
```
-- `ikonboard.cgi:60-65`

The comment above it explains why `use lib` rather than `unshift @INC`:

> mod_perl doesn't seem to like manual unshifting of the @INC because it *has* to
> go in a BEGIN {} block as mod_perl only see's this once, things get out of
> whack. "use lib" is a more elegant solution.
> -- `ikonboard.cgi:45-50`

The paths are *relative*. That works under mod_cgi because Apache chdirs to the
script's directory; the comment at lines 51-58 tells the admin to hand-edit
absolute paths in if it does not. `use Benchmark` at line 68 is loaded
unconditionally, only to produce the execution-time line in the admin stats box
(`Lib/FUNC.pm:852-857`).

#### 2.2 mod_perl global resets (lines 77-93)

```perl
$iB::COOKIES_OUT = [];
$iB::SESSION     = undef;
%iB::IN          = ();
@iB::TEMP_COOKIE = ();
$iB::COOKIES     = {};
$iB::MEMBER      = undef;
$iB::ACTIVE      = undef;
```
-- `ikonboard.cgi:77-83`

The comment above is candid about why:

> Even though we use strict, $iB is a global package and as mod_perl compiles
> once and runs, all the values are carried over. We don't want that.
> -- `ikonboard.cgi:73-75`

This is correct, and it is the right fix for the seven variables named. Section
8 covers the state it does *not* reach.

```perl
use constant IS_MODPERL => $ENV{MOD_PERL};
use subs qw(exit);
*iB::exit = IS_MODPERL ? \&Apache::exit : sub { CORE::exit };
```
-- `ikonboard.cgi:91-93`

Calling `exit()` inside a mod_perl child kills the Apache process. The board
therefore installs its own `iB::exit`, resolved once at compile time, that maps
to `Apache::exit` under mod_perl and `CORE::exit` otherwise. Every terminating
path in the codebase -- `catch_die` (line 575), `print_ikonboard`
(`Lib/FUNC.pm:983`), `FUNC::STD::Error` (`Lib/FUNC.pm:664`),
`Sessions::do_log_in` (`Sources/Sessions.pm:498`) -- calls `iB::exit()` rather
than `exit`. That discipline is unusually consistent for the era.

#### 2.3 The warning filter (lines 102-107)

```perl
$SIG{__WARN__} = sub {
   my $wn = shift;
   return if $wn =~ /Use of uninitialized value/i;    #Most annoying
   return if $wn =~ /name "(?:.+?)" used only once/i; #Very annoying
   warn $wn;
};
```

Two whole classes of Perl diagnostic are discarded before they reach the error
log. Say plainly what that costs.

"Use of uninitialized value" is the warning Perl emits when you interpolate or
compare a variable that was never given a value. In a codebase where identifiers
are fully-qualified package globals -- which `use strict 'vars'` permits without
declaration -- that warning is the *only* signal that a name is wrong. `use
strict` cannot catch `$iB::PTH` because `$iB::PTH` is syntactically a legal
package variable; it catches `$pth` but not `$iB::pth`.

The tree contains a live example. `Sources/Boards.pm:65`:

```perl
	unless (-e $iB::PTH.'/Data/ForumJump.pm') {
		$std->build_forumjump( DB	  => $db,
							   CATS	  => $obj->{'TOTAL_CATS'},
							   FORUMS => $obj->{'TOTAL_FORUMS'}
							 );
	}
```

`$iB::PTH` is assigned nowhere in the distribution -- a full-tree search finds
exactly one occurrence, this one. The test therefore reduces to `-e
'/Data/ForumJump.pm'`, an absolute path from filesystem root that will not
exist on any normal host. So the guard never fires, and `build_forumjump`
(`Lib/FUNC.pm:256`) runs on **every** board-index request: it walks all
categories and all forums, then calls `FUNC::ADMIN::make_module`
(`Lib/ADMIN.pm:210`), which copies the existing `Data/ForumJump.pm` to a backup
and writes a fresh one. Two file writes and a full category/forum traversal per
front-page hit, forever, because one warning class was silenced.

The second suppressed class, `"name used only once"`, hides exactly the same
category of typo in the other direction.

Line 109 installs `$SIG{__DIE__} = \&catch_die`, which turns any `die` anywhere
in the tree into a styled HTML error page with `$ENV{DOCUMENT_ROOT}` scrubbed
out of the message (`ikonboard.cgi:546-576`). Line 116 shows a commented-out
`$SIG{ALRM}` watchdog that would append to `Data/timeout_log` for any request
over 30 seconds; the matching `alarm(30)` / `alarm(0)` calls are commented out
at lines 387 and 493. It shipped disabled.

#### 2.4 Configuration (lines 123-140)

```perl
require "Boardinfo.cgi";
$iB::INFO = Boardinfo->new();
```
-- `ikonboard.cgi:123-124`

`Boardinfo.cgi` is resolved through the `./Data` entry in `@INC`; the real path
is `cgi-bin/Data/Boardinfo.cgi`. **It is not in this tree**, because it does not
exist until the installer writes it. Its generator is
`install_modules/functions.pm:433` (`write_boardinfo`), which emits a trivial
package with a single constructor:

```perl
print FH <<_END_PRINT_;
package Boardinfo;
  
  sub new {
    my \$pkg = shift;
    my \$obj = {
_END_PRINT_

    for my $key (sort { $a cmp $b } keys %{$data}) {
        my $space = " " x (20 - (length($key)));
        $data->{$key} =~ s|!|&#33;|g;
        print FH qq~'$key' $space => q!$data->{ $key }!,\n~;
    }
```
-- `install_modules/functions.pm:457-470`

Note `q!...!` -- single-quoted, non-interpolating, with `!` escaped to `&#33;` on
the way in. Configuration values are inert strings. (The near-identical
`FUNC::ADMIN::make_module` at `Lib/ADMIN.pm:210` takes an `INTERPOLATE` flag and
will use `qq!...!` instead -- that is how the admin CP rewrites `Boardinfo.cgi`
in place when settings change.)

The key set is recoverable from the shipped defaults file, `cgi-bin/ikonboard.conf`,
118 lines of `KEY = value`:

```
BOARD_URL             = 
CGI_EXT               = 
COOKIE_ID             = 
DB_DIR                = 
DB_DRIVER             = 
DB_NAME               = 
DB_PASS               = 
FLOCK                 = 1
GUEST_GROUP           = 2
IKON_DIR              = 
SESSION_EXPIRATION    = 3000
SKINS                 = 1:Default:Standard Ikonboard Skin
SUPAD_GROUP           = 4
```

Blank values are the ones the installer fills in. Compound values are packed
into single strings with `|&|` as the record separator and `:` as the field
separator -- `SKINS`, `FORUM_SKINS`, `EMOTICONS`, `IP_FILTER`, `EMAIL_FILTER`,
`LANGUAGES`, `WORD_FILTER`, `SKIN_TEMPLATES` all use this convention, and every
consumer re-splits it by hand at the point of use (e.g.
`Sources/Sessions.pm:104`, `Lib/FUNC.pm:139`).

Lines 126-129 patch one omission at runtime: if `PUBLIC_UPLOAD` is empty, derive
it from `HTML_DIR` by substituting `non-cgi` for `uploads`.

Lines 135-140 are the installer guard:

```perl
if (  (-e $iB::INFO->{'IKON_DIR'}."installer.cgi")
   && (!(-e $iB::INFO->{'IKON_DIR'}."install.lock")) ) {
   &catch_die("FATAL ERROR:<br>The installer (installer.cgi) is still present in the root ikonboard ".
              "directory. Ikonboard will not run until this file is removed!<br>".
```

The board refuses to run at all if `installer.cgi` is present *and*
`install.lock` is absent. `install.lock` is written by
`install_modules/admin.pl:162` at the end of a successful install, and
`installer.cgi:117` refuses to re-run once it exists. The two files interlock:
a completed install leaves a lock so the board runs; a half-finished one leaves
the installer exposed and the board refuses to start rather than let an
unauthenticated visitor reach it. That is a better failure mode than most
contemporaries managed.

#### 2.5 CGI.pm and the input filter (lines 147-192)

```perl
$CGI::USE_PARAM_SEMICOLONS = 1;
$CGI::PRIVATE_TEMPFILES    = 1;
$CGI::HEADERS_ONCE         = 1;
$CGI::POST_MAX             = 500*1024;
```
-- `ikonboard.cgi:161-164`

500 KB post limit (2.1.9 used 150 KB and disabled uploads outright --
`ib219/cgi-bin/ikonboard.cgi:18-19`). `PRIVATE_TEMPFILES` makes CGI.pm
unlink upload temp files immediately after opening them so other users on a
shared host cannot read them; that is a deliberate shared-hosting hardening
choice, and the commented-out `$TempFile::TMPDIRECTORY` at line 169 is its
escape hatch. A comment at 143-145 notes that CGI.pm is a stopgap: "We'll be
using CGI.pm until iCGI.pm is mod_perl compatible." No `iCGI.pm` ships.

Then the whole parameter space is harvested and filtered in one statement:

```perl
%iB::IN = map { &iB::_clean_key($_) => &iB::_clean_value($iB::CGI->param($_)) } $iB::CGI->param;
```
-- `ikonboard.cgi:175`

```perl
sub _clean_key {
    my $key = shift;
    return '' unless defined $key;
    $key =~ s!\.\.!!g;
    $key =~ s!\_\_(.+?)\_\_!!g;
    &iB::_trim($key);
    $key =~ m!^([\w\.-\_]+)$!;
    return $1;
}
```
-- `ikonboard.cgi:506-514`

```perl
sub _clean_value {
    my $Tmp = shift;
    return '' unless defined $Tmp;
    $Tmp =~ s|&|&amp;|g;
    $Tmp =~ s|<!--|&#60;&#33;--|g; $Tmp =~ s|-->|--&#62;|g;
    $Tmp =~ s|<script|&#60;script|ig;
    $Tmp =~ s|>|&gt;|g;
    $Tmp =~ s|<|&lt;|g;
    $Tmp =~ s|"|&quot;|g;
    ...
    $Tmp =~ s|\$|&#036;|g;
    ...
}
```
-- `ikonboard.cgi:516-537`

Three architectural observations, without straying into the security chapter:

1. **This is an HTML filter, not a general one.** It escapes the characters that
   matter in markup. It does not quote for SQL, does not normalize paths beyond
   deleting literal `..`, and does not quote for the shell. Every later layer
   must assume input is HTML-safe and nothing else.
2. **`$` is escaped to `&#036;` for a structural reason.** Skin templates are
   compiled into `qq~...~` strings (section 3.5). Any `$` that survived into a
   template body would be interpolated as a Perl variable when the view ran.
   The escape at line 530 is what makes the skin engine safe to feed user data.
   The mirror-image un-escape lives in `FUNC::STD::doHTML`
   (`Lib/FUNC.pm:438-447`).
3. **`map` over `param()` takes only the first value of a multi-valued
   parameter**, because `$iB::CGI->param($_)` is called in scalar context. Any
   code that genuinely needs a list -- the skin editor's `SUB:` fields, the
   session ID -- goes back to `$iB::CGI->param()` directly
   (`Sources/Sessions.pm:96`, `Admin/SkinControl.pm:1134`). `%iB::IN` is a
   flattened, sanitized view; `$iB::CGI` is the raw one, and both are in scope
   everywhere.

Line 180 is the AD/CP alias, discussed in section 3.3.4:

```perl
$iB::IN{AD} ||= $iB::IN{CP};
```

Then cookies, filtered by prefix so the board never touches anything that is not
its own:

```perl
@iB::TEMP_COOKIE = $iB::CGI->cookie();
for my $c (@iB::TEMP_COOKIE) {
   # Only get 'our' cookies
   next unless $c =~ /^$iB::INFO->{'COOKIE_ID'}/;
   # Add it to our hash
   $iB::COOKIES->{$c} = $iB::CGI->cookie($c);
}
```
-- `ikonboard.cgi:186-192`

`COOKIE_ID` is a per-board prefix set at install time. It is what lets two
Ikonboards share a hostname without trampling each other's cookies -- the same
role `DB_PREFIX` plays for SQL tables.

#### 2.6 The ARC4 database-password bootstrap (lines 211-267)

This block is the strangest part of startup and deserves a careful reading.

```perl
	require 'Sources/ARC4.pm' or die "Cannot open ARC4";
	require 'Sources/MIME/Base64.pm' or die "Cannot open Base64";
	opendir (DIR, $iB::INFO->{'IKON_DIR'}.'Data');
	my @list = grep { !/\A\.{1,2}\Z/ } readdir(DIR);
	closedir(DIR);
	my @key  = grep { /.+?(\.pwd)\Z/ } @list;
```
-- `ikonboard.cgi:211-216`

The board scans `Data/` for a file ending in `.pwd`. That filename *is* the
encryption key. If no such file exists, the board is running for the first time
since the feature was added, and it performs a one-time migration:

```perl
	unless (scalar @key > 0) {
 		my $file = &iB::my_gen_key();
 		my $file_name = $iB::INFO->{'IKON_DIR'}.'/Data/' . $file . '.pwd';
 		open (KEYF, ">" . $file_name ) or die "Cannot write to $file_name ($!)";
		print KEYF $file_name;
 		close KEYF;
 		chmod ( 0644, $file_name );
 		$file = $file . '.pwd';
		my $ark4 = Crypt::ARC4->new($file); #preparing the crypting module
		my $OLD = Boardinfo->new();
		if ($iB::INFO->{'DB_PASS'}) {
		$OLD->{'DB_PASS'} = MIME::Base64::encode_base64($ark4->ARC4( $iB::INFO->{'DB_PASS'} )); # crypting the password
		$OLD->{'DB_PASS'} =~ s!\n\Z!!;
		}
```
-- `ikonboard.cgi:217-230`

It generates a 16-character random name, creates `Data/<name>.pwd`, then uses
that *name* (not the file's contents) as the ARC4 key to encrypt each of the
five possible database passwords (`DB_PASS`, `mySQL_DB_PASS`, `pgSQL_DB_PASS`,
`Oracle_DB_PASS`, `DBM_DB_PASS`), Base64-encodes them, and rewrites
`Boardinfo.cgi` through `FUNC::ADMIN::make_module` at lines 255-260.

On every subsequent request the reverse runs:

```perl
	unless ($iB::INFO->{'DB_DRIVER'} eq 'DBM') {
	for my $f (@key) {
		my $ark4 = Crypt::ARC4->new($f);
		$iB::INFO->{'DB_PASS'} = $ark4->ARC4( MIME::Base64::decode_base64($iB::INFO->{'DB_PASS'}) );# decrypting the pass
	}
	}
```
-- `ikonboard.cgi:262-267`

Architecturally, this is a *filename-as-key* scheme layered on a config file
that the web server can already read. Its stated purpose is to stop a plaintext
database password appearing in `Boardinfo.cgi` -- a real concern in 2002, when
a misconfigured server would happily serve `.cgi` files as text. Its practical
effect is that anyone who can list `Data/` can decrypt. The DBM driver skips
decryption entirely, because DBM has no password.

Two incidental notes. The key generator seeds from a subprocess:

```perl
  srand (time ^ $$ ^ unpack "%L*", `ps axww | gzip`);
```
-- `ikonboard.cgi:278`

which forks `ps` and `gzip` on every first-run -- and on any platform without
them (Windows), silently contributes nothing. And lines 251-253:

```perl
		if ($^O eq 'MacOS' && ($^O eq 'MSWin32' || !Win32::IsWin95())) {
		$OLD->{'FLOCK'} = 0;
		}
```

`$^O` cannot be both `MacOS` and `MSWin32`; the condition is unsatisfiable, so
`FLOCK` is never turned off here. The intended test is almost certainly the `&&`
of an `ne`-style guard used correctly elsewhere in the tree
(`iDatabase/Driver/DBM.pm:24`, `iDatabase/Driver/CSV.pm:21`). Flagging as a bug,
not a design.

#### 2.7 The database handle (lines 286-303)

```perl
my $create = $iB::INFO->{DB_DRIVER} eq 'DBM' ? 1 : 0;
my $drop   = $iB::INFO->{DB_DRIVER} eq 'DBM' ? 1 : 0;

my $db    = iDatabase::SQL->new( DATABASE  => $iB::INFO->{'DB_NAME'},
                                 DB_DIR    => $iB::INFO->{'DB_DIR'},
                                 IP        => $iB::INFO->{'DB_IP'},
                                 PORT      => $iB::INFO->{'DB_PORT'},
                                 USERNAME  => $iB::INFO->{'DB_USER'},
                                 PASSWORD  => $iB::INFO->{'DB_PASS'},
                                 DB_PREFIX => $iB::INFO->{'DB_PREFIX'},
                                 DB_DRIVER => $iB::INFO->{'DB_DRIVER'},
                                 ATTR      => { allow_create => $create,
                                                allow_drop   => $drop,
                                              },
                               ); 
                          
# Check for errors
&catch_die( $db->error ) if $db->error;
```

`allow_create` and `allow_drop` are true **only** for DBM. That is deliberate:
DBM "tables" are files that appear on demand, so the driver must be allowed to
make them. For a real SQL server the schema is installed once by the installer
and the runtime handle is denied DDL. `$db` is then threaded as an explicit
argument to every method in the board -- `$idx->ShowTopic($db)`,
`$sess->authenticate($db)`, `$ad->process($db)`. It is the one piece of state
the codebase passes around rather than reaching for in `iB::`.

#### 2.8 Identity, skin, and the last of the setup (lines 307-373)

```perl
my $std   = FUNC::STD->new();
my $sess  = Sessions->new();
```

`$iB::TT0 = new Benchmark;` (line 312) starts the clock. Lines 317-324 resolve
the client IP, preferring `HTTP_X_FORWARDED_FOR` and taking only the first
address if a proxy chain supplied several -- a 2002-appropriate choice that also
means the IP the board binds a session to is attacker-controllable via a header.

```perl
$std->ValidateEntry($db);
```
-- `ikonboard.cgi:335`

`ValidateEntry` (`Lib/FUNC.pm:395-423`) does two unrelated things. First, a
referer check -- but only for `POST` requests where `act` is exactly `Post`:

```perl
	if (lc($ENV{'REQUEST_METHOD'}) eq 'post' and $ENV{'HTTP_REFERER'} and $iB::IN{act} eq 'Post') {
		my $b_url = $INFO->{'BOARD_URL'};
		$b_url =~ s!http://!!i;
		$obj->Error(DB => $db, STD => $obj, LEVEL=>'5',MESSAGE=>'referrer_fail') unless $ENV{'HTTP_REFERER'} =~ m!$b_url!i;
	}
```

Second, if `/proc/loadavg` is readable, it reads the one-minute load average
into `$iB::CONTENT->{'LOAD'}` and, if `LOAD_LIMIT` is configured and exceeded,
aborts the request with a "server too busy" page. A CGI forum that sheds load by
reading `/proc` is a genuinely thoughtful bit of shared-hosting engineering.

```perl
$iB::SKIN   = $std->LoadSkin();
do $iB::SKIN->{'DIR'} . '/Universal.pm';
```
-- `ikonboard.cgi:338-340`

`LoadSkin` (`Lib/FUNC.pm:99-177`) resolves the skin by a three-rule cascade:
admin CP always gets the default skin; a per-forum skin from `FORUM_SKINS`
overrides; otherwise an `sid` URL parameter or a `skin` cookie selects from the
`SKINS` string. It `require`s `Skin/<dir>/Styles.pm` and returns
`Styles::new("Styles")` -- a hash of colors and image tags -- decorated with
`DIR`, `FULL_DIR`, and `IMAGES_URL`. Note `do` rather than `require` for
`Universal.pm`: `do` re-executes on every call, so the correct skin's universal
elements always win. `Lib/FUNC.pm:23` has already hard-`require`d
`'Default/Universal.pm'` at compile time regardless of skin; line 340 overwrites
those subs with the chosen skin's. It works, but it means package `Universal` is
whatever was loaded last.

Lines 344-346 carry a marked bug fix in the source:

```perl
# ( ADDED HERE BY KEVaholic00 FOR BUG FIX #168, COMMENTED OUT BELOW )
# Lets add on the skin name for ease of use.
my $images_url                   = $iB::INFO->{'IMAGES_URL'};

$iB::INFO->{'IMAGES_URL'}       .= '/' . $iB::SKIN->{'FULL_DIR'};
```

with the superseded version left in place, commented, at lines 366-370. The
tree carries several of these attributed in-line patches (`KEVaholic00`,
`Camil`, `Infection`, `Andrey Prokopenko`, `Nurlan`) -- evidence of a codebase
being maintained by a rotating volunteer team rather than a single author.

```perl
$iB::MEMBER = $sess->authenticate($db) unless $iB::IN{'act'} eq 'Reg';
$iB::ACTIVE = $sess->active_users($db) if (( ($iB::IN{'act'} eq 'st' || $iB::IN{'act'} eq 'ST' || $iB::IN{'act'} eq 'Profile') || !$iB::IN{'act'}) or (defined $iB::IN{'c'} ));
```
-- `ikonboard.cgi:352-353`

Authentication runs for every action except registration. The active-users scan
-- a full `WHERE RUNNING_TIME > (time-900)` sweep of `active_sessions` -- runs
only on the board index, topic views, profile views, and category views, because
it is expensive. Note `'st'` and `'ST'` are both tested but `%Mode` only has
`ST`; the lowercase branch is dead as far as dispatch is concerned but still
triggers the scan.

Finally:

```perl
eval { iB::Action($db) } || $std->cgi_error($@);
iB::exit();
```
-- `ikonboard.cgi:376-377`

The entire request body runs inside a block `eval`. Any `die` -- including the
ones raised by the drivers on SQL failure -- lands in `cgi_error`, which delegates
to `catch_die` and prints the styled error page.

---

### 3.3 The dispatcher

`iB::Action` (`ikonboard.cgi:382-502`) is three dispatch stages stacked, plus
two gates.

#### 3.1 The gates

```perl
    if ($iB::IN{'AD'} or $iB::IN{CP}) {
        require Admin::Functions;
        my $ad = Admin::Functions->new();
        $ad->process($db);
        return "0 but true";
    }
```
-- `ikonboard.cgi:396-401`

Admin wins before anything else -- including before the board-offline check, so
an administrator can reach the CP while the board is down. Then:

```perl
    unless ($iB::INFO->{'B_ONLINE'}) {
        unless ($iB::MEMBER_GROUP->{'ACCESS_OFFLINE'}) {
```
-- `ikonboard.cgi:404-405`

and the forced-login gate at 413-421, which exempts only `Reg` and `LostPass`.

#### 3.2 Stage 1 -- the `%Mode` table

```perl
    my %Mode = (  ST        => ['Topic'              , 'ShowTopic'   ],
                  SF        => ['Forum'              , 'ShowForum'   ],
                  SR        => ['Forum'              , 'ShowRules'   ],
                  SC        => ['Boards'             , 'ShowStart'   ],
                  Search    => ['Search::api'        , 'Process'     ],
                  Online    => ['Online'             , 'Process'     ],
                  Legends   => ['Legends'            , 'Process'     ],
                  Help      => ['Help'               , 'Process'     ],
                  Members   => ['Memberlist'         , 'Process'     ],
                  Reg       => ['Register'           , 'Process'     ],
                  Post      => ['Post'               , 'Process'     ],
                  Login     => ['LogInOut'           , 'Process'     ],
                  Profile   => ['Profile'            , 'Process'     ],
                  UserCP    => ['UserCP::Menu'       , 'Process'     ],
                  Mod       => ['Moderate'           , 'Process'     ],
                  Poll      => ['iPoll'              , 'Process'     ],
                  Print     => ['PrintPage'          , 'Process'     ],
                  Invite    => ['Misc::Invite'       , 'Process'     ],
                  Mail      => ['Misc::MailMember'   , 'Process'     ],
                  Cookies   => ['Misc::Cookies'      , 'Process'     ],
                  PMarkers  => ['Misc::PMarkers'     , 'Process'     ],
                  Forward   => ['Misc::Forward'      , 'Process'     ],
                  AOL       => ['Misc::AOL'          , 'Process'     ],
                  ICQ       => ['Misc::ICQ'          , 'Process'     ],
                  MSN       => ['Misc::MSN'          , 'Process'     ],
                  Attach    => ['Misc::Attachments'  , 'Process'     ],
                  Msg       => ['UserCP::Messenger'  , 'Process'     ],
                  MSV       => ['UserCP::Messview'   , 'Process'     ],
                  MSS       => ['UserCP::Messsend'   , 'Process'     ],
                  MSM       => ['Massmsend'  ,         'Process'     ],
                  Subs      => ['Misc::Track'        , 'Process'     ],
                  LostPass  => ['UserCP::Lostpass'   , 'Process'     ],
                  BoardIdx  => ['Boards'             , 'ShowStart'   ],
                  ModCP     => ['ModCP'              , 'Process'     ],
                  Calendar  => ['Calendar'           , 'Process'     ],
                  Report    => ['Misc::Report'       , 'Process'     ],
                  Upgrade   => ['Upgrade'            , 'Process'     ],
                  Warn      => ['Warn'               , 'Process'     ],
                  # Added by KEVaholic00: member notepads
                  NotePad   => ['NotePad'            , 'Process'     ],
                  # Added by Camil: Newest post
                  NW        => ['Newest'             , 'shownewest'  ],
				  ModSet    => ['ModSet'             , 'Process'     ],
				  Welcome   => ['Welcome'            , 'Process'     ],
				  Posters   => ['Posters'            , 'showposter'  ],
				  Happybd   => ['Happybd'            , 'Process'     ],
               );
```
-- `ikonboard.cgi:424-470`

Forty-four actions. The table is rebuilt on every request -- it is a lexical
inside `Action`, not a package constant -- which costs 44 hash-store operations
per hit. Trivial under mod_cgi, pure waste under mod_perl, and a symptom of the
whole file being written for the CGI case first.

The convention is: value `[0]` is the module (`require`-able name, `::`
included), value `[1]` is the method. Almost every module standardizes on
`Process`; the exceptions are the six oldest and largest views (`Topic`,
`Forum`, `Boards`) and three later additions (`Newest`, `Posters`) that never
got normalized. `SC` and `BoardIdx` both point at `Boards::ShowStart`, `SF` and
`SR` both at `Forum`, so 44 keys resolve to 39 distinct module/method pairs.

The mix of two-letter opaque codes (`ST`, `SF`, `SC`, `NW`, `MSV`, `MSS`, `MSM`)
and readable words (`Search`, `Profile`, `Calendar`) is chronological: the
abbreviations are the original 3.0 set, kept because they were already in
bookmarks and links, and every later feature spelled its action out.

#### 3.3 The guard, and the eval

```perl
    $iB::IN{'act'} = 'BoardIdx' if $iB::IN{'act'} eq '';
    $iB::IN{'act'} = 'BoardIdx' unless exists $Mode{ $iB::IN{'act'} };

    # Nice little hack to save writing loads of subroutines for each action.
    # It builds the code from the %Mode hash, depending on the contents of 'act'
    # For example, the eval'd code may look like:

    #      use Topic;
    #      my $idx = Topic->new();
    #         $idx->ShowTopic($db);


    my $code = 'require '.$Mode{ $iB::IN{'act'} }[0].';'.
               'my $idx = '.$Mode{ $iB::IN{'act'} }[0].'->new();'.
                  '$idx->' .$Mode{ $iB::IN{'act'} }[1].'($db);';

    eval $code;
```
-- `ikonboard.cgi:473-489`

A string `eval` whose content is built from a query parameter looks like remote
code execution. **It is not, and the check is one line above it.** Line 474
rewrites `act` to `BoardIdx` unless it is already an existing key of `%Mode`.
By the time `$code` is assembled, `$iB::IN{'act'}` can only be one of the 44
literals in the table, so the only strings that can ever be interpolated are the
88 literals in the table's values. Nothing attacker-supplied reaches the eval.
State that plainly; the alternative is an archive note that misleads every
future reader.

What the eval buys is laziness. `require` inside an eval'd string means the
module for the requested action -- and only that module -- is compiled. A board
index request never parses `ModCP.pm` (2,131 lines) or `Post.pm` (1,623). Under
mod_cgi that is the difference between a 100 ms page and a 500 ms one. It is the
same reason the whole thing is one script: the dispatcher pays for exactly the
code the request needs, which the forty-script model could not do because each
script had to `require ikon.lib` whole.

The closing comment is worth preserving verbatim as period voice:

```perl
    return "shut up complaining mod_perl or I'll kick your ass";
```
-- `ikonboard.cgi:499`

with the explanation above it at 495-497: because `Action` is wrapped in
`eval { } || $std->cgi_error($@)`, a false return would trigger the error
handler with an empty `$@`.

#### 3.4 The AD -> CP aliasing

Line 180 does `$iB::IN{AD} ||= $iB::IN{CP};` with no comment. The explanation is
216 lines later, inside `Action`:

```perl
    # As the admin link has "AD=1" in it, some firewalls/banner blockers
    # will produce a blank page, not what we want.
    # As Ikonboard 3 has used AD=1 since day 1, I don't want to have to weed
    # through the code looking for every single instance it's been used, so
    # we merely use perls' excellent reg-ex to turn AD into CP. For those who
    # have bookmarked their adminCP link, we allow AD=1 to be used also.
```
-- `ikonboard.cgi:389-394`

Ad-blocking software in 2002 pattern-matched URLs for `AD=`, and ate the
administrator's control panel. Rather than rename the parameter across a
34-module admin subsystem, the author accepted a new name at the front door and
aliased it to the old one before anything else ran. Every `$iB::IN{'AD'}` test
in `Sources/` -- `Lib/FUNC.pm:113` in `LoadSkin`, `Sources/Sessions.pm:213` and
`:218` in `authenticate` -- continues to work unmodified. The comment's claim
about "perls' excellent reg-ex" does not match the code, which is a plain
`||=`; it is probably a leftover from a first attempt.

#### 3.5 Stage 2 -- per-module `CODE=`

Once a module is running, it dispatches again on `$iB::IN{'CODE'}`. The shipped
template for third-party developers documents the pattern:

```perl
sub Process {
    my ($obj, $db) = @_;
    my $CodeNo = $iB::IN{'CODE'};

    # We set up a hash. The left hand side is what the CODE value
    # was from the accessing URL (eg: CODE=save_date). To the right
    # is the sub routine that it will execute.
    ...
    my %Mode = ( 'printable_date'       => \&printable_date,
                 'another_sub'          => \&sub_two,
               );
    $Mode{$CodeNo} ? $Mode{$CodeNo}->($obj,$db) : FatalError();
} 
```
-- `ib311/Tools/writing_hacks/module_template.pm`

Stage 2 uses **code references**, not string eval -- the module is already
compiled, so there is nothing to defer. Across the tree there are 152
second-stage endpoints in 27 modules (`out_actions.txt` section 3.2). The largest
are `ModCP` (22 codes), `Moderate` (20), `UserCP::Messenger` (12), `Profile`
(9), `Post` (8), `NotePad` (7).

`CODE` values are as inconsistent as `act` values, and for the same reason:
zero-padded numerics (`CODE=00` ... `CODE=19`) in the modules inherited from 3.0,
readable words (`CODE=topic_search`, `CODE=do_forum_rules`, `CODE=process_move`)
in everything written later. `Post.pm` and `iPoll.pm` do it *both* ways -- a
`%Mode` hash and an `if/elsif` chain on the same `CODE` values.

The static analysis also finds seven `CODE` entries pointing at handlers that do
not exist in the module that names them -- `ModSet` `edit`, `Moderate` `10` and
`11`, `Online` `forum`, `Register` `06`, `UserCP::Messsend` `13`, `iPoll` `03`
(`out_actions.txt` section 3.2). Some are inherited into the package from
elsewhere; some are simply dangling. Because the second-stage lookup uses
`\&name`, a missing sub is a compile-time-resolvable stub that dies at call
time, not a silent no-op.

#### 3.6 Stage 3 -- the admin control panel

`Admin::Functions::process` (`Sources/Admin/Functions.pm:46`) is the third
dispatcher, and it is guarded by three sequential checks before it reaches its
table:

1. **Logged in at all** -- `unless ($iB::MEMBER->{'MEMBER_ID'})`, print the CP
   login form (line 51).
2. **Group permission** -- `unless ($iB::MEMBER_GROUP->{'ACCESS_CP'})`, append
   the attempt to `Temp/log-<member id>.cgi`, set the member's `ALLOW_POST` to
   0, and error out (lines 60-76). Trying to reach the CP without permission
   costs you your posting rights.
3. **A live admin session** -- a separate timestamp file
   `Temp/admin-<member id>.cgi` must exist and be newer than 8 hours
   (`60*480` seconds, line 82); otherwise the file is unlinked and the CP login
   form is shown (lines 77-102).

The admin session is thus a *second* authentication layer with its own storage
(a file, not the `active_sessions` table) and its own expiry, entered through
`act=dologin` (line 152) and torn down on timeout. Only then:

```perl
	my %Mode = ( dologin  => \&dologin,
				 top      => \&top,
				 body     => \&body,
				 cat      => \&cat,
				 ops      => \&Ops,
				 bak      => \&back,
				 ...
				 menuadmin => \&menuadmin,
			   );

	$Mode{ $iB::IN{'act'} } ? $Mode{ $iB::IN{'act'} }->($obj, $db) : Frames($obj, $db);
```
-- `Sources/Admin/Functions.pm:104-140`

Thirty-three entries, each a thin loader:

```perl
sub	forum {
	my ($obj, $db) = @_;
	require 'Admin/ForumControl.pm';
	my $idx = Admin::ForumControl->new()->process($db);
}
```
-- `Sources/Admin/Functions.pm:327-331`

Same lazy-require economy as stage 1, expressed as code references instead of
eval because the names are fixed. The fallback is `Frames` (line 361), which
emits the frameset -- the CP is a three-frame HTML application, and `top` and
`body` are the frame sources.

Note that stage 3 **reuses `act`**. The same query parameter names a front-end
module when `AD` is absent and an admin section when it is present. `%Mode` in
`ikonboard.cgi` and `%Mode` in `Admin::Functions` are disjoint namespaces
selected by a third parameter. It works, but it means `act=forum` means nothing
on the front end and `act=ST` means nothing in the CP, and neither table
documents the other's existence.

---

### 3.4 The database abstraction layer

This is the headline architectural change from 2.x. Ikonboard 2.1.9 had no
database: `data/allforums.cgi`, `members/<name>.cgi`, one file per forum of
pipe-delimited posts. Ikonboard 3.1.1 has `Sources/iDatabase/` -- 13 files, 6,530
lines -- sitting between every module and storage.

#### 4.1 `iDatabase::SQL` -- the factory and forwarder

```perl
    # Attempt to load the correct driver
    my $class = "iDatabase::Driver::$args{DB_DRIVER}";
    # Make global
    $DRIVER   = $class;
    {
        local ($@);
        my $r_class = $class;
           $r_class =~ s~::~/~g;
        eval { require "$r_class.pm" };
        if ($@) {
            # Until we do something more elaborate
            die "Cannot load driver:  $args{DB_DRIVER} (Perl says: $@)";
            return;
        }
    }
    
    
    # Get a new SQL connection
    $obj->{driver} = $class->newSQL( \%args );
```
-- `Sources/iDatabase/SQL.pm:44-62`

The `$db` object every module receives is a thin wrapper holding `{driver}`,
`{database}`, `{name}`, `{base_dir}`, `{prefix}`. It defines almost no methods
itself. Everything else goes through `AUTOLOAD`:

```perl
sub AUTOLOAD {
    my $obj = $_[0];  # Don't shift
    # Return if it's a destroy method
    return if $AUTOLOAD =~ /::DESTROY$/;
    # Get the method and package name
    my ($pkg, $method) = $AUTOLOAD =~ /^(.*)::([^:]+)$/;
    # If it's a sub to be compiled here, then do so, if not
    # pass it on to the driver module
    if (exists $SUBS{$method}) {
        eval "#line 0 Compile iDatabase::SQL::$method\n$SUBS{$method}";
        if ($@) { die "Unable to compile $pkg::$method $@"; }
        return $iDatabase::SQL::{$method}->( @_ );
    }
    # Still here? forward to the driver then..
    shift @_; # remove this $obj as we need to use $obj->{driver};
    $AUTOLOAD =~ s/^.*:://;
    $obj->{driver}->$AUTOLOAD(@_);
}
```
-- `Sources/iDatabase/SQL.pm:78-95`

Two mechanisms in one. `%SUBS` is a hash of *source text* -- six small accessors
(`driver`, `prefix`, `show_query`, `query_count`, `matched_records`, `error`)
stored as heredocs at lines 112-160 -- compiled into existence on first use and
installed into the symbol table so subsequent calls hit the real sub. Everything
not in `%SUBS` is forwarded verbatim to the driver.

This is a deliberate startup-cost optimization: a request that never asks for
`query_count` never compiles `query_count`. Given that `error` is called 642
times across the tree (`out_subs.txt` section 3.4) and `query` 263 times, the
saving is real but small -- six subs.

Two defects in this file are worth recording, because they explain later
behavior:

- Lines 168-182 define `$SUBS{def_select}`, `$SUBS{def_insert}`,
  `$SUBS{def_update}`, `$SUBS{def_query}`, `$SUBS{def_delete}` -- forwarding
  wrappers keyed under names beginning `def_`. `AUTOLOAD` looks up
  `$SUBS{$method}` where `$method` is `select`, `insert`, and so on. The `def_`
  keys can never match. They are dead code; the plain AUTOLOAD forward at line
  94 does the work instead, with identical effect.
- `reset` (lines 103-108):

  ```perl
  sub reset {
      undef %CONNECTED;
      # Kludge alert!
      no strict 'refs';
      eval {"$DRIVER::disconnect( \@_ )"};
  }
  ```

  `eval { ... }` is a *block* eval whose body is a string literal in void
  context -- it evaluates the string and discards it. And `"$DRIVER::disconnect"`
  interpolates a scalar named `$disconnect` in package `DRIVER`, not `$DRIVER`
  followed by `::disconnect`. Nothing is called. The author's own comment
  ("Kludge alert!") suggests he knew. In practice the mySQL driver disconnects
  from its own `END` and `DESTROY` blocks (`Driver/mySQL.pm:100-105`), so the
  connection does close.

#### 4.2 The driver contract

`iDatabase::Driver::Base` (300 lines) is the interface, expressed as a base
class of no-op stubs that each concrete driver inherits from and overrides.
A driver must provide:

| method | contract |
|---|---|
| `newSQL($args)` | construct + connect; return blessed driver object |
| `connect` / `disconnect` | transport lifecycle |
| `select` | one row by primary key -> hashref |
| `query` | many rows by criteria -> arrayref (or one row for `MATCH => 'ONE'`) |
| `insert` | write a row; return the new primary key |
| `update` | write columns by key or `WHERE` |
| `delete` | remove rows by key or `WHERE` |
| `count` | row count |
| `create_table` / `drop_table` / `drop_tables` / `drop_database` | DDL |
| `lock_table` / `release_lock` | concurrency, flat-file drivers only |
| `back_up` / `table_import` | dump and restore |
| `create_index` / `update_index` / `drop_index` | index maintenance |

`Base` also supplies four *concrete* helpers that every driver actually uses:

- `load_cfg($table)` (lines 266-288) -- `do`es `Database/config/<table>.cfg` and
  populates `cur_table`, `cur_p_key`, `cur_method`, `cur_update`, `cur_ID`,
  `cur_DBID`, `cur_INDEX`, `all_cols`, `total_cols`, `col_name`. Every single
  `select`, `query`, `insert`, `update`, `delete` in every driver begins by
  calling it. That is one `do`-file per query -- cached in `%INC` under mod_perl,
  a fresh disk read and compile under mod_cgi.
- `parse_where($where)` (lines 44-63) -- the pseudo-SQL translator, below.
- `parse_limit($range)` (lines 73-84) -- turns `"0 to 10"` into `LIMIT 0, 11`.
  The comment notes pgSQL needs it "the other way around for some surreal
  reason." In practice `Driver/pgSQL.pm` does not override `parse_limit` at all;
  it ignores the inherited helper and inlines its own `OFFSET`/`LIMIT`
  construction at `Driver/pgSQL.pm:270-276`. The base method is therefore used
  only by mySQL.
- `decode_record` / `encode_record` (lines 228-260) -- split and join a flat
  record on the literal delimiter `|^|`.

`Base` defines `rebuild_record` twice, at line 164 (stub) and again at line 182
(real implementation). Perl takes the later definition; the stub is shadowed.

#### 4.3 How a query is expressed

Callers never write SQL. They pass named arguments:

```perl
	$obj->{'TOTAL_CATS'}   = $db->query(TABLE	  => 'categories',
										SORT_KEY  => 'CAT_POS',
										SORT_BY	  => 'A-Z',
										MATCH	  => 'ALL',
									  )	|| die $db->{'error'};
```
-- `Sources/Boards.pm:47-51`

The full argument set (`Driver/mySQL.pm:208-221`) is `TABLE`, `COLUMNS`,
`SORT_KEY`, `SORT_BY` (`A-Z` / `Z-A`), `WHERE`, `MATCH` (`ONE` / `ALL` /
`WITH COUNT`), `RANGE` (`"0 to 10"`), `COUNT`, `INDEX`, plus `DBID` and `ID`
which are marked `# DEPRECIATED IN SQL` but still passed by callers because the
flat-file drivers need them to pick a file.

`WHERE` is the interesting one. It is neither SQL nor Perl but a hybrid:

```perl
        my $view1 = $db->query( TABLE  => 'topic_views',
                                ID     => $obj->{'.forum_id'},
                                WHERE  => "TOPIC_ID == '$topic_view1' and FORUM_ID eq '$forum_view1' and MEMBER_ID eq '$membid'",
                               );
```
-- `Sources/Topic.pm:67-70`

Perl comparison operators (`eq`, `ne`, `==`, `!=`, `=~`, `!~`) and Perl boolean
words (`and`, `or`, `&&`), on bare column names, with values in single quotes.
`Base::parse_where` converts that dialect toward SQL for the DBI drivers:

```perl
sub parse_where {
    my ($obj, $where) = @_;
    my %ops = ( 'eq' => '=',
                'ne' => '<>',
                '==' => '=',
                '!=' => '<>',
                '=~' => 'REGEXP',
                '!~' => 'NOT REGEXP'
              );
    my %bln = ( 'and' => 'AND',
                'or'  => 'OR',
                '&&'  => 'AND'
              );

    $where =~ s#\s{1,}(eq|ne|==|!=|=~)\s{1,}# $ops{$1} #ig;
    $where =~ s#\s{1,}(and|or|&&)\s{1,}# $bln{$1} #ig;
    # Convert perl / into single quotes...
    $where =~ s#/#'#g;
    return $where;
}
```
-- `Sources/iDatabase/Driver/Base.pm:44-63`

The design intent is clear: pick a syntax the flat-file drivers can execute
*directly as Perl*, and translate it into SQL for the SQL drivers. That is the
right way round -- the cheap backend gets the native form, the expensive backend
pays the translation.

#### 4.4 How the flat-file drivers emulate SQL

`Driver/DBM.pm` is the flat-file driver that shipped as the default. It ties an
`AnyDBM_File` hash per table (preferring `DB_File`, falling back through
`GDBM_File`, `NDBM_File`, `SDBM_File` -- `Driver/DBM.pm:16`), keyed by primary
key, whose values are `|^|`-joined records.

`select` by primary key is a hash lookup, and is genuinely fast. `query` is
where the emulation happens:

```perl
    if ($IN->{'WHERE'}) {
        $statement_string = "(".$IN->{'WHERE'}.")";
        for my $i (0 .. $obj->{'total_cols'}) {
            $statement_string =~ s!(^|[\s\b\(])($obj->{'col_name'}->[$i])([\b\s\)]|$)!$1\$data->{'$2'}$3!g;
        }
        #> Swop SQL LIKE to perl regexp
        $statement_string =~ s#\s{1}NOT LIKE\s{1}# !~ #g;
        $statement_string =~ s#\s{1}LIKE\s{1}# =~ #g;
        #> We assume that /$word/ is looking for a word boundry
        #> and /%$word%/ is looking for any match
        $statement_string =~ s#/(.+?)/#/\\b$1\\b/i#g;
        $statement_string =~ s#/\\b%#/#g;
        $statement_string =~ s#%\\b/#/#g;
    } else {
		$statement_string = qq~(\$data->{ \$obj->{'cur_p_key'} } ne '')~;
	}
```
-- `Sources/iDatabase/Driver/DBM.pm:212-228`

Every bare column name in the `WHERE` is rewritten into `$data->{'COLUMN'}`.
`LIKE` becomes `=~`, `NOT LIKE` becomes `!~`, `/word/` becomes `/\bword\b/i`,
`%` wildcards are stripped. The result is a Perl boolean expression, which is
then compiled into a predicate function:

```perl
    my $eval = qq~sub Check {  my (\$obj, \$data) = \@_; return 1 if $statement_string; }~;
    
    {
        local $@;
        eval $eval; die "Eval Error while parsing:: $statement_string" . $@ if $@;
    }
```
-- `Sources/iDatabase/Driver/DBM.pm:236-241`

and applied to every record in the table:

```perl
	tie (my %DB, $AnyDBM_File::ISA[0], $file, O_RDWR|O_CREAT, 0777) || die "Can't open file ($file) for reading. $!";
    my ($k, $v);
	while (($k, $v) = each(%DB)) {
		my $data = $obj->decode_record($v);
		if ($obj->Check($data)) {
    		push @to_sort, $data;
```
-- `Sources/iDatabase/Driver/DBM.pm:245-251`

So a `WHERE` clause against a DBM board is a **full table scan with a
per-row Perl subroutine call**, and the predicate is recompiled from source on
every query. Sorting (lines 265-288), `RANGE` slicing, and `COUNT` all happen in
memory afterward, because the storage layer cannot do any of it.

The `INDEX` argument is the escape hatch: `Driver/DBM.pm:174-192` redirects an
indexed lookup to `query_index` + `select`, turning a scan into two hash
lookups. The `Tools/create_indexes.cgi` utility in the distribution exists to
build those indexes. On mySQL, `INDEX` degrades to an ordinary
`WHERE key='value'` (`Driver/mySQL.pm:234-242`) because the server has real
indexes.

`Driver/CSV.pm` uses the same technique against line-oriented text files, with
one extra generalization at `Driver/CSV.pm:229-250`: a table whose `MTD` is
`multiple` is a *directory* of per-record files, and the driver `readdir`s it.
The comment is honest about the cost:

```perl
        # Really not recommended!!
        # Huge resource drain on large databases.
        # Only added to allow mySQL intergration.
```
-- `Sources/iDatabase/Driver/CSV.pm:231-233`

**`Driver/CSV.pm` is vestigial and cannot be loaded.** Three independent
confirmations: (a) its package declaration is `package iDatabase;`
(`Driver/CSV.pm:1`), not `iDatabase::Driver::CSV`, so `iDatabase::SQL::new`'s
`$class->newSQL(...)` would call a method in the wrong package; (b) it has no
`newSQL` sub at all and no `@ISA` linking it to `Driver::Base`; (c) the
installer's driver menu offers only four options --

```perl
    my $select = "<select name='DB_DRIVER' class='forminput'><option value='DBM' selected>DBM Database</option><option value='mySQL'>mySQL Database</option><option value='pgSQL'>PostgreSQL Database</option><option value='Oracle'>Oracle Database</option>";
```
-- `install_modules/database.pl:44`

and there is no `Sources/Search/API/api_CSV.pm` to match the four that do exist.
Its own header dates it: "iDatabase v1.0 (May 2001)". It is the Ikonboard 3.0
flat-file engine, left in the tree after the driver API was introduced and never
converted or deleted. Anyone reading this codebase should treat the driver count
as **four live, one fossil**.

#### 4.5 Two schema definitions

The abstraction's real cost is not the five code paths. It is that the schema
exists twice, in two formats, maintained by hand.

**Definition one**: `Database/config/<table>.cfg`, 29 files, read at runtime by
`Base::load_cfg` on every single query.

```perl
package IMPORT;

$STRING = { "TABLE"   => "forum_topics",
            "P_KEY"   => "TOPIC_ID",
            "MTD"     => "single",
            "UPDATE"  => "top",
            "ID"      => "FORUM_ID",
          };
        
%{ $COLS } = (        "TOPIC_ID"          => [0 ,  'update', 10 , 1],
                      "TOPIC_TITLE"       => [1 ,  'string', 70, 1],
                      "TOPIC_DESC"        => [2 ,  'string', 70,  ],
                      "TOPIC_STATE"       => [3 ,  'string', 8    ],
                      ...
```
-- `Database/config/forum_topics.cfg:1-13`

Each column is `[ ordinal, type, width, not-null ]`. The ordinal is what makes
`|^|`-joined flat records decodable. The type vocabulary is `update` (an
auto-incrementing primary key), `num`, `string`, `text`.

**Definition two**: `INSTALL_DATA/mysql_schema.txt`,
`INSTALL_DATA/postgres_schema.txt`, `INSTALL_DATA/oracle_schema.txt` -- 435, 418,
and 436 lines of hand-written DDL, executed statement-by-statement by the
installer (`install_modules/database.pl:228`, `:415`, `:616`) with a regex
rewriting `ib_` to the chosen prefix.

```sql
CREATE TABLE ib_forum_topics (
  TOPIC_ID bigint(10) unsigned DEFAULT '0' NOT NULL,
  TOPIC_TITLE varchar(70) NOT NULL,
  TOPIC_DESC varchar(70),
  ...
  PRIMARY KEY (TOPIC_ID),
  INDEX forum_topics_idx1(FORUM_ID,PIN_STATE,TOPIC_LAST_DATE),
  INDEX forum_topics_idx2(WATCHED,FORUM_ID),
  INDEX forum_topics_idx3(TOPIC_LAST_DATE)
);
```
-- `INSTALL_DATA/mysql_schema.txt:196-222`

The two definitions must agree on column names and order or the board breaks --
`load_cfg`'s `col_name` array drives `decode_record`, and `insert`/`update` skip
any key not present in `all_cols` (`Driver/mySQL.pm:332`, `:436`). They do not
have to agree on types, and they do not: the DDL carries indexes the `.cfg`
knows nothing about, and the `.cfg` carries a `MTD`/`DBID` file-layout model the
DDL knows nothing about.

There is a *third*, generated definition -- `Driver/mySQL.pm:656` `create_table`
derives DDL from the `.cfg` -- and it disagrees with the hand-written one. The
`update` branch has a transcription error:

```perl
        if ($obj->{'all_cols'}->{$entry}[1] eq 'update') {

          if ($obj->{'all_cols'}->{$entry}[2] <= 3) {
                $field .= " TINYINT($obj->{'all_cols'}->{$entry}[2])";
          } elsif ($obj->{'all_cols'}->{$entry}[1] <= 5) {
                $field .= " SMALLINT($obj->{'all_cols'}->{$entry}[2])";
```
-- `Sources/iDatabase/Driver/mySQL.pm:683-688`

The first test reads `[2]` (the width). Every subsequent test in that branch
reads `[1]` -- the type *string* `'update'`, which numifies to 0. So any
auto-increment key wider than 3 falls into the `SMALLINT` arm, capping it at
65,535. `TOPIC_ID` is `[0, 'update', 10, 1]`; the hand-written DDL correctly
says `bigint(10) unsigned`, the generator would say `SMALLINT(10) UNSIGNED`. The
`num` branch immediately below (lines 700-714) has the same structure written
correctly, which is how one can tell it is a copy-paste slip rather than intent.

This does not bite a normal install, because `create_table` returns early unless
`_can_create` is set, and `ikonboard.cgi:286-287` sets it only for DBM. It would
bite anyone using the admin CP's table-creation tools against a SQL backend.

#### 4.6 The tradeoff

The install guide states the goal plainly:

> A host that has installed Perl 5 or better with the DB_file module installed.
> Please note that although we offer MySQL databases for Ikonboard, that it is
> not a requirement to have MySQL capabilities on your site in order to use this
> version of iB.
> -- `ib311/Install_Guide.html`

In 2002 that was the whole ballgame. A $10/month shared host gave you a cgi-bin
and Perl; a database was an upcharge, or unavailable. Ikonboard's competitors
that required MySQL -- phpBB, vBulletin, Invision Board -- were locked out of that
market. iDatabase is what let one codebase serve both the hobbyist on free
hosting and the large board on a real server, and it is why Ikonboard was as
widespread as it was.

What it cost:

- **Five drivers to maintain** (four live), 5,471 lines between them, each
  reimplementing `select`/`query`/`insert`/`update`/`delete` independently.
  They drifted: mySQL's `query` has an `INDEX` shortcut; DBM's has a
  `query_index` indirection; CSV's has a `POP` argument nothing else knows about.
- **A query language nobody else speaks.** `WHERE "MEMBER_ID eq '$id'"` is
  neither SQL nor Perl. Every developer had to learn it, and the translation
  layers are regex substitutions on strings.
- **The abstraction leaks the weakest backend upward.** Because DBM cannot do
  joins, no module uses joins. `Boards::ShowStart` issues three separate
  full-table `query` calls for categories, moderators, and forums
  (`Sources/Boards.pm:47-63`) and the code correlates them in Perl afterward --
  `build_forumjump` does the category/forum join with a `grep`
  (`Lib/FUNC.pm:281`). That is exactly right for DBM and exactly wrong for
  MySQL. The mySQL driver
  supports `LIMIT`, but the flat-file `RANGE` semantics that callers actually
  use mean records are frequently fetched and then discarded.
- **Two hand-maintained schemas** plus a third generated one that disagrees.
- **A `do`-file per query.** `load_cfg` reads and compiles a config file for
  every single database operation.

A parallel abstraction exists for search -- `Sources/Search/api.pm:29` picks
`Search/API/api_<driver>.pm` at load time -- and a third for admin DDL
(`Sources/iDatabase/Admin/a_base.pm` + `a_DBM`, `a_mySQL`, `a_pgSQL`,
`a_Oracle`). The pattern is applied consistently; it is applied three times.

---

### 3.5 The skin engine

#### 5.1 Two files per view

Each view in `Skin/Default/` exists twice: a human-editable `.cfg` template and
a compiled `.pm` module. There are 31 views, 275 template subroutines total
(`out_skin.txt` sections 3.1-2). The board `require`s **only** the `.pm`; the
`.cfg` exists solely so the admin CP can reconstruct the editing form.

The `.cfg` is a flat sectioned text format:

```
HelpView
[=HEADER]
use strict;

[=SUB-row]
#=DESC

#=TOP_LINE
my $entry = shift;


#=BODY

          <!-- Help Entry ID:$entry->{'ID'} -->
          <tr>
            <td bgcolor='$entry->{cell_colour}' align='center' width='10%'>$iB::SKIN->{'C_ON'}</td>
```
-- `Skin/Default/HelpView.cfg:1-18`

The `.pm` is that same content wrapped in Perl:

```perl
package HelpView;
use strict;


sub start {
my ($one_text, $two_text, $three_text) = @_;

return qq~
     <table cellpadding=4 cellspacing='0' border='0' width='$iB::SKIN->{'TABLE_WIDTH'}' align='center'>
```
-- `Skin/Default/HelpView.pm:1-9`

`#=TOP_LINE` becomes the subroutine prologue (argument unpacking), `#=BODY`
becomes the `qq~...~` body, `[=HEADER]` becomes the package preamble. Views are
called as plain package functions, not methods: `HelpView::start(...)`,
`BoardsView::ForumRow(...)`, `ModCPView::startcp(...)`.

#### 5.2 The compiler

`Admin::SkinControl::save_skin` (the block at `Sources/Admin/SkinControl.pm:1096`
onward) is the compiler. It builds both files in one pass:

```perl
	my $config_info = qq~[=NAME]\n$iB::IN{'PKG'}\n[=HEADER]\n$header\n~;
	my $module_info = qq~package $iB::IN{'PKG'};\n\n$header\n\n~;

	# Grab in the form data (and there is a lot!)

	my @input = grep { /^SUB:/ } $iB::CGI->param();
```
-- `Sources/Admin/SkinControl.pm:1129-1134`

For each subroutine, it un-escapes the HTML the browser sent back, neutralizes
the delimiter, and expands the admin-facing tag vocabulary into real Perl
variable syntax:

```perl
		# Sort out the tags...
		$this_sub =~ s!&#60;!<!g;
		$this_sub =~ s!&#62;!>!g;
		# Make tidle's safe
		$this_sub =~ s!~!&#152;!g;
		# Convert $SKIN tags back..
		$this_sub =~ s!<%SKIN:(\w+)%>!\$iB::SKIN->\{'$1'\}!ig;
		# Convert language tags back..
		$this_sub =~ s!<%LANG:(\w+):(\w+)%>!\$$1::lang->\{'$2'\}!ig;
		# Convert $iB::INFO tags back..
		$this_sub =~ s!<%VAR:(\w+)%>!\$iB::INFO->\{'$1'\}!ig;
		# Convert $iB::IN tags back..
		$this_sub =~ s!<%IN:(\w+)%>!\$iB::IN\{'$1'\}!ig;
		# Convert $iB::MEMBER tags back..
		$this_sub =~ s!<%MEMBER:(\w+)%>!\$iB::MEMBER->\{'$1'\}!ig;
```
-- `Sources/Admin/SkinControl.pm:1150-1164`

then appends to both outputs:

```perl
		# Append to the config file.
		$config_info .= qq~[=SUB-$name]\n#=DESC\n$this_desc\n#=TOP_LINE\n$this_top\n#=BODY\n$this_sub\n~;
		# Append to our module.
		$module_info .= qq~sub $name {\n\t$this_top\nreturn qq\~\n$this_sub\n\~;\n}\n\n~;
	}

	# Dont forget the true value for our require file..

	$module_info .= "\n\n1;";
```
-- `Sources/Admin/SkinControl.pm:1174-1182`

And -- this is the part that makes the whole scheme survivable -- it validates
before it commits, by writing to a scratch file and trying to load it:

```perl
	open TESTFILE, ">$module.txt" or $ADMIN->Error(...);
	...
	{
		# Turn off our die catcher...
		local $SIG{__DIE__} = undef;
		# Try and "require" it
		eval { require "$module.txt"; };

		if ($@) {
			unlink "$module.txt";
			my $error = $@;
			$error =~ s!$module.txt!!g;
			$ADMIN->Error( ... MSG => "Error: A mistake has been made when editing, this is no longer a valid perl module. No changes have been made to the existing skin modules or configuration files. ...");
		}
	}
```
-- `Sources/Admin/SkinControl.pm:1189-1208`

Only after a clean `require` are the real `.cfg` and `.pm` overwritten
(lines 1211-1219). An admin cannot save a syntactically broken skin. That is
a well-judged safety net for a system whose fundamental design lets an
administrator type executable Perl into a `<textarea>`.

#### 5.3 Why compile, and what it costs

**Why.** A runtime template parser has to read the template, tokenize it, walk
it, and interpolate -- for every element, on every request. Compiling to Perl
moves all of that to edit time. At runtime a view is a subroutine call returning
an already-interpolated string; the interpolation itself is done by the Perl
interpreter's own string machinery, which is as fast as it gets. Under mod_perl
the compiled view is also cached in `%INC` and never re-parsed at all. For a
2002 shared host running mod_cgi, this is the difference between a forum that
loads and one that times out.

`out_skin.txt` section 3.4 confirms the mechanism is uniform: **274 of 275
template bodies** are `qq~ ... ~`.

**What it costs.**

1. **Admin-editable content is executable Perl.** The boundary between "styling"
   and "code" does not exist. Anyone with `ACCESS_CP` can write arbitrary Perl
   into a skin element and it will run as the web server user on every page
   view. The `eval { require }` validation checks that it *compiles*, not that
   it is safe.
2. **`$` and `@` interpolate.** Inside `qq~...~`, a literal `$` in template
   content is a variable reference. This is why `ikonboard.cgi:530` escapes `$`
   to `&#036;` on every inbound parameter, and why `Base::decode_record` escapes
   `$` to `&#36` on the way out of storage (`Driver/Base.pm:236`). A skin author
   who wants a dollar sign must know to write the entity.
3. **`~` becomes unusable.** It is the `qq` delimiter, so the compiler replaces
   it with `&#152;` (line 1154) -- which is not, in fact, a tilde in any
   character set; `&#152;` is an unassigned C1 control position in Latin-1. A
   tilde typed into a skin element does not survive a round trip.
4. **The two copies drift.** `out_skin.txt` section 3.1 shows **25 of 31** views
   in this shipped distribution have a `.cfg` timestamped *newer* than the `.pm`
   -- most by 15 days (`.cfg` 07/12/2002, `.pm` 06/27/2002). Since the board
   loads only the `.pm`, the templates a purchaser opens in the skin editor are
   not necessarily the markup their board is rendering. Three files are unpaired
   entirely: `Menu.cfg` (no module), `Styles.pm` (no template), `gfx_data.cfg`
   (no module).

`Styles.pm` and `gfx_data.cfg` are the other half of the skin: `gfx_data.cfg`
is the *definition* of each graphical element (label, filename, dimensions,
border, align, alt), and `Styles.pm` is the *compiled* result -- the same
`<img src=...>` tags pre-assembled as strings:

```perl
            'A_FORWARD'             => qq!<img src="$iB::INFO->{'IMAGES_URL'}/Skin/Default/images/t_...
```
-- `Skin/Default/Styles.pm`

Same pattern, one level down: an admin edits structured data, the CP compiles it
to Perl, the board loads only the Perl. `LoadSkin` (`Lib/FUNC.pm:158`) tests for
`Skin/<name>/Styles.pm` specifically when deciding whether a skin exists.

Templates reference 72 distinct `$iB::SKIN` keys, 27 `$iB::INFO` keys, and 29
language namespaces (`out_skin.txt` section 3.3). Thirty-three inline JavaScript
handlers are embedded in template bodies, concentrated in `PostView` (11) and
`MessengerView` (5).

#### 5.4 The outer template

Views produce fragments. The page shell comes from somewhere else entirely -- a
row in the `templates` database table, fetched by
`FUNC::Output::print_ikonboard`:

```perl
	my $template = $IN->{'DB'}->select( TABLE => 'templates', KEY   => $name );
	# Fail safe (incase our assinged skin board template has been deleted..)
	unless ($template->{'TEMPLATE'}) {
		$template = $IN->{'DB'}->select( TABLE => 'templates',  KEY   => 'global' );
	}
```
-- `Sources/Lib/FUNC.pm:902-906`

This one *is* a runtime-substituted template, with `<% TAG %>` placeholders:

```perl
	$it =~ s!<% TITLE %>!$IN->{'TITLE'}!i;
	...
	$it =~ s!<% IB CSS %>!$css_info!i;
	$it =~ s!<% IB JAVASCRIPT %>!$ikonboard_js!is;
	...
	$it =~ s!<% IKONBOARD %>!$IN->{'OUTPUT'}!i;
	...
	$it =~ s!<% NAVIGATION %>!$obj->navigation($IN)!ieg;
	$it =~ s!<% BOARD HEADER %>!Universal::BoardHeader($time)!ieg;
	$it =~ s!<% COPYRIGHT %>!$ib_copy!ig;
	$it =~ s!<% STATS %>!$stats!ig;
```
-- `Sources/Lib/FUNC.pm:917-945`

So Ikonboard 3 has **two** template systems with different syntaxes and
different storage: compiled Perl in the filesystem for page components, and
`<% TAG %>` substitution in the database for the page wrapper. The wrapper is
the one an ordinary admin edits (it is where the site's own HTML goes), which
is presumably why it is the one that stayed interpreted.

`print_ikonboard` also optionally expands SSI directives into the wrapper
(lines 926-930, dispatching to `_get_ssi`, which will fetch over HTTP via
`LWP::Simple` for `virtual` includes), runs an optional whitespace compressor
(lines 953-973), emits the HTTP header with all accumulated cookies, prints, and
calls `iB::exit()`. Its closing comment:

```perl
	# So, after many modules, routines, checks, regex's and other magic,
	# all it takes is two words to make ikonboard appear....

	print $it;
	undef $it;
	# ... talk about an anti-climax.
```
-- `Sources/Lib/FUNC.pm:977-982`

---

### 3.6 Sessions and identity

#### 6.1 What 2.1.9 did

Nothing. `$inmembername = cookie("amembernamecookie")` and `$inpassword =
cookie("apasswordcookie")` (`ib219/cgi-bin/ikonboard.cgi:45-46`), then
a lookup against `members/<name>.cgi`. There was no server-side record of a
logged-in user, no concept of a session, no way for the board to know who was
online except by scanning for recently-modified files, and no way to log anybody
out except by clearing their cookie.

#### 6.2 The `active_sessions` table

Ikonboard 3 adds a real session store. Its schema is one of the 29 table
configs:

```perl
$STRING = { "TABLE"   => "active_sessions",
            "P_KEY"   => "ID",
            "MTD"     => "single",
            "UPDATE"  => "bottom",
          };
        
%{ $COLS } = (        "ID"                  => [0,  'string', 32, 1],
                      "MEMBER_NAME"         => [1,  'string', 32   ],
                      "MEMBER_PASSWORD"     => [2,  'string', 32   ],
                      "MEMBER_ID"           => [3,  'string', 32   ],
                      "THIS_IP"             => [4,  'string', 16, 1],
                      "LAST_LOG_IN"         => [5,  'num'   , 10   ],
                      "USER_AGENT"          => [6,  'string', 80, 1],
                      "RUNNING_TIME"        => [7,  'num'   , 10   ],
                      "MEMBER_LOGSTATE"     => [8,  'num'   , 1    ],
                      "LOCATION"            => [9,  'string', 160  ],
                      "LOG_IN_TYPE"         => [10, 'num'   , 1    ],
                      "MEMBER_GROUP"        => [11, 'num'   , 3    ],
             );
```
-- `Database/config/active_sessions.cfg`

Every visitor gets a row, member or guest. `ID` is the session key,
`RUNNING_TIME` the last-seen timestamp, `LOCATION` a `act|&|QUERY_STRING` pair
that drives the who's-online display, `LOG_IN_TYPE` the anonymous-browsing flag,
`THIS_IP` and `USER_AGENT` the binding material.

Session IDs come from `Sessions::my_gen_id`:

```perl
sub	my_gen_id {
	my $obj = shift;
	srand($$|time);
	my $session = int(rand(600000000000));
	return $mem->MD5(unpack("H*", pack("Nnn", time, $$, $session)));
}
```
-- `Sources/Sessions.pm:478-483`

Time, PID, and a `rand` seeded from `$$|time`, MD5'd. Adequate against casual
guessing, weak against anyone who can observe timing -- but a genuine advance on
having no session at all.

#### 6.3 `authenticate()`

`Sessions::authenticate` (`Sources/Sessions.pm:90-329`) is 240 lines and does
considerably more than authenticate. In order:

1. **Read the raw session parameter.** `$iB::IN{'s'} = $iB::CGI->param('s');`
   then `s!\W!!g` -- bypassing `_clean_value` deliberately, because the filtered
   version would have HTML entities in it (lines 96-100).
2. **IP ban check** against `IP_FILTER`, splitting on `|&|`, converting `\*` to
   `.*` and matching as a regex (lines 103-111).
3. **Discard sentinel cookies.** A logged-out board writes `'-'` into its
   cookies rather than expiring them (`my_bash_cookies`, lines 503-508); this
   step deletes any cookie whose value is `'-'`, plus any password cookie
   shorter than 32 characters (lines 115-119).
4. **Last-visit bookkeeping.** `lastactivity` and `lastvisit` cookies, with a
   two-hour idle heuristic: if the activity cookie is more than 7,200 seconds
   old, treat the previous activity time as the start of a new visit
   (lines 121-138). This is what drives "new since your last visit" markers.
5. **Two abuse heuristics** that route to `$std->Error(LEVEL => 5)`: a session
   cookie containing whitespace, and a `User-Agent` beginning `teleport` (the
   Teleport Pro site-ripper) (lines 140-149).
6. **Choose an authentication path**, via `goto`:

```perl
	if (exists($iB::IN{'UserName'})  and exists($iB::IN{'PassWord'}) ) {
		...
		$in_member   = $iB::IN{'UserName'};
		$in_password = $mem->MD5($iB::IN{'UserName'}, $iB::IN{'PassWord'});
		$method      = 'by name';
		goto AUTHENTICATE;
	} elsif
		($session_cookie) {
			$this_session = $obj->get_session( DB=> $db, ID => $session_cookie);
		goto CHECK;
	} elsif
		($iB::IN{'s'}) {
			$this_session = $obj->get_session( DB=> $db, ID => $iB::IN{'s'});
		goto CHECK;
	}
```
-- `Sources/Sessions.pm:151-180`

   Form credentials win; then the session cookie; then the URL session
   parameter. Passwords are stored as `MD5(lc(username) . password)` --
   see `FUNC::Member::MD5` at `Lib/FUNC.pm:1391-1399`, which adds `$Pass` then
   `$Name` to the digest with the name lowercased. The username acts as a salt.

7. **Legacy password upgrade.** If the stored hash is shorter than 32 characters
   it is a 2.x DES `crypt` value, so the board re-computes `crypt` with the
   first two lowercased characters of the username as salt, and on a match
   rewrites the row with the MD5 form (lines 195-206). Silent, transparent
   migration on first login.
8. **Update or create the session row** (lines 226-242 for members, 276-292 for
   guests), writing `LOCATION`, `RUNNING_TIME`, `THIS_IP`, `USER_AGENT`,
   `MEMBER_GROUP`.
9. **Emit the session cookie** (line 296), fall back to a synthetic guest record
   if nobody authenticated (`SetUpGuest`, line 298, `Lib/FUNC.pm:1403`), load
   the member's group row from `mem_groups` into `$iB::MEMBER_GROUP` (line 300),
   and refuse the request entirely if that group lacks `VIEW_BOARD` (line 304).
10. **Email ban check** and skin/language cookie seeding (lines 309-325).

The function returns the member hashref, which `ikonboard.cgi:352` assigns to
`$iB::MEMBER`. From that point every module reads `$iB::MEMBER` and
`$iB::MEMBER_GROUP` directly.

#### 6.4 Session binding

`get_session` is where a session is validated rather than merely looked up:

```perl
	my $session = $IN->{'DB'}->select( TABLE => 'active_sessions', KEY => $IN->{'ID'} );

	return {} unless $session->{'ID'};

	if ($session->{'MEMBER_ID'}) {

		if ($iB::IN{'IP_ADDRESS'} ne $session->{'THIS_IP'}) {
			return {};
		}

		if ($iB::INFO->{'CHECK_USER_AGENT'}) {
			return {} unless $ENV{'HTTP_USER_AGENT'} eq $session->{'USER_AGENT'};
		}
	}
	return $session;
```
-- `Sources/Sessions.pm:375-389`

A member session is bound to the IP that created it, and optionally to the exact
`User-Agent` string (`CHECK_USER_AGENT = 1` in the shipped defaults). Guest
sessions are unbound. Stealing a session cookie is therefore not sufficient
unless you also match the IP -- which is a meaningful control in 2002 and a
usability disaster for anyone behind a rotating proxy, which is precisely why
`ikonboard.cgi:317` prefers `HTTP_X_FORWARDED_FOR` and `active_users` has to
de-duplicate by name:

```perl
			# XXX Remove Dupes (this occurs if a user is on a proxy, and their IP changes).

			next if exists $obj->{'seen_name'}->{ $session->{'MEMBER_NAME'} };
```
-- `Sources/Sessions.pm:55-57`

#### 6.5 Expiry and the who's-online list

`clean_sessions` runs on session creation, not on a timer:

```perl
	$iB::INFO->{'SESSION_EXPIRATION'} = (time - $iB::INFO->{'SESSION_EXPIRATION'});

	$IN->{'DELETE'} = qq! or (THIS_IP eq '$iB::IN{'IP_ADDRESS'}' and (MEMBER_ID eq '$IN->{'MEMBER'}->{'MEMBER_ID'}' or MEMBER_GROUP == '2'))! if $IN->{'DELETE'} == 1;

	$db->delete( TABLE    => 'active_sessions',
				 WHERE    => "RUNNING_TIME < $iB::INFO->{'SESSION_EXPIRATION'}$IN->{'DELETE'}",
				 SORT_KEY => 'RUNNING_TIME',
			   );
```
-- `Sources/Sessions.pm:466-473`

Every new session sweeps out rows older than `SESSION_EXPIRATION` (3,000 seconds
by default) *and* any prior session from the same IP for the same member, plus --
note the hardcoded `MEMBER_GROUP == '2'` -- any guest session from that IP. So a
guest who logs in does not leave a ghost. Garbage collection is amortized across
logins rather than scheduled, which is the only option available to a CGI
program with no daemon.

`active_users` (lines 39-87) is the read side: a single `WHERE RUNNING_TIME >
(time - 900)` scan, sorted descending, tallying members, anonymous members,
guests, and a per-forum occupancy count parsed out of the `LOCATION` field:

```perl
		if ($session->{'LOCATION'}) {
			my ($act, $q_string) = split (/\|&\|/, $session->{'LOCATION'});
			if ( $q_string =~ m{[&;\?]f=(\d+)(?:[&;|]|$)} ) {
				$obj->{'active_forums'}->{$1}++;
			}
		}
```
-- `Sources/Sessions.pm:68-73`

The board recovers "who is in which forum" by regex-matching the stored query
string. Note the 900-second window here versus the 3,000-second expiry: a
session survives for 50 minutes but only shows as "online" for 15.

---

### 3.7 The language layer

Ikonboard 2.1.9 had English hardcoded in the CGI scripts, interleaved with the
HTML that displayed it. Ikonboard 3.1.1 extracts every user-visible string into
`Languages/<code>/<Area>Words.pm` -- 29 files for English, one per functional
area, 2,344 lines total.

#### 7.1 The file format

Same convention as `Boardinfo.cgi` and `Styles.pm`: a package with one
constructor returning a hashref.

```perl
package HelpWords;


sub new {
  my $pkg = shift;
  my $obj = {
   
#+----------------------------------------------------------------------
#| Do Not remove or edit anything above this line!
#| Only Edit the words on the right of the => arrow
#+----------------------------------------------------------------------

page_title      => "Ikonboard Help Files",
help_txt        => "Welcome to the Ikonboard help database....",

submit          => "Search!",
```
-- `Languages/en/HelpWords.pm:1-16`

Values are `"`-quoted and therefore *interpolating* -- a language file can
contain `$iB::INFO->{'BOARD_URL'}` and it will expand. None of the 29 shipped
English files declares `use strict` (`out_subs.txt` section 3.2 lists all of
them), which is consistent with them being edited by translators rather than
programmers, and with the admin CP's `Admin::LangControl` (732 lines) writing
them mechanically.

#### 7.2 `LoadLanguage`

```perl
sub	LoadLanguage {
	my ($obj, $area) = @_;
	my ($lang, $default);
	local $@;

	# Make sure the cookie data is legal
	if ($iB::COOKIES->{$iB::INFO->{'COOKIE_ID'}.'lang'}) {
		$iB::COOKIES->{$iB::INFO->{'COOKIE_ID'}.'lang'} =~ s/^([\d\w]+)$/$1/;
	}

	$default = $iB::COOKIES->{$iB::INFO->{'COOKIE_ID'}.'lang'}
			|| $iB::INFO->{'DEFAULT_LANGUAGE'}
			|| 'en';

	# Quick check to make sure the directory exists

	unless (-d $iB::INFO->{IKON_DIR}."Languages/$default") {
		$default = 'en';
	}

	my $code = 'require '. "\"$default/" .$area. '.pm"; $lang ='. $area. '->new();';
	eval $code;

	$obj->cgi_error("Could not access the language file: $@") if $@;
	return $lang;
}
```
-- `Sources/Lib/FUNC.pm:184-209`

Language selection is: `lang` cookie -> `DEFAULT_LANGUAGE` -> `en`, with a
directory existence check as the backstop. Then another string `eval`, this one
building `require "en/HelpWords.pm"; $lang = HelpWords->new();`.

Note the difference from the dispatcher eval. `$area` is always a literal
supplied by the calling module, so that half is safe. `$default` comes from a
**cookie**. The sanitization is `s/^([\d\w]+)$/$1/` -- a substitution that
replaces the string with itself when it matches and *leaves it untouched when it
does not*. It filters nothing. The `-d` existence check at line 200 is what
actually constrains the value, and it constrains it to directory names that
exist under `Languages/`. A precise treatment belongs in the security chapter;
architecturally, the point is that the language layer, like the dispatcher and
like the flat-file `WHERE` compiler, is built on string eval -- that is this
codebase's idiom for "late binding," used three times in three subsystems.

#### 7.3 How modules reach strings

Each module loads its own area into a package global named for itself, at file
scope:

```perl
my $std      = FUNC::STD->new();
...
$Legends::lang = $std->LoadLanguage('LegendsWords');
```
-- `Sources/Legends.pm:24-27`

and then indexes it directly:

```perl
    $html  = LegendsView::card_header( $Legends::lang->{'ibc_title'},
                                       $Legends::lang->{'ibc_text'}
                                     );
```
-- `Sources/Legends.pm:44-46`

The convention is rigid enough that the skin compiler can round-trip it: an
admin editing a template sees `<%LANG:Legends:ibc_title%>`, and
`Admin::SkinControl.pm:1158` rewrites it to `$Legends::lang->{'ibc_title'}` on
save. `out_skin.txt` section 3.3 counts 29 language namespaces referenced across
the views, led by `UserCP` (125 keys), `ModCP` (115), `Messenger` (78), `Post`
(68).

Two universal namespaces are loaded outside any module: `$Universal::lang` in
`FUNC::STD::new` (`Lib/FUNC.pm:34`), so every object construction seeds it; and
`$iBoard::lang` inside `FUNC::STD::Error` (`Lib/FUNC.pm:643`), loaded lazily
because error text is only needed when something fails. `Error` also supports a
single positional substitution:

```perl
	if ($IN{'EXTRA'}) {
		$iBoard::lang->{ $IN{'MESSAGE'} } =~ s!<#VAR#>!$IN{'EXTRA'}!g;
	}
```
-- `Sources/Lib/FUNC.pm:648-650`

-- which mutates the loaded hash in place. Under mod_perl that mutation persists;
see section 3.8.

The design cost of the whole layer is that language is bound to a **package
global assigned at module load**, not to the request. Under mod_cgi that is
identical to per-request. Under mod_perl it is not.

---

### 3.8 mod_perl

#### 8.1 What ships

Three pieces:

- `Sources/iPerl/mod_perl.pm` -- an 83-line preloader.
- `Tools/mod_perl/start_up.pl` -- the `PerlRequire` script.
- The resets and the `exit` override in `ikonboard.cgi` (section 3.2.2).

`start_up.pl` compiles CGI.pm's exports up front and then pulls in the
preloader, defensively:

```perl
use CGI ();
CGI->compile (':all');
...
{
    local ($@);
    # Supress warnings
    $^W = undef;
    eval "require iPerl::mod_perl";
    unless ($@) {
       use iPerl::mod_perl;
    } else {
       print STDERR "Could not Preload Ikonboard: $@";
   }
}
```
-- `ib311/Tools/mod_perl/start_up.pl`

`iPerl::mod_perl` detects the environment properly -- it distinguishes mod_perl
from FastCGI, which many contemporaries did not:

```perl
# Are we sure about the mod_perl? It's not fast_cgi is it?
$MOD_PERL  = (exists $ENV{GATEWAY_INTERFACE} and ($ENV{GATEWAY_INTERFACE} =~ /^CGI-Perl/)) ? 1 : 0;
BEGIN { if ($MOD_PERL) { require Apache::DBI; } }
```
-- `Sources/iPerl/mod_perl.pm:18-19`

`Apache::DBI` is the connection-pooling layer -- with it, `DBI->connect` in
`Driver/mySQL.pm:82` returns a cached handle instead of opening a new socket per
request. That alone is most of the win.

It then preloads seventeen modules (lines 45-61) -- the hot path: `Boards`,
`Forum`, `Post`, `Register`, `Search::api`, `Sessions`, `Topic`, `NotePad`,
`Posters`, `Newest`, `iTextparser`, `iDatabase::SQL`, and the five `UserCP`
modules -- and finally precompiles the entry script itself through
`Apache::RegistryLoader` (lines 68-78). Paths are hardcoded to the author's own
test board (`/home/ikonboar/public_html/z_test`) and must be edited; the file
opens with a warning that a mistake here can stop Apache restarting.

The board also *reports* whether it is under mod_perl, in the admin stats box:

```perl
				<td width='60%' bgcolor='$iB::SKIN->{MISCBACK_TWO}'>Mod Perl?</td>
				<td bgcolor='$iB::SKIN->{MISCBACK_TWO}'>@{[ $ENV{MOD_PERL} ? 'Yes' : 'No' ]}</td>
```
-- `Sources/Lib/FUNC.pm:879-880`

#### 8.2 The hazard

Under mod_cgi, a module's file scope runs once per request and is thrown away.
Under mod_perl, a module is compiled **once per Apache child process** and reused
for every subsequent request that child serves -- including requests from
different users. Any `my` variable at file scope is therefore a per-child
singleton with a lifetime of minutes to hours.

`ikonboard.cgi:77-83` clears the seven `$iB::*` globals for exactly this reason.
It cannot clear a file-scoped lexical inside a module, because that lexical is
not reachable from outside its own file. `out_subs.txt` section 3.3 finds **95
files** holding state at file scope.

Most of them are harmless, and it matters to say which. The triage:

**Category A -- stateless service objects. Not a problem.**

```perl
my $std      = FUNC::STD->new();
my $mime     = MimeTypes->new();
my $output   = FUNC::Output->new();
my $poll     = iPoll->new();
```
-- `Sources/Topic.pm:26-29`

`FUNC::STD->new()` returns `bless {}, $pkg` (`Lib/FUNC.pm:30-36`). It carries no
per-request data; it exists only to give the subs a method-call syntax. Caching
it across requests is not merely safe, it is the *point* -- it is the one thing in
this file that mod_perl genuinely optimizes. The same applies to the
`$ADMIN`/`$INFO`/`$SKIN`/`$std`/`$mem` quintet at the top of all 34
`Sources/Admin/*.pm` files, and to the lookup tables in `Lib/Crypt.pm` (the DES
S-boxes, `@SPtrans0..7`, `@skb0..7`) and `Mail/Sendmail.pm` (the four
address-validation regexes). Roughly 80 of the 95 files are this case.

**Category B -- configuration snapshots. Stale, not dangerous.**

```perl
my $INFO = Boardinfo->new();
```
-- `Sources/Lib/FUNC.pm:27`, and the same line in `Admin/Functions.pm:30`,
`Lib/ADMIN.pm`, `Makelog.pm`

A private copy of the configuration, taken at module load. `LoadSkin` reads
`$INFO->{'DEFAULT_SKIN'}`, `$INFO->{'SKINS'}`, `$INFO->{'ALLOW_SKINS'}` from
this copy, not from `$iB::INFO` (`Lib/FUNC.pm:108`, `:123`, `:138-139`). If an
administrator changes a board setting, `Boardinfo.cgi` is rewritten on disk but
these snapshots are not -- the change takes effect only when the Apache child
recycles. Confusing, occasionally infuriating to debug, but not a data-leak.

**Category C -- genuinely per-request data at file scope. These are the real
ones.**

| file | lexical | what it holds | severity |
|---|---|---|---|
| `Sources/Forum.pm:32` | `$fcookie` | *this visitor's* forum "mark as read" cookie, plus a mutation of `$iB::last_visit` | high |
| `Skin/Default/MessengerView.pm:4` | `$base_url` | a URL with `s=$iB::SESSION` interpolated into it | high |
| `Sources/Boards.pm:26` | `$cats_printed` | categories rendered so far, never reset | low (consumer is commented out) |
| `Sources/Online.pm:31` | `$html` | page output accumulator | low (reassigned with `=` at line 121 before use) |
| `Skin/Default/MenuView.pm:3` | `$base_url` | a URL prefix with no session in it | none |

`Sources/Forum.pm:32-33` is the clearest case:

```perl
my $fcookie = $iB::COOKIES->{ $iB::INFO->{COOKIE_ID}.'forum-'.$iB::IN{f} };
$iB::last_visit	= $fcookie && $fcookie > $iB::last_visit ? $fcookie : $iB::last_visit;
```

This runs *at module load*, reading the current visitor's cookie jar and the
current request's `f` parameter, and then **mutating a global**. Under mod_perl,
`Forum.pm` is loaded by the first request to a forum in that child. `$fcookie`
freezes that visitor's read-marker. Every later request served by the same child
skips both lines entirely (the module is already in `%INC`), so
`$iB::last_visit` never gets the per-forum override -- the "new posts since your
last visit" markers silently stop working for everyone after the first user.
Not a disclosure bug, but a visible correctness bug that only appears under
mod_perl.

`Sources/Boards.pm:26` `my $cats_printed = {}` accumulates keys at line 472 and
is never reset. The consumer that would have made this a rendering bug is
commented out:

```perl
		#next if exists	$cats_printed->{ $this_cat->{'CAT_ID'} };
```
-- `Sources/Boards.pm:322`

so today it is only an unbounded memory leak in the child, one hash key per
category per request. Worth naming because uncommenting that line -- an obvious
thing for a maintainer to try -- would turn a leak into "categories vanish from
the board index after the first page view."

`Skin/Default/MessengerView.pm:4` is the one with a visible consequence for a
different user:

```perl
my $base_url = qq[$iB::INFO->{'BOARD_URL'}/ikonboard.$iB::INFO->{'CGI_EXT'}?s=$iB::SESSION;act=UserCP;CODE=];
```

`$iB::SESSION` is interpolated at module load. Under mod_perl every private-
messaging link rendered by that Apache child, for every subsequent visitor,
carries the session ID of whoever loaded the module first. Its sibling
`Skin/Default/MenuView.pm:3` builds the same kind of prefix *without* the `s=`
parameter and is harmless -- the two files are one character of divergence apart
in risk.

**Category D -- the language and skin bindings.** These are file-scope by
convention across the entire front end:

```perl
$Topic::lang = $std->LoadLanguage('TopicWords');
```
-- `Sources/Topic.pm:30`

The language is chosen from the visitor's `lang` cookie (section 3.7.2). At file
scope, under mod_perl, the *first* visitor to hit that module in a given child
picks the language for everyone that child subsequently serves. The same applies
to `$Universal::lang`, seeded inside `FUNC::STD::new`, and to the in-place
mutation of `$iBoard::lang` in `FUNC::STD::Error` (`Lib/FUNC.pm:649`), which
permanently substitutes one request's `EXTRA` value into a shared error string.

The skin has an identical problem by a different route. Views are loaded with
`require`, which is `%INC`-cached:

```perl
    require $iB::SKIN->{'DIR'} . '/TopicView.pm' or die $!;
```
-- `Sources/Topic.pm:51`

Every skin's `TopicView.pm` declares `package TopicView`. A second visitor using
a different skin has a different `%INC` key (`Blue/TopicView.pm` vs
`Default/TopicView.pm`), so the file *is* loaded -- and its subs overwrite the
first skin's in the shared `TopicView` namespace. Whoever loaded last wins, for
everybody, until the child recycles. Forty-five `require $iB::SKIN->{'DIR'}`
sites exist across `Sources/`; five of them -- `Profile.pm:17`, `Misc/AOL.pm:26`,
`Misc/ICQ.pm:27`, `Misc/MSN.pm:27`, `Misc/Invite.pm:29` -- sit at file scope,
which makes them first-request-wins instead of last-request-wins.
`ikonboard.cgi:340` uses `do` rather than
`require` for `Universal.pm` specifically to sidestep this -- evidence the issue
was understood, but only fixed in one place.

**The honest summary**: mod_perl support in Ikonboard 3.1.1 is real and
carefully done at the *entry script* level -- the globals are reset, `exit` is
overridden, the preloader is correct, `Apache::DBI` is pulled in. It was not
carried through into the modules, where the file-scope idiom that is free under
CGI becomes cross-request state. A board running mod_perl with a single skin and
a single language would mostly work; multi-skin or multi-language boards would
exhibit exactly the kind of intermittent, un-reproducible weirdness that gets
reported as "sometimes the board is in German."

---

### 3.9 Request lifecycle

One row per stage of a normal front-end page view (`act=ST`, member, DBM
backend). Line references are to `ikonboard.cgi` unless noted.

| # | stage | code | reads | can fail on |
|---|---|---|---|---|
| 1 | Interpreter start | Apache spawns perl, or reuses a mod_perl child | `@INC`, `MOD_PERL` env | missing modules; relative `@INC` if Apache did not chdir |
| 2 | Global reset | 77-83 | -- | (mod_perl only; no-op under CGI) |
| 3 | Handler install | 102-109 | -- | -- -- but silently discards two warning classes for the rest of the request |
| 4 | Config load | 123-124 | `Data/Boardinfo.cgi` | file absent (never installed) -> die -> `catch_die` HTML page |
| 5 | Installer guard | 135-140 | `installer.cgi`, `install.lock` on disk | refuses to run if installer present without lock |
| 6 | CGI setup | 147-171 | `QUERY_STRING`, POST body | `POST_MAX` exceeded -> CGI.pm truncates; upload temp dir unwritable |
| 7 | Input filter | 175-180 | all CGI params | none -- non-matching keys become `undef`, values are HTML-escaped |
| 8 | Cookie read | 186-192 | `HTTP_COOKIE` | none; non-prefixed cookies ignored |
| 9 | Library load | 202-204 | `Lib/FUNC.pm`, `Sessions.pm`, `iDatabase/SQL.pm` | compile error -> die |
| 10 | Password bootstrap | 211-267 | `Data/*.pwd` | `Data/` unwritable on first run -> die; forks `ps`/`gzip` |
| 11 | DB connect | 289-303 | driver module, `DB_*` config | driver missing -> "Cannot load driver"; `newSQL` die on connect failure |
| 12 | Service objects | 307-308 | -- | `Lib/FUNC.pm` BEGIN requires `Boardinfo.cgi` and `Default/Universal.pm` |
| 13 | Client IP | 317-324 | `HTTP_X_FORWARDED_FOR`, `REMOTE_ADDR` | header is client-controlled |
| 14 | Entry validation | 335 (`Lib/FUNC.pm:395`) | `HTTP_REFERER`, `/proc/loadavg` | referer mismatch on `act=Post`; load over `LOAD_LIMIT` -> "server too busy" |
| 15 | Skin load | 338-340 (`Lib/FUNC.pm:99`) | `sid` param, `skin` cookie, `SKINS`, `FORUM_SKINS`; `Skin/<dir>/Styles.pm`, `Universal.pm` | missing `Styles.pm` -> falls back to `Default`; compile error -> `catch_die` |
| 16 | Authenticate | 352 (`Sessions.pm:90`) | `s` param, session/member/password cookies; `active_sessions`, `member_profiles`, `mem_groups` | IP ban; user-agent ban; IP/UA mismatch drops the session; group lacks `VIEW_BOARD` |
| 17 | Active users | 353 (`Sessions.pm:39`) | `active_sessions` scan, 900 s window | full table scan on flat-file backends |
| 18 | Admin divert | 396-401 | `AD` / `CP` | -> stage 3 dispatcher; three gates in `Admin/Functions.pm:46-102` |
| 19 | Board gates | 404-421 | `B_ONLINE`, `ACCESS_OFFLINE`, `FORCE_LOGIN` | offline page; forced login redirect |
| 20 | Dispatch | 424-489 | `act` | cannot fail -- unknown `act` forced to `BoardIdx` at 474 |
| 21 | Module compile | 485-489 `require` | `Sources/<Module>.pm` | syntax error -> `$@` -> `cgi_error` |
| 22 | Module BEGIN | e.g. `Topic.pm:20-31` | `Lib/FUNC.pm`, language file, view module | missing `Languages/<code>/<Area>Words.pm` -> `cgi_error` |
| 23 | Second dispatch | module `%Mode` on `CODE` | `CODE` | undefined handler -> die at call time |
| 24 | Queries | `$db->query(...)` | `Database/config/<table>.cfg` per call, then storage | `load_cfg` die on missing cfg; driver die on SQL error; flat-file scan cost |
| 25 | View render | `<View>::sub(...)` | `Skin/<dir>/<View>.pm`, `$iB::SKIN`, `$<Mod>::lang` | uninterpolated `$` in template; missing lang key renders empty |
| 26 | Page assembly | `Lib/FUNC.pm:794` | `templates` table row, `ikonboard.js`, `ikonboard.css` | missing template row -> falls back to `global`; SSI fetch failure -> "[an error occurred...]" |
| 27 | Header + output | `Lib/FUNC.pm:975-983` | `@{$iB::COOKIES_OUT}` | header already sent (guarded by `$iB::CONTENT->{'HTTP'}`) |
| 28 | Exit | `iB::exit()` | -- | `Apache::exit` under mod_perl, `CORE::exit` otherwise |

Stages 4-17 run identically for every request regardless of what it is asking
for. Only stages 20-26 vary by action.

---

### 3.10 Uncertainties and open questions

Flagged rather than asserted:

1. **`Boardinfo.cgi` is reconstructed, not observed.** The key list above comes
   from `ikonboard.conf` plus `write_boardinfo`. A real installed board would
   also carry `mySQL_DB_PASS`, `pgSQL_DB_PASS`, `Oracle_DB_PASS`, `DBM_DB_PASS`
   (from `ikonboard.cgi:231-250`), `DEFAULT_SKIN`, `SKIN_TEMPLATES`,
   `DEFAULT_LANGUAGE`, `AU_FORMAT`, `LOAD_LIMIT`, `DOC_TYPE`, `TEMPLATE_TAGS`,
   `EVENT_FORUM`, and `WEB_RING`, all of which are read by consumers but absent
   from `ikonboard.conf`. The installer presumably adds them; without an
   installed tree that cannot be confirmed.
2. **`Sources/Post2.pm` versus `Sources/Post.pm`.** 1,610 and 1,623 lines, 36
   subs each, near-identical. `Post2.pm` is not named by the dispatcher and is
   not `require`d by anything in the tree. `out_delta.txt` maps 2.1.9's
   `postings.cgi` to it. Whether it is a live alternate posting path activated
   by some configuration, or an abandoned fork, could not be determined from the
   distribution alone. The same question applies to `iTextparser2.pm` (identical
   line count to `iTextparser.pm`).
3. **`Sources/Makelog.pm`** declares `use iDatabase;` and calls
   `iDatabase->new()`, `->connect()`, `->prepare(INSERT => ..., WHERE => 'at
   bottom')` -- an API that does not exist in `iDatabase::SQL` or in any driver.
   It is 2.x-era or 3.0-era vestige. Nothing references it.
4. **The `Sources/iDatabase/Admin/` hierarchy is only partly implemented.**
   `a_base.pm::install_database` is a documented stub whose body is `return;`.
   `a_mySQL.pm` (declaring `package iDatabase::Admin::SQL`, not
   `::a_mySQL` -- the same package-name mismatch as `Driver/CSV.pm`) has a real
   implementation. How the four `a_*.pm` files are actually selected at runtime
   was not traced past `Admin/dbHandler.pm:364`.
5. **The `qq!` versus `qq~` split in generated files** is consistent within each
   generator but not across them, and `_clean_value` escapes `!` to `&#33;`
   while the skin compiler escapes `~` to `&#152;`. Whether a value can round-trip
   through both generators unharmed was not exhaustively tested.
6. **Nothing here has been executed.** Ikonboard 3.1.1 requires Perl 5.004+ with
   `DB_File` and, for the SQL backends, `DBI` plus a `DBD`; the code uses
   idioms (`goto LABEL` out of conditionals, bareword filehandles, `local $^W`)
   that modern Perl still accepts but that a modern toolchain will not
   necessarily reproduce faithfully. Every claim in this chapter is derived from
   reading the source, not from running it.

---

## 4. Module reference

This chapter is a lookup table, not a narrative. It documents every Perl module
shipped in the `cgi-bin/` tree of Ikonboard 3.1.1 (Jarvis Entertainment Group,
Inc., July 2002): what each file is, what it dispatches, what it loads, and where
it is broken, unreachable, or unfinished.

The tree measured here is 179 Perl files, 72,805 lines, 1,624 subroutines, of
which 74% carry `use strict`. The tree was never installed, so
`cgi-bin/Boardinfo.cgi` -- the generated configuration module every part of the
product loads -- is absent. Any module whose `BEGIN` block says
`require 'Boardinfo.cgi'` will refuse to compile as shipped. That is a property
of the archive copy, not of the software; the installer writes `Boardinfo.cgi` as
its last act.

---

### 4.1 How to read this chapter

#### 1.1 The three dispatch stages

Ikonboard 3 is a single-entry-point CGI. Every request in the entire product --
board index, a post, an avatar upload, the whole admin control panel -- arrives at
`ikonboard.cgi` and is routed by up to three successive lookups.

**Stage 0 -- the admin fork.** Before any board routing happens,
`ikonboard.cgi:396` checks two query parameters:

```perl
    if ($iB::IN{'AD'} or $iB::IN{CP}) {
        require Admin::Functions;
        my $ad = Admin::Functions->new();
        $ad->process($db);
        return "0 but true";
    }
```

If either is set, the request never reaches the board dispatcher at all; it is
handed to `Admin::Functions->process($db)` and stage 1/2 below are replaced by the
admin control panel's own two-level dispatch. `AD` and `CP` are synonyms:
`ikonboard.cgi:180` does `$iB::IN{AD} ||= $iB::IN{CP};` and the admin output
layer rewrites `AD=1` to `CP=1` in every URL and form it emits
(`Sources/Lib/ADMIN.pm:94-98`). The stated reason is in a comment repeated
verbatim in three places: `AD=1` looked like an ad-server parameter and was being
eaten by banner blockers.

**Stage 1 -- `act=` selects a module and a method.** `ikonboard.cgi:424-470`
declares a 44-entry `%Mode` hash mapping the `act` parameter to a
`[ 'Package::Name', 'method' ]` pair, then builds and `eval`s the call:

```perl
    my $code = 'require '.$Mode{ $iB::IN{'act'} }[0].';'.
               'my $idx = '.$Mode{ $iB::IN{'act'} }[0].'->new();'.
                  '$idx->' .$Mode{ $iB::IN{'act'} }[1].'($db);';

    eval $code;
```

The comment above it shows the intent plainly ("Nice little hack to save writing
loads of subroutines for each action"). Two guards precede it:
`$iB::IN{'act'} = 'BoardIdx' if $iB::IN{'act'} eq '';` and
`$iB::IN{'act'} = 'BoardIdx' unless exists $Mode{ $iB::IN{'act'} };` -- an unknown
`act` silently becomes the board index rather than an error. Because the module
name is taken from the hash and never from user input, the `eval STRING` is not
an injection point.

**Stage 2 -- `CODE=` selects a subroutine inside that module.** Every non-trivial
front-end module repeats the pattern in its own `Process`:

```perl
sub	Process {
    my ($obj, $db) = @_;
    my $CodeNo = $std->CheckCodeNo($iB::IN{'CODE'});
    my %Mode = ( '00'     => \&Show_titles,
                 '01'     => \&Show_section,
                 '02'     => \&do_search,
               );
    require	$iB::SKIN->{'DIR'} . '/HelpView.pm'	or die $!;
    $Mode{$CodeNo} ? $Mode{$CodeNo}->($obj,	$db) : HelpError($obj, $db);
}
```
-- `Sources/Help.pm:154-163`

Two `CODE` conventions coexist. The older modules use two-digit numeric codes
(`00`, `01`, ...) validated by `FUNC::STD::CheckCodeNo`, which insists on exactly
two digits (`Sources/Lib/FUNC.pm:530-536`). The newer modules -- ModCP, ModSet,
Welcome, Happybd, Legends, Online, Calendar, PrintPage, Massmsend, and the whole
admin CP -- use word codes (`topic_search`, `do_edit`, `emoticons`) and read
`$iB::IN{'CODE'}` raw. A handful of modules use both styles in the same
subsystem: `Sources/Calendar.pm` accepts only `CODE=1`, a single digit, which
`CheckCodeNo` would have rejected -- Calendar reads `CODE` raw for that reason.

**Stage 2, admin variant.** In the control panel, `act=` is consumed a second
time: `Admin::Functions::process` (`Sources/Admin/Functions.pm:104-141`) maps
`act` to one of 33 loader subs, each of which is three lines:

```perl
sub	forum {
    my ($obj, $db) = @_;
    require 'Admin/ForumControl.pm';
    my $idx = Admin::ForumControl->new()->process($db);
}
```
-- `Sources/Admin/Functions.pm:327-331`

The loaded admin module then does its own `%Mode` lookup on `CODE`. So an admin
URL carries three routing parameters: `CP=1`, `act=forum`, `CODE=do_add`.

#### 1.2 The universal construction convention

Every front-end module obeys the same contract, because `ikonboard.cgi` builds
the call string from it:

* the package is named exactly as the `%Mode` entry says (`Topic`, `Misc::AOL`,
  `UserCP::Messsend`);
* it provides `sub new` that blesses a hash ref and returns it, taking no
  meaningful arguments;
* it provides the named entry method -- `Process` for 38 of the 44 actions,
  and a module-specific name for the other six (`ShowTopic`, `ShowForum`,
  `ShowRules`, `ShowStart`, `shownewest`, `showposter`);
* the entry method receives exactly one argument: the live `iDatabase::SQL`
  handle.

The admin modules obey a parallel contract -- `Package->new()->process($db)` with
a **lowercase** `process` -- with exactly one exception:
`Admin::WebRing` is invoked as `Admin::WebRing->new()->Process($db)`
(`Sources/Admin/Functions.pm:352`), and defines `sub Process` accordingly.

#### 1.3 The module preamble

Almost every module opens with the same six-part preamble. Understanding it once
removes the need to repeat it 100 times below:

```perl
package	Help;
use	strict;
BEGIN {	require	'Lib/FUNC.pm'; }

my $std		 = FUNC::STD->new();
my $output	 = FUNC::Output->new();
my $mem		 = FUNC::Member->new();

$Help::lang	= $std->LoadLanguage("HelpWords");
```
-- `Sources/Help.pm:1-22`

1. `package` + `use strict` (in 74% of files).
2. A `BEGIN` block that `require`s `Lib/FUNC.pm`, and often `iTextparser.pm`.
3. File-scoped lexicals holding singleton helper objects -- `$std`, `$output`,
   `$mem`, `$txt`, `$mail`. These are the mod_perl hazard: they are initialized
   once per child process and never reset.
4. A package-global `$Package::lang` holding the loaded language pack.
5. Optionally a `require $iB::SKIN->{'DIR'} . '/SomeView.pm'` -- the View layer,
   loaded either in `BEGIN` (risky: the skin must already be resolved) or inside
   `Process` (the majority).
6. `sub new`, the handlers, `Process`, an error stub, `1;`, sometimes `__END__`.

Whether the View is required in `BEGIN` or in `Process` matters. `ikonboard.cgi`
sets `$iB::SKIN` at line 338, well before dispatch, so a `BEGIN`-time
`require $iB::SKIN->{'DIR'}...` does work at runtime -- but it makes the module
un-loadable outside a live request. `Sources/Posters.pm:20-23`,
`Sources/Warn.pm:5-7`, `Sources/Profile.pm:17` and `Sources/Misc/AOL.pm:26` are
the modules that do this.

#### 1.4 Notation used in the tables

| Column | Meaning |
|---|---|
| `act=` | Stage-1 value in the query string |
| `CODE=` | Stage-2 value in the query string |
| View | The `Skin/<name>/*View.pm` file the module renders through |
| Lang | The `Languages/<code>/*Words.pm` pack the module loads |

Line citations are of the form `Sources/Post.pm:1234` and refer to the file as
shipped, tabs and all.

---

### 4.2 The complete endpoint table

#### 2.1 Stage 1 -- all 44 `act=` values

Source: `ikonboard.cgi:424-470`.

| `act=` | Module | Method | Purpose |
|---|---|---|---|
| `ST` | `Topic` | `ShowTopic` | Render one topic's posts, paginated |
| `SF` | `Forum` | `ShowForum` | Render one forum's topic list |
| `SR` | `Forum` | `ShowRules` | Render a forum's rules page |
| `SC` | `Boards` | `ShowStart` | Render a single category's forum list |
| `Search` | `Search::api` | `Process` | Search form, execution, cached results |
| `Online` | `Online` | `Process` | "Who's online" listing |
| `Legends` | `Legends` | `Process` | Emoticon / iB-code / avatar legend pop-ups |
| `Help` | `Help` | `Process` | Board help topics and help search |
| `Members` | `Memberlist` | `Process` | Sortable, filterable member list |
| `Reg` | `Register` | `Process` | Registration form and account creation |
| `Post` | `Post` | `Process` | New topic / reply / quote / edit |
| `Login` | `LogInOut` | `Process` | Log-in form, log-in, log-out |
| `Profile` | `Profile` | `Process` | View and edit member profiles, avatars |
| `UserCP` | `UserCP::Menu` | `Process` | Member control panel root |
| `Mod` | `Moderate` | `Process` | Inline moderation actions on a topic/post |
| `Poll` | `iPoll` | `Process` | Create poll, vote, view results |
| `Print` | `PrintPage` | `Process` | Printer-friendly topic or private message |
| `Invite` | `Misc::Invite` | `Process` | "Invite a friend" mail |
| `Mail` | `Misc::MailMember` | `Process` | Email a member without exposing the address |
| `Cookies` | `Misc::Cookies` | `Process` | Mark forum read / flush all board cookies |
| `PMarkers` | `Misc::PMarkers` | `Process` | Mark all posts read |
| `Forward` | `Misc::Forward` | `Process` | Forward a topic link to an address |
| `AOL` | `Misc::AOL` | `Process` | AIM pager pop-up |
| `ICQ` | `Misc::ICQ` | `Process` | ICQ pager pop-up |
| `MSN` | `Misc::MSN` | `Process` | MSN pager pop-up |
| `Attach` | `Misc::Attachments` | `Process` | Count and redirect to a post attachment |
| `Msg` | `UserCP::Messenger` | `Process` | Private-message inbox, address book, prefs |
| `MSV` | `UserCP::Messview` | `Process` | Read one private message |
| `MSS` | `UserCP::Messsend` | `Process` | Compose/send one private message |
| `MSM` | `Massmsend` | `Process` | Mass private message (staff) |
| `Subs` | `Misc::Track` | `Process` | Topic subscription add/remove |
| `LostPass` | `UserCP::Lostpass` | `Process` | Lost-password recovery, 3 steps |
| `BoardIdx` | `Boards` | `ShowStart` | Board index (also the fallback for bad `act`) |
| `ModCP` | `ModCP` | `Process` | Moderator control panel |
| `Calendar` | `Calendar` | `Process` | Birthday / event calendar |
| `Report` | `Misc::Report` | `Process` | Report a post to moderators |
| `Upgrade` | `Upgrade` | `Process` | 3.1.0 -> 3.1.1 data patch (admin only) |
| `Warn` | `Warn` | `Process` | Issue a warn-level increment to a member |
| `NotePad` | `NotePad` | `Process` | Member scratch notepad (KEVaholic00) |
| `NW` | `Newest` | `shownewest` | Jump to first unread post in a topic (Camil) |
| `ModSet` | `ModSet` | `Process` | Per-moderator email notification settings |
| `Welcome` | `Welcome` | `Process` | Auto-welcome PM to new members |
| `Posters` | `Posters` | `showposter` | Pop-up: who posted in this topic, and how often |
| `Happybd` | `Happybd` | `Process` | Auto birthday PM editor and sender |

Two of these entries carry inline credits in the source, which is the only place
in the product where a non-Jarvis contributor is named in the dispatcher:

```perl
                  # Added by KEVaholic00: member notepads
                  NotePad   => ['NotePad'            , 'Process'     ],
                  # Added by Camil: Newest post
                  NW        => ['Newest'             , 'shownewest'  ],
```
-- `ikonboard.cgi:462-465`

#### 2.2 Stage 2 -- `CODE=` tables per module

152 second-stage endpoints exist across 27 modules. Entries marked ***** name a
handler that is **not defined anywhere in the tree**; hitting them produces a
Perl `Undefined subroutine ... called` death, caught by `iB::catch_die` and shown
as an "Ikonboard CGI Error" page. See section 4.9.

##### Board / content

| Module | `CODE=` | Handler |
|---|---|---|
| `Post` | `00` | `NewPostForm` |
| | `01` | `NewPost` |
| | `02` | `ReplyPostForm` |
| | `03` | `ReplyPost` |
| | `06` | `ReplyQuoteForm` |
| | `07` | `ReplyQuote` |
| | `08` | `EditForm` |
| | `09` | `DoEdit` |
| `iPoll` | `00` | `ProcessPoll` |
| | `01` | `StartPoll` |
| | `02` | `AddPoll` |
| | `03` | `nullvote` ***** |
| `Moderate` | `00` | `CloseForm` |
| | `01` | `OpenForm` |
| | `02` | `MoveForm` |
| | `03` | `DeleteForm` |
| | `04` | `DeletePost` |
| | `05` | `EditForm` |
| | `06` | `CloseTopic` |
| | `07` | `OpenTopic` |
| | `08` | `DeleteTopic` |
| | `09` | `DeletePost` |
| | `10` | `ForumModForm` ***** |
| | `11` | `Announcement` ***** |
| | `12` | `DoEdit` |
| | `13` | `Unlucky_for_some` (stub, see section 9.5) |
| | `14` | `DoMove` |
| | `15` | `PinTopic` |
| | `16` | `UNPinTopic` |
| | `17` | `rebuild` |
| | `18` | `AddTopicWatch` |
| | `19` | `RemoveTopicWatch` |

Note the gap at `Post` `04`/`05`, and that `Moderate` `04` and `09` both point at
`DeletePost`.

##### Moderator control panel

| Module | `CODE=` | Handler |
|---|---|---|
| `ModCP` | `topic_search` | `topic_search` |
| | `post_search` | `post_search` |
| | `process_topics` | `process_topics` |
| | `process_posts` | `process_posts` |
| | `readpost` | `read_post` |
| | `open` | `open_close_topics` |
| | `process_open` | `process_open_close_topics` |
| | `do_openclose` | `do_openclose` |
| | `delete` | `delete` |
| | `process_delete` | `process_delete` |
| | `do_delete` | `do_delete` |
| | `prune` | `prune` |
| | `process_prune` | `process_prune` |
| | `move` | `move_topics` |
| | `process_move` | `process_move` |
| | `do_move` | `do_move` |
| | `watched_search` | `watched_search` |
| | `merge` | `merge` |
| | `select_merge` | `select_merge` |
| | `do_merge` | `do_merge` |
| | `forum_rules` | `forum_rules` |
| | `do_forum_rules` | `do_forum_rules` |
| `ModSet` | `add` | `add` |
| | `do_add` | `do_add` |
| | `edit` | `edit` ***** |
| | `edit_e` | `edit_e` |
| | `do_edit` | `do_edit` |
| | `del` | `del` |
| | `do_del` | `do_del` |
| | `sender` | `sender` |
| `Welcome` | `edit` | `edit` |
| | `do_edit` | `do_edit` |
| | `sender` | `sender` |
| `Happybd` | `edit` | `edit` |
| | `do_edit` | `do_edit` |
| | `sender` | `sender` |

##### Member-facing

| Module | `CODE=` | Handler |
|---|---|---|
| `Register` | `00` | `ShowForm` |
| | `02` | `CreateAccount` |
| | `03` | `validate_user` |
| | `04` | `show_board_rules` |
| | `05` | `show_dumb_form` |
| | `06` | `check_dumb_form` ***** |
| `Profile` | `01` | `validate` |
| | `02` | `DoProfile` |
| | `03` | `ShowProfile` |
| | `04` | `DoEmail` |
| | `05` | `DoSettings` |
| | `06` | `DoChangeAccount` |
| | `10` | `AddAvatar_installed` |
| | `11` | `AddAvatar_url` |
| | `12` | `UploadAvatar` |
| `LogInOut` | `00` | `ShowForm` |
| | `01` | `LogIn` |
| | `02` | `ShowLogOut` |
| | `03` | `DoLogOut` |
| `UserCP::Menu` | `00` | `Splash` |
| | `01` | `Personal` |
| | `02` | `Email` |
| | `03` | `Subs` |
| | `04` | `Settings` |
| | `05` | `Account` |
| | `06` | `Cancelsub` |
| `UserCP::Lostpass` | `00` | `Splash` |
| | `01` | `step_b` |
| | `02` | `step_c` |
| | `03` | `unlock_box` |
| `NotePad` | `00` | `NotePad` |
| | `01` | `SaveNotePad` |
| | `02` | `saved_p` |
| | `03` | `save_saved_p` |
| | `04` | `mess` |
| | `05` | `save_mess` |
| | `91` | `CreateDBtable` |

##### Messenger

| Module | `CODE=` | Handler |
|---|---|---|
| `UserCP::Messenger` | `00` | `Splash` |
| | `01` | `msg_list` |
| | `02` | `contact` |
| | `05` | `delete` |
| | `06` | `multiact` |
| | `07` | `prefs` |
| | `08` | `do_prefs` |
| | `09` | `add_member` |
| | `10` | `del_member` |
| | `11` | `edit_member` |
| | `12` | `do_edit` |
| `UserCP::Messview` | `03` | `view_msg` |
| `UserCP::Messsend` | `04` | `send` |
| | `13` | `send2` ***** |
| `Massmsend` | `13` | `send` |

The messenger's numeric codes are allocated across three modules from one shared
sequence -- `03` lives in Messview, `04` in Messsend, `05`-`12` in Messenger, `13`
in Massmsend -- which is why `UserCP::Messsend` has an orphan `13` entry pointing
at a `send2` that was never written: `13` was reassigned to `Massmsend::send`.

##### Utility and information

| Module | `CODE=` | Handler |
|---|---|---|
| `Search::api` | `00` | `SearchForm` |
| | `01` | `do_search` |
| | `02` | `show_results` |
| | `03` | `get_new` |
| `Help` | `00` | `Show_titles` |
| | `01` | `Show_section` |
| | `02` | `do_search` |
| `Legends` | `emoticons` | `emoticons` |
| | `ibcode` | `ibcode` |
| | `avatars` | `showavatars` |
| `Online` | `listall` | `list_all` |
| | `forum` | `list_forum` ***** |
| `Calendar` | `1` | `now` |
| `PrintPage` | `pm` | `pm` |
| `Warn` | `00` | `WarnForm` |
| | `01` | `do_warn` |
| `Misc::MailMember` | `00` | `mail_member` |
| | `01` | `send_mail` |
| `Misc::Forward` | `00` | `show_form` |
| | `01` | `send_mail` |
| `Upgrade` | *(empty)* | `Upgrade` |
| | `doupgrade` | `do_upgrade` |

#### 2.3 Stage 2 -- admin control panel `act=` values

`Admin::Functions::process` (`Sources/Admin/Functions.pm:104-141`) maps 33
`act` values. Three of them are frame plumbing rather than modules:

| `act=` | Target |
|---|---|
| `dologin` | `Admin::Functions::dologin` (creates the admin session file) |
| `top` | `Admin::SKIN::top` (the CP header frame) |
| `body` | `Admin::Index` (the CP welcome frame) |
| *(anything else)* | `Admin::Functions::Frames` -- emits the frameset |

The other 30 each load one module. Their per-module `CODE=` tables are listed in
section 4.6.

---

### 4.3 Front-end controllers -- `Sources/*.pm`

33 files sit at the top level of `Sources/`, 32 of them Perl modules (the 33rd is
a directory-listing `index.html` decoy). Sizes below are the shipped line counts.

#### 3.1 `Boards.pm` -- 558 lines, `package Boards`

The board index. Entered as `act=BoardIdx` (or an empty/unknown `act`) or
`act=SC` for one category; both route to `ShowStart`, which branches at line 93:

```perl
	$iB::IN{'act'} eq 'SC' ? $obj->show_cat($db) : $obj->show_list($db);
```

**Subs:** `new`, `ShowStart`, `show_list`, `show_cat`, `render_subcat`,
`render_forum`, `new_cat_posts`, `new_posts`.

**View:** `BoardsView` (`BoardsView::PageTop`, `CatHeader_Expanded`,
`CatHeader_Collapsed`, `ForumRow`, `ActiveUsers`, `ShowStats`, `Invite_Friend`,
`Web_Ring`, `BoardInformation`, `birthday`, `events`). **Lang:** `BoardWords`.

`ShowStart` is the busiest single subroutine in the board: it issues three
queries (categories, moderators, forums), builds the sub-category index, renders
the forum rows, then performs a run of scheduled maintenance chores that have no
other home -- the calendar/birthday sweep (lines 121-251, hourly, guarded by
`Database/Temp/calendar.lock`), the SSI online-list regeneration (lines 253-279,
per minute, guarded by `Database/Temp/online_list.lock`), and the
most-users-ever statistic (lines 283-297). The birthday sweep will construct a
`Happybd` object and send a birthday PM inline (lines 163-167).

**Notable -- a path bug that defeats a cache.** Line 65:

```perl
	unless (-e $iB::PTH.'/Data/ForumJump.pm') {
		$std->build_forumjump( DB	  => $db, ... );
	}
```

`$iB::PTH` occurs exactly once in the whole product and is never assigned
anywhere. It is therefore always `undef`, so the test is `-e '/Data/ForumJump.pm'`
-- an absolute path from filesystem root, which will not exist. The consequence is
that `build_forumjump` regenerates `Data/ForumJump.pm` from the database on
**every board index view**, which is what the guard was written to prevent.

**Notable -- `next` used outside a loop.** `render_forum` and `render_subcat` are
plain subs, but they use `next` for early exit (`Boards.pm:488`, `491`, `510`,
`417`). In Perl this exits the caller's `foreach` loop, not the sub -- so a forum
the viewer may not see does not merely get skipped, it terminates the enclosing
render loop and silently truncates the rest of the category. Perl emits
"Exiting subroutine via next" for this, which `ikonboard.cgi`'s warn filter does
not suppress.

`new_posts` / `new_cat_posts` implement the new-post folder icon by comparing
`FORUM_LAST_POST` against the greater of the board-wide last-visit cookie and a
per-forum `<cookieid>forum-<id>` cookie. `render_subcat` carries two blocks
labeled `# added by kevaholic00` (lines 432-434, 440-442) that total per-forum
active-user counts up into the sub-category row.

#### 3.2 `Forum.pm` -- 696 lines, `package Forum`

Topic list for one forum (`act=SF`) and the forum rules page (`act=SR`).

**Subs:** `new`, `ShowForum`, `ShowRules`, `_show_rules`, `_show_forum`,
`_do_row`, `Check_access`, `Forum_login`, `do_config_check`, `authenticate_user`,
`folder_icon`, `lastpost_icon`, `get_last_date`, `is_vote`, `is_post`.

**View:** `ForumView`. **Lang:** `ForumWords`.

Both entry points check `$iB::IN{'L'} == 1` and divert to `authenticate_user`
(the per-forum password gate) before rendering. `_show_forum` is where the
board's most intricate pagination lives: pinned topics are fetched as a separate
query and unshifted onto the front of the normal topic slice, with the slice
start and end adjusted by the pinned count so that posts do not "go missing"
across page boundaries (lines 214-267, with a nine-line comment explaining it).

The module reconstructs per-forum viewing preferences from a single `fPrefs`
cookie holding `forum:prune:sort_key:sort_by:start`, re-emitting it on every
view (lines 149-175). Sort keys, prune windows and sort directions are validated
against whitelist hashes and rejected with `LEVEL=>5` if unrecognized
(lines 206-207) -- one of the better-guarded input paths in the product.

**Notable:** line 4, before the copyright banner, declares
`my $salt = 'cRey_BjaM_WbB';` with the comment `#Random salt for hack protection`
-- a hard-coded constant shipped identically to every installation.

#### 3.3 `Topic.pm` -- 812 lines, `package Topic`

Renders one topic. Entry: `ShowTopic`.

**Subs:** `new`, `ShowTopic`, `do_member`, `do_guest`, `Check_access`, `view_ip`,
`Moderation_panel`, `_append`, `get_avatar`, `edit_button`, `delete_button`,
`get_t_button`, `mem_stat`.

**View:** `TopicView`. **Lang:** `TopicWords`. Also `require`s `iPoll.pm` and
`Data/MimeTypes.cfg` in `BEGIN` and holds a module-level `iPoll` object so that a
poll topic can be rendered inline.

`ShowTopic` records the read into `topic_views` for logged-in members before
anything else (lines 66-86) -- insert on first view, update `VIEWED`/`POSTED_IN`
thereafter. This table is what `Newest.pm` later reads to compute "first unread
post". It also honors `view=new` / `view=old` to walk to the next or previous
topic by `TOPIC_LAST_DATE` (lines 88+).

`do_member` and `do_guest` are the two per-post renderers, differing in the
member panel: avatar, rank pips, post count, warn level, and the pager icons.
`view_ip` gates IP display, `Moderation_panel` emits the per-topic moderator
controls, `get_t_button` picks the new-topic/reply button pair.

#### 3.4 `Post.pm` -- 1,624 lines, `package Post`

The largest front-end controller and the second-largest module in the product.
Handles new topics, replies, quoted replies and edits, plus (via `iPoll`) the
poll-creation form.

**Subs (36):** `new`, `NewPollForm`, `NewPoll`, `NewPostForm`, `NewPost`,
`_do_new_post`, `ReplyPostForm`, `ReplyPost`, `_do_reply`, `ReplyQuoteForm`,
`ReplyQuote`, `do_topic_summary`, `EditForm`, `DoEdit`, `_do_edit`,
`CompileHTML`, `_post_body`, `Stats`, `_headers`, `_name_field`,
`_submit_field`, `valid_cal`, `leap_year`, `CompilePost`, `UpdateBoardStats`,
`update_member`, `check_last_post`, `check_email_replies`, `notepad`, `Process`,
`LoadForum`, `LoadTopic`, `GetPost`, `SetSession`, `Filter`, `PostError`.

**View:** `PostView`. **Lang:** `PostWords`. **Requires:** `Lib/FUNC.pm`,
`iTextparser.pm`, `Searchlog.pm` (all in `BEGIN`), plus `Misc/Track.pm`,
`ModSet.pm`, `SSI::Parser` and `Data/MimeTypes.cfg` on demand.

The shape is consistent: each action has a `XxxForm` sub that renders, and an
`Xxx` sub that validates and hands off to a `_do_xxx` worker.
`Process` (line 1414) resolves forum, topic and post IDs, loads them, runs
`SetSession` (permission checks), then dispatches. `SetSession` is where posting
permission, the moderation queue flag (`$obj->{preview}`), group-level
`AVOID_Q`, and forum passwords are all resolved.

`CompilePost` (line 1003) is the heart: it builds the `%POST` record, runs the
text through `iTextparser::Convert_for_db`, applies the word filter via
`Filter`, and attaches the member's post color. `UpdateBoardStats`,
`update_member`, `check_last_post` and `check_email_replies` are the post-commit
fan-out: board counters, member post count and rank promotion, forum last-post
denormalization, and subscription mail.

`Post.pm` knows it is being used by `iPoll`: `new` records
`$obj->{'_pkg'} = (caller(0))[0]` and several branches read
`$obj->{'_pkg'} eq 'iPoll'` to switch behavior (topic icon 99, poll state, and
returning the new topic ID instead of redirecting).

`FUNC::Member::UpdateMember` performs the same caller sniff from the other side --
it only runs the rank-promotion loop when `(caller(0))[0] eq 'Post'`
(`Sources/Lib/FUNC.pm:1356-1358`).

#### 3.5 `Post2.pm` -- 1,610 lines, `package Post`

**A second, older copy of `Post.pm`, shipped in the release and never loaded.**
It declares the same `package Post`. Nothing in the tree `require`s it -- a
whole-tree grep for `Post2` finds only its own filename.

Diffing the two with whitespace ignored yields eight changed hunks. `Post.pm`
(dated 07/09/2002) is the newer; `Post2.pm` (06/24/2002) is the predecessor. The
substantive differences:

* **Post color handling.** `Post2.pm` hard-codes the fallback post color:
  `$iB::MEMBER->{'POST_FONT_COLOR'} = qq!#000000!;`. `Post.pm` replaces this in
  three places with the configurable `$iB::INFO->{'FONT_COLOR_DEFAULT'}` and adds
  a second guard honoring `$iB::INFO->{'FONT_COLOR_ALLOW'}`.
* **Edited-post color.** In `_do_edit`, `Post2.pm` uses the *editing* member's
  color; `Post.pm` reloads the *original author* via
  `$mem->LoadMember(... METHOD => 'by id')` and uses theirs.
* **Search index timing.** `Post2.pm` calls `$s_log->compile_entry` earlier, in
  the topic-update branch; `Post.pm` moves it after the reply is committed.
* **Redirect anchors.** `Post2.pm` emits `...;r=1#top`; `Post.pm` emits
  `...;r=1;&#top` -- the extra `;&` is a workaround for the semicolon/ampersand
  query-separator problem `ikonboard.cgi:153-159` also deals with.

Its presence in the tarball is a packaging accident, but a useful one: it is a
dated snapshot of the same file two weeks earlier.

#### 3.6 `iPoll.pm` -- 428 lines, `package iPoll`

Poll creation, voting and result rendering. Entered as `act=Poll`; also called
directly by `Topic.pm` to render a poll inside a topic view.

**Subs:** `new`, `StartPoll`, `AddPoll`, `ProcessPoll`, `display_poll`,
`_show_results`, `_show_form`, `Process`, `LoadForum`, `LoadTopic`, `SetSession`,
`PollError`.

**View:** `PollView` (plus `PostView`, because it delegates the poll form to
`Post::NewPollForm`). **Lang:** `PostWords`.

Poll answers are stored as one packed string in `forum_polls.POLL_ANSWERS`, in
the format `id~::~answer~=~votes|` repeated. Both `_show_results` and
`_show_form` unpack it with the same nested `split`. Voting is recorded in
`forum_poll_voters` keyed on member ID, poll ID and forum ID; a "null vote"
(`$iB::IN{nullvote}`) records the voter but does not increment any answer, which
is how a member views results without voting.

**Notable -- the author's own assessment.** The module opens with a to-do block
(lines 20-31) proposing a `Postings/` package split, and `ProcessPoll` begins:

```perl
	# This is horrible and I hate it.
	# First thing to do for v3.1? Rewrite the entire polling/posting system...
```
-- `Sources/iPoll.pm:138-139`

with `# Cut off a piece of duct tape...` at line 158 above
`$iB::IN{CODE} = '01';` -- the module rewrites its own dispatch parameter mid-flight
so that the `Post` object it is about to call behaves as if it were handling a new
post.

**Notable -- dead endpoint.** `CODE=03` maps to `\&nullvote`, which does not exist
in this or any file. Because `\&name` on an undefined sub is legal Perl, the hash
lookup succeeds and the call dies at runtime. See section 9.

#### 3.7 `Moderate.pm` -- 1,492 lines, `package Moderate`

Inline moderation: the actions reachable from the moderation panel at the foot of
a topic (`act=Mod`).

**Subs (31):** `new`, `CloseForm`, `OpenForm`, `DeleteForm`, `EditForm`,
`MoveForm`, `CompileHTML`, `_post_body`, `_headers`, `DoMove`, `move_topic`,
`DoEdit`, `CloseTopic`, `OpenTopic`, `DeleteTopic`, `DeletePost`, `create_post`,
`UpdateStats`, `AddTopicWatch`, `RemoveTopicWatch`, `PinTopic`, `UNPinTopic`,
`rebuild`, `CheckAuthorisation`, `Process`, `Unlucky_for_some`, `GetPost`,
`LoadForum`, `LoadTopic`, `SetSession`, `ModerateError`.

**Views:** `ModView` and `PostView` (both `require`d unconditionally in
`Process`). **Langs:** `ModerateWords` and `PostWords`.

Structure mirrors `Post.pm` -- a `*Form` renderer and a committing counterpart for
each verb. `CheckAuthorisation` is the gate: super-moderator group, per-forum
moderator row, or refusal. `rebuild` (CODE=17) recounts a topic's post total and
repairs the denormalized last-post fields. `AddTopicWatch`/`RemoveTopicWatch`
write `forum_subscriptions`.

Three of its twenty `CODE` values are non-functional: `10` (`ForumModForm`) and
`11` (`Announcement`) name subs that do not exist, and `13` is the joke stub
described in section 9.5.

#### 3.8 `ModCP.pm` -- 2,132 lines, `package ModCP`

The moderator control panel -- the second-largest module in the product and the
largest front-end controller. A full-screen batch console: search topics or
posts, then apply open/close, delete, move, merge or prune to the selection.

**Subs (33):** `new`, `mod_splash`, `read_post`, `_forum_splash`, `delete`,
`process_delete`, `do_delete`, `prune`, `process_prune`, `open_close_topics`,
`process_open_close_topics`, `do_openclose`, `move_topics`, `process_move`,
`do_move`, `watched_search`, `topic_search`, `post_search`, `process_posts`,
`process_topics`, `_reset_stats`, `_reset_forum`, `_main_splash`, `merge`,
`td_select`, `select_merge`, `do_merge`, `forum_rules`, `do_forum_rules`,
`Process`, `LoadForum`, `LoadTopic`, `SetSession`.

**View:** `ModCPView` -- at 1,157 lines and 59 subs, the largest View in the
default skin, and larger than most controllers. **Langs:** `ModCPWords` and
`ModerateWords`. Instantiates a module-level `Moderate` object (`$mod`) and a
`Searchlog` object (`$s_log`).

`Process` differs from every other controller in one respect: rather than each
handler printing its own page, handlers accumulate into `$obj->{html}` and
`Process` prints once at the end, unless a handler sets `$obj->{bypass}`:

```perl
	$Mode{$CodeNo} ? $Mode{$CodeNo}->($obj, $db) : mod_splash($obj, $db);

	unless ($obj->{bypass}) {
		$output->print_ikonboard( DB           => $db, ... );
	}
```
-- `Sources/ModCP.pm` (`Process`)

Each destructive verb is a three-step wizard: `verb` (choose forum) ->
`process_verb` (choose rows) -> `do_verb` (commit), which is why the `CODE` table
is so long.

#### 3.9 `ModSet.pm` -- 600 lines, `package ModSet`

Per-moderator email notification rules for moderated forums: which forums a
moderator wants to be told about, and by what template.

**Subs:** `new`, `splash`, `_settings_splash`, `add`, `do_add`, `del`, `do_del`,
`edit_e`, `do_edit`, `sender`, `Process`, `SetSession`.

**View:** `ModCPView` (borrowed -- it has no view of its own). **Lang:**
`ModCPWords`. `splash` instantiates `ModCP` to reuse its forum splash.

`sender` (line 293, ~230 lines) is the largest sub: it is called from `Post.pm`
when a post lands in a moderation queue and mails every subscribed moderator.

**Notable:** `CODE=edit` maps to `\&edit`, and `ModSet.pm` has no `sub edit` --
only `edit_e`. Dead endpoint.

#### 3.10 `Welcome.pm` -- 247 lines, `package Welcome`

Composes and sends the automatic welcome private message to newly registered
members. Called by `Register.pm` (twice: lines 362-363 and 572-573) and by
`Admin::Authorise` when an administrator manually approves a registration.

**Subs:** `new`, `splash`, `_settings_splash`, `edit`, `do_edit`, `sender`,
`Process`, `SetSession`. **View:** `ModCPView`. **Lang:** `ModCPWords`.

`Welcome.pm`, `Happybd.pm` and `ModSet.pm` are near-identical in shape -- the same
eight subs, the same borrowed `ModCPView`, the same `$ModCP::lang`, the same
`edit`/`do_edit`/`sender` triple. They are three instances of one template.

#### 3.11 `Happybd.pm` -- 281 lines, `package Happybd`

The birthday equivalent of `Welcome.pm`: an editable message template and a
`sender` that PMs the member on the day. Triggered from `Boards::ShowStart`
during the hourly calendar sweep (`Boards.pm:163-167`), and editable via
`act=Happybd;CODE=edit`.

**Subs:** `new`, `splash`, `_settings_splash`, `edit`, `do_edit`, `sender`,
`Process`, `SetSession`. **View:** `ModCPView`. **Lang:** `ModCPWords`.

#### 3.12 `Calendar.pm` -- 287 lines, `package Calendar`

Month-grid calendar of member birthdays and forum events.

**Subs:** `new`, `one`, `take_months`, `next_day`, `now`, `Process`,
`CalendarError`, `check_date`, `leap_year`.

**View:** `CalendarView`. **Langs:** `CalendarWords` and `UserCPWords`.

**Notable -- attribution.** The header credits an outside author:
`# Script author: Nurlan Mukhanov (Infection) Modif. by Camil`
(`Sources/Calendar.pm`, header). "Infection" is credited twice more elsewhere:
`FUNC::STD::htmlcut` (`Sources/Lib/FUNC.pm:215`, "This routine written by
Infection") and `iDatabase::Driver::Base::make_hash_ref`
(`Sources/iDatabase/Driver/Base.pm:25`, "Routine written by Nurlan
(infection)"). "Camil" is the same contributor credited for `Newest.pm` in the
dispatcher.

`%Mode` has a single entry, `'1' => \&now` -- a one-digit code, which is why the
module reads `$iB::IN{'CODE'}` directly rather than through `CheckCodeNo`.

#### 3.13 `Register.pm` -- 733 lines, `package Register`

Registration: form, validation, account creation, email validation, board rules
acceptance and the anti-bot "dumb form".

**Subs:** `new`, `CreateAccount`, `clean_registrations`, `validate_user`,
`ShowForm`, `show_dumb_form`, `show_board_rules`, `Process`, `RegisterError`.

**View:** `RegisterView` (402 lines, 18 subs -- one per optional profile field).
**Langs:** `RegisterWords` and `UserCPWords`.

`Register` is the only action `ikonboard.cgi` special-cases in session handling:
`$iB::MEMBER = $sess->authenticate($db) unless $iB::IN{'act'} eq 'Reg';`
(`ikonboard.cgi:352`) -- registration runs without an authenticated member.

`CreateAccount` fans out to `FUNC::Member::AddMember`, the `authorisation` table
(if admin approval or email validation is on), the `calendar` table (birthday),
`message_stats`, and `Welcome->sender`. `clean_registrations` prunes stale
unvalidated rows; the same routine exists independently in `Admin::Index`.

**Notable:** `CODE=06` maps to `\&check_dumb_form`, which does not exist. The
"dumb form" (`CODE=05`) renders, but its submit target was never written.

#### 3.14 `Profile.pm` -- 963 lines, `package Profile`

Member profile display and every profile-editing operation.

**Subs (17):** `new`, `DoProfile`, `AddAvatar_installed`, `AddAvatar_url`,
`DoEmail`, `DoSettings`, `DoChangeAccount`, `do_email_change`, `_verify_mail`,
`_reset_email`, `validate`, `change_pass`, `get_daily`, `ShowProfile`,
`UploadAvatar`, `Process`, `ProfileError`.

**View:** `ProfileView`. **Lang:** `ProfileWords`. Requires `ProfileView` at
file scope (line 17), outside any sub -- so the module cannot be compiled outside
a request that has already resolved `$iB::SKIN`.

Three avatar paths exist and are separately gated: pick an installed avatar
(`CODE=10`), supply a remote URL (`CODE=11`), upload a file (`CODE=12`).
Email changes are two-phase -- `do_email_change` issues a token, `_verify_mail`
consumes it, `_reset_email` reverts.

#### 3.15 `LogInOut.pm` -- 147 lines, `package LogInOut`

The thinnest of the major controllers, because the actual authentication happens
in `Sessions.pm` before dispatch. By the time `LogIn` (CODE=01) runs,
`$iB::MEMBER` is already populated; `LogIn`'s job is only to compute the return
URL from the referer, set the anonymous-login cookie if `Privacy` was checked,
and print the "thanks" redirect screen.

`DoLogOut` (CODE=03) blanks the `active_sessions` row and expires five cookies:
`iBSessionID`, `iBMemberID`, `iBPassWord`, `skin`, `anonlogin`.

**View:** `LogInView`. **Lang:** `LoginWords`.

#### 3.16 `Sessions.pm` -- 514 lines, `package Sessions`

Not reachable by `act=`; loaded directly by `ikonboard.cgi:203` and used for
every request. This is the authentication and presence layer.

**Subs:** `new`, `active_users`, `authenticate`, `invalid_admin`, `get_session`,
`create_session`, `clean_sessions`, `my_gen_id`, `do_log_in`, `my_bash_cookies`.

`authenticate` (line 90) is the entry point. It sanitizes `s`, applies the
`IP_FILTER` ban list, deletes the sentinel `-` cookie values written at logout,
maintains the `lastvisit`/`lastactivity` cookie pair (with a two-hour rule: if
no activity cookie update for 7,200 seconds, treat the previous activity time as
the last visit), and then picks one of three credential sources via `goto`:
submitted username/password, a session cookie, or an `s=` session ID.

It carries an explicit legacy path: if the stored password hash is shorter than
32 characters it is assumed to be an Ikonboard 2 DES `crypt` hash, and
`Sources/Lib/Crypt.pm` is loaded to verify it:

```perl
		if (length($this_member->{'MEMBER_PASSWORD'}) < 32) {
			use Lib::Crypt;
			$pass2 = crypt ($iB::IN{'PassWord'}, lc (substr($iB::IN{'UserName'}, 0, 2 )));
		}
```
-- `Sources/Sessions.pm:195-198`

`active_users` produces the online counts and name lists consumed by
`Boards.pm`, de-duplicating members whose IP changes behind a proxy.

#### 3.17 `Memberlist.pm` -- 259 lines, `package Memberlist`

The member list (`act=Members`). Single handler; `Process` just calls
`show_results`. Builds four `<select>` controls (sort key, sort order, filter by
group, results per page) from whitelist hashes and rejects any value not in them
(lines 146-147). Renders per-member pager icons and either the group team icon or
a run of rank "pips".

**View:** `MemberlistView`. **Lang:** `MemberlistWords`.

The pagination here is hand-rolled and does **not** use
`FUNC::STD::build_pagelinks`, unlike Forum, Online and Search -- it reimplements
the same logic inline (lines 172-193) with a different window size (+/-2 pages
instead of +/-5).

#### 3.18 `Online.pm` -- 240 lines, `package Online`

"Who's online" (`act=Online;CODE=listall`). Reads `active_sessions` for rows with
`RUNNING_TIME` in the last 900 seconds and `LOG_IN_TYPE != 1` (i.e. excluding
anonymous logins), then translates each row's stored `LOCATION` -- `act|&|query
string` -- into a human-readable "viewing X" line, resolving forum and category
IDs to names and honoring per-forum view permissions.

Member names are wrapped in the per-group prefix/suffix from
`$iB::INFO->{AU_FORMAT}`, parsed once at file scope into `$format`
(lines 35-39).

**View:** `OnlineView`. **Lang:** `OnlineWords`.

**Notable -- dead endpoint and a joke.** `CODE=forum` maps to `\&list_forum`,
which does not exist. The fallback `sub OnlineError { }` (line 237) is empty, so
an unrecognized `CODE` produces a completely blank response -- no HTTP header, no
body. Lines 48-51 contain a POD block that is a comment to the next maintainer:

```perl
=pod
=HEADER	How about this?
sub	new { return bless {}, $_[0]; }
=cut
```

#### 3.19 `Legends.pm` -- 179 lines, `package Legends`

Three pop-up reference cards: the emoticon table, the iB-code table, and the
installed-avatar gallery.

`ibcode` is the interesting one: rather than hard-coding example markup, it
builds a sample string for each tag and runs it through
`iTextparser::Convert_for_db` so the "used" column shows the real rendered
output of the real parser (lines 68-82). The tag list includes `SQL` and `HTML`
alongside the usual `B`/`U`/`I`/`S`/`CODE`/`QUOTE`/`IMG`/`EMAIL`/`URL`/`SIZE`/
`COLOR`/`ME`/`FONT`.

`showavatars` reads `HTML_DIR/avatars` from disk rather than from the database.

**View:** `LegendsView`. **Lang:** `LegendsWords`.

**Notable:** the error fallback at line 174 is
`sub LegendError { die "I'm working on it!" }` -- a developer placeholder that
shipped. Any unrecognized `CODE` produces an "Ikonboard CGI Error" page reading
"I'm working on it!".

#### 3.20 `Help.pm` -- 172 lines, `package Help`

Board help system: a title index (`CODE=00`), one section (`CODE=01`, keyed on
`HID`, validated as 1-3 digits), and a `LIKE`-based search across title and body
(`CODE=02`). Content lives in the `help` table and is editable through
`Admin::Helpcontrol`.

**View:** `HelpView`. **Lang:** `HelpWords`.

#### 3.21 `Massmsend.pm` -- 561 lines, `package Massmsend`

Mass private messaging (`act=MSM;CODE=13`). Effectively a fourth messenger
module: it duplicates `UserCP::Messsend`'s compose form, notepad integration and
`td_select` helper, but fans the message out to a group, a list of names, or the
entire membership.

**Subs:** `new`, `send`, `send_mass`, `notepad`, `td_select`, `mass_pm_form`,
`Show_menu`, `Process`, `SetSession`, `MessengerError`.

**Views:** `MessengerView` and `PostView`. **Langs:** `MessengerWords` and
`PostWords`. Note it sets `$Messenger::lang`, the same package global the three
`UserCP::Mess*` modules use -- they share one language namespace.

#### 3.22 `NotePad.pm` -- 224 lines, `package NotePad`

Community contribution (KEVaholic00). Three independent per-member scratch pads
stored in one `member_notepads` row: a general notepad (`NOTEPAD_TEXT`), a saved
post (`SAVED_P`), and a saved message (`SAVED_M`). Each has a view code and a
save code. Posting and messaging both hook into it: `Post::notepad` and
`Messsend::notepad` write `SAVED_P`/`SAVED_M` when the composer's "save" box is
ticked.

**View:** `NotePadView`. **Lang:** `NotePadWords`.

**Notable -- an unguarded schema endpoint.** `CODE=91` runs:

```perl
sub	CreateDBtable {
	my ($obj, $db) = @_;

	$db->create_table( TABLE => 'member_notepads' );
	...
}
```
-- `Sources/NotePad.pm:193-205`

There is no permission check anywhere in `NotePad::Process`, and the module never
consults `$iB::MEMBER_GROUP`. `act=NotePad;CODE=91` is a DDL operation exposed to
any visitor. (Whether it succeeds depends on the driver: `iDatabase::SQL` is
constructed with `allow_create` set only when the driver is DBM --
`ikonboard.cgi:286-287`.) This is the installation step for the contributed
feature, left in the shipped module.

#### 3.23 `Newest.pm` -- 130 lines, `package Newest`

Community contribution (Camil). `act=NW;f=..;t=..` computes the first post in a
topic the member has not seen -- by comparing each post's `POST_DATE` against the
member's `topic_views.VIEWED` timestamp -- works out which page that post falls
on, and issues a `pure_redirect` to `act=ST;...;st=N;&#entryNNN`.

Guests skip the computation entirely via a `goto LAST` to a redirect to the top
of the topic (lines 54-56, 90-92).

`prune_historic` is a piggy-backed cron: once per day, guarded by an mtime check
on `Database/Temp/prune_historic.cgi`, it deletes `topic_views` rows older than
`HISTORIC_LIMIT` days across every forum. Several such "cron by mtime lock file"
patterns exist in the product (see also `FUNC::Output::member_bar`,
`Boards::ShowStart`, `Search::API::api_global::clean_up`).

No View, no language pack -- it only redirects.

#### 3.24 `Posters.pm` -- 101 lines, `package Posters`

Pop-up listing who posted in a topic and how many times, sorted by frequency.
Builds a frequency hash over `forum_posts.AUTHOR`, then resolves the member names
in a single `WHERE ... or ...` query.

**View:** `PostersView` (required in `BEGIN`). **Lang:** `PostersWords`.

**Notable -- three small defects in 101 lines:**
* `my $output = FUNC::Output->new();` is declared twice, at lines 25 and 27
  (`"my" variable $output masks earlier declaration in same scope`).
* `@names` is seeded with a placeholder empty entry
  (`my @names = {ID => "", NUMBER => ""};`, line 67), which contributes a
  `MEMBER_ID eq ""` term to the generated `WHERE` clause.
* `$Pos::lang` is assigned without `my` under `use strict` -- legal only because
  it is a fully qualified package global.

#### 3.25 `PrintPage.pm` -- 232 lines, `package PrintPage`

Printer-friendly rendering. Two modes: a whole topic (default, `splash`) and a
single private message (`CODE=pm`). Both strip the board chrome and run posts
back through `iTextparser` for a plain-text-ish presentation. `Check_access`
re-applies forum view permissions so the print view cannot be used to bypass a
protected forum.

**View:** `PrintPageView`. **Lang:** `PrintpageWords`.

#### 3.26 `Warn.pm` -- 192 lines, `package Warn`

The warn system: a moderator raises or lowers a member's `WARN_LEVEL`, with an
optional note mailed to the member and logged. `WarnForm` (CODE=00) renders,
`do_warn` (CODE=01) commits.

**View:** `WarnView` (required at file scope, line 7). **Lang:** `WarnWords`.

**Notable:** `sub FatalError { die "I'm working on it!" }` at line 167 -- the same
placeholder as `Legends.pm`.

#### 3.27 `Upgrade.pm` -- 126 lines, `package Upgrade`

A single-purpose data patcher for the 3.1.0 -> 3.1.1 step, and the only module in
the tree with a named individual author in its header:
`# Script Author: Phil Gengler (LrdChaos) <lrdchaos@codeallday.com>`.

It **does** do real work. `do_upgrade` (line 61):

1. inserts a new `MASS_MAIL` email template into `email_templates` (the template
   body is given inline, including the unsubscribe wording);
2. deletes the legacy `Email-log` file;
3. loads `Skin/Default/Styles.pm` and `Skin/Default/gfx_data.cfg`, adds a
   `B_POLL_LOCKED` entry to both (the locked-poll folder icon
   `f_poll_locked.gif`), rewrites the image paths so they interpolate at runtime,
   and regenerates both files through `FUNC::ADMIN::make_module`.

Access is restricted to the super-admin group (line 115). The `%Mode` table keys
the default screen on the empty string, so a bare `act=Upgrade` renders the
one-button form.

**Notable -- two defects.** Line 70 reads `$iB::INFO{'DB_DIR'}` (hash) rather than
`$iB::INFO->{'DB_DIR'}` (hash reference), so the `-e` test is against a bare
`"Email-log"` in the current directory and the unlink is skipped. And the error
fallback, `sub FatalError { }` (line 124), is empty -- an unrecognized `CODE`
returns a blank page.

#### 3.28 `iTextparser.pm` -- 449 lines, `package iTextparser`

The iB-code / BBCode engine, and one of the most-loaded modules in the product
(required by Post, Profile, Register, ModCP, Moderate, PrintPage, Online,
Legends, Massmsend, all three UserCP messenger modules, `Admin::SKIN`,
`Admin::Helpcontrol`, `Admin::Convert_ib` and `FUNC::Mailer`).

**Subs:** `new`, `do_wrapper`, `Convert_for_db`, `Convert_for_textfield`,
`Convert_for_email`, `fix_real_url`, `chomp_url`.

`Convert_for_db` is the forward direction (typed text -> stored HTML);
`Convert_for_textfield` is the reverse (stored HTML -> editable text);
`Convert_for_email` strips to plain text. `do_wrapper` centralizes the
`QUOTE`/`CODE`/`SQL`/`HTML` box markup so the surrounding table can be changed in
one place. `fix_real_url` and `chomp_url` normalize links, including stripping
the session ID out of self-referencing URLs so sessions do not leak into posted
links.

**Notable:** lines 50-59 preserve the instructions of a hand-applied patch in the
shipped source:

```perl
# add this one:
    $html{START} = qq~~;
# and this one:
    $html{START}.= qq~</span>~ unless $in->{POST_COLOR};
# and CHANGE this one: (remove </span>)
```

#### 3.29 `iTextparser2.pm` -- 449 lines, `package iTextparser`

**A second, older copy of `iTextparser.pm`, never loaded.** Same package name,
never `require`d by anything. Whitespace-insensitive diff shows three
substantive changes in the newer file:

* a key-name typo fix: `POST_COLOR=>$use_pfc` in `iTextparser.pm` versus
  `POSTCOLOR=>$use_pfc` in `iTextparser2.pm` -- since `do_wrapper` tests
  `$in->{POST_COLOR}`, the older file's `CODE` blocks always emitted the
  `</span>` wrapper;
* the edit-marker stripper changed from
  `s!\n\n<br><br><\!--EDIT\|(.+?)\|(.+?)-->!!ig` to
  `s#<!--EDIT\|(.+?)\|(.+?)-->##ig` -- the older pattern only matched when the
  marker was preceded by exactly two newlines and two `<br>`s;
* the session-stripping regex widened from `s=[\w\d]{16,32}` to
  `s=[\w\d]{32,32}` -- the newer file only strips full-length 32-character
  session IDs.

#### 3.30 `Searchlog.pm` -- 97 lines, `package Searchlog`

Maintains the `search_log` table used by the DBM search back-end. Not reachable
by `act=`; instantiated by `Post.pm`, `Post2.pm`, `Moderate.pm` and `ModCP.pm`.

**Subs:** `new`, `compile_entry`, `Convert_for_index`, `remove_entry`,
`add_entry`. `compile_entry` is called on every new post; `remove_entry` when a
post is deleted or moved.

#### 3.31 `ARC4.pm` -- 87 lines, `package Crypt::ARC4`

A compact pure-Perl RC4 implementation, shipped under a two-line permissive
header (`This is free software and may be modified and/or redistributed under
the same terms as Perl itself.`) with no author name. `new`/`ARC4`/`Setup`, plus
a commented-out usage example after `__END__` including the caveat
`# (Warning: Encrypted file leaks line lengths.)`.

Its one job is the database password at rest. `ikonboard.cgi:211-267` generates
a random 16-character key on first run, writes it to `Data/<key>.pwd`, uses the
*filename* as the RC4 passphrase, encrypts each of the five configured DB
passwords, Base64-encodes them, and rewrites `Boardinfo.cgi`. On every later
request it reverses that to recover `DB_PASS`. `Admin/Filemanager.pm`,
`Admin/Category.pm` and `Admin/dbHandler.pm` repeat the same decrypt block for
the admin password.

Note the key generation seed at `ikonboard.cgi:278`:

```perl
  srand (time ^ $$ ^ unpack "%L*", `ps axww | gzip`);
```

-- a backtick shell-out to `ps` piped through `gzip`, which is a Unix-only idiom
in a product that otherwise goes to some trouble to support Win32.

#### 3.32 `Makelog.pm` -- 35 lines, `package Makelog`

**Dead.** Never `require`d by anything (whole-tree grep finds only its own
filename), and it could not work if it were: it calls a database API that does
not exist in Ikonboard 3.

```perl
sub add_log ($) {
    my $obj = shift;
    $db->connect('Logging/'.$obj->{'ARGS'}->{'INDEX'},$obj->{'ARGS'}->{'INDEX'});
    my $sth = $db->prepare(INSERT=>$obj->{'ARGS'}->{'DATA'},WHERE=>'at bottom');
    $sth->execute();
    $sth->done();
    $db->disconnect($sth);
}
```
-- `Sources/Makelog.pm:21-28`

`connect`/`prepare`/`execute`/`done`/`disconnect` is the iDatabase v1.0 idiom
from the Ikonboard 2 era. Ikonboard 3's `iDatabase::SQL` exposes
`select`/`query`/`insert`/`update`/`delete` and has no `prepare`. The module also
does `use iDatabase;` and `my $db = iDatabase->new();` at file scope -- and
`package iDatabase` exists in exactly one place in this tree,
`Sources/iDatabase/Driver/CSV.pm`, which is itself unreachable (section 5.4). It is one
of the two files in `Sources/` without `use strict`.

---

### 4.4 `Sources/Lib/` -- the core library

Four files, 2,906 lines, loaded by essentially everything through the same
`BEGIN { require 'Lib/FUNC.pm'; }` line.

#### 4.1 `Lib/FUNC.pm` -- 1,637 lines, four packages

The single most important file in the product. It defines four packages in one
file, and every front-end module instantiates two to five of them at file scope.

```perl
BEGIN {
	require 'Boardinfo.cgi' or die "Cannot load Module: $!";
	require 'Default/Universal.pm' or die $!;
}
```
-- `Sources/Lib/FUNC.pm:21-24`

Note the hard-wired `Default/Universal.pm`: the *default* skin's universal view
is loaded at compile time regardless of which skin the visitor has selected. The
visitor's actual skin universal is loaded separately by `ikonboard.cgi:340`
(`do $iB::SKIN->{'DIR'} . '/Universal.pm';`), which redefines the same
`Universal::` subs over the top.

##### `FUNC::STD` (lines 1-728) -- 30 subs

The general-purpose toolbox. Conventionally instantiated as `my $std`.

| Method | Purpose |
|---|---|
| `new` | Blesses an empty hash; also loads `UniversalWords` into `$Universal::lang` |
| `LoadSkin` | Resolves the visitor's skin by four ordered rules (admin CP -> per-forum skin -> `sid` cookie/param -> default), verifies `Styles.pm` exists, `require`s it, and returns the `Styles` object with `DIR`, `FULL_DIR`, `IMAGES_URL` added |
| `LoadLanguage($area)` | Builds `require "<lang>/<Area>.pm"; $lang = <Area>->new();` and `eval`s it; falls back to `en` if the directory is missing |
| `build_pagelinks` | The shared paginator: renders `<< 1 2 [3] 4 ... >>` with a +/-5-page window |
| `build_forumjump` | Regenerates `Data/ForumJump.pm` from the categories/forums arrays via `FUNC::ADMIN::make_module` |
| `ForumJump` | Loads `Data/ForumJump.pm` and emits the jump `<select>`, filtered by the member's group |
| `htmlcut` | Truncates a string without cutting through an HTML entity (credited to "Infection") |
| `get_date` | UNIX timestamp -> formatted date, applying board time zone plus the member's `TIME_ADJUST`, in the admin's `CLOCK_STYLE` template; contains an AM/PM fix credited in-line to "Freakboy" |
| `Error` | Loads `ErrorWords`, substitutes `<#VAR#>`, renders through `Universal::Error`, prints a full page and **exits** |
| `ValidateEntry` | Post-referer check plus the `/proc/loadavg` server-load limiter |
| `ib_stats` / `_update_stats` / `_save_stats` / `_load_stats` | The board statistics counter, persisted as generated Perl in `Data/Stats.pm` |
| `IsNumber`, `IsWord`, `CheckCodeNo`, `CheckEmail`, `_trim` | Input validators |
| `doHTML`, `TextTidy` | Entity decoding and whitespace tidying |
| `ib_int` | Ceiling division, used for page counts |
| `unHTML`, `CleanKey`, `CleanValue`, `GetDate`, `MaintenanceMode`, `cgi_error` | Marked `DEPRECIATED` in their comment headers; `GetDate` and `MaintenanceMode` are empty subs |

`Error` is the most-called method in the entire product: 642 call sites.

##### `FUNC::Output` (lines 735-1174) -- 9 subs

The output layer. Conventionally `my $output`.

| Method | Purpose |
|---|---|
| `print_ikonboard` | The main page printer -- see below |
| `print_popup` | Minimal chrome-free page for the pop-up windows (Legends, Posters, pagers) |
| `board_offline` | Renders `Universal::Offline` when the board is switched off |
| `redirect_screen` | Interstitial "...please wait" page with a meta refresh |
| `pure_redirect` | HTTP 302, with an explicit `location:` fallback on `MSWin32` because "the status code 302 is largely ignored by NT" |
| `_print_http_header` | Emits the header exactly once, guarded by `$iB::CONTENT->{'HTTP'}` |
| `navigation` | Builds the breadcrumb trail from the `NAV`/`NAV_ONE`/`NAV_TWO` arguments |
| `member_bar` | The logged-in member strip, PM counter and PM pop-up |
| `_get_ssi` | Resolves `<!--#include-->` / `<? virtual() ?>` / `<!--#exec cgi-->` directives in a board template, via `LWP::Simple` for the CGI cases |

`print_ikonboard` (line 794) is where a request ends. It inlines
`ikonboard.js`, links `ikonboard.css`, computes the Benchmark timings, builds the
admin-only stats table, fetches the board template row from the `templates`
table, and substitutes seven markers -- `<% TITLE %>`, `<% GENERATOR %>`,
`<% IB CSS %>`, `<% IB JAVASCRIPT %>`, `<% IKONBOARD %>`, `<% NAVIGATION %>`,
`<% BOARD HEADER %>`, `<% COPYRIGHT %>`, `<% STATS %>`, `<% MEMBER BAR %>`.
It optionally runs an HTML whitespace compressor with a
`<!--NoCompression//-->` escape hatch (lines 953-974), prints, and calls
`iB::exit()`. The self-aware comment above the print is worth preserving:

```perl
	# So, after many modules, routines, checks, regex's and other magic,
	# all it takes is two words to make ikonboard appear....

	print $it;
	undef $it;
	# ... talk about an anti-climax.
```
-- `Sources/Lib/FUNC.pm:977-982`

`member_bar` also carries the private-message pruning cron: once per day,
guarded by the mtime of `Database/Temp/prune_message.cgi`, it walks **every**
member profile and deletes messages older than `MSG_PRUNE_DAYS`
(lines 1115-1160). On a large board that is an O(members x messages) sweep
executed inside a page render.

##### `FUNC::Member` (lines 1182-1444) -- 12 subs

Member records. Conventionally `my $mem`.

| Method | Purpose |
|---|---|
| `AddMember` | Creates the profile row, mints the member ID, hashes the password, updates the name and email indexes |
| `LoadMember` | Fetches by `by id` / `by email` / `by ip` / name; returns a guest structure on miss |
| `UpdateMember` | Writes the profile back; when the caller is `Post`, also runs rank promotion and optional group advancement |
| `CheckName`, `Check_Mem_Email` | Uniqueness lookups against the indexes |
| `MD5` | `Crypt::MD5` hex digest of password + lowercased name |
| `SetUpGuest` | Returns the canonical guest pseudo-member hash |
| `RandomPassword` | Eight random alphanumerics |
| `convert_to_num`, `convert_to_chr`, `GetLetter` | Member-ID and alphabet-index helpers |

The member ID format is set in `AddMember`:

```perl
	my $IdPart   = $obj->convert_to_num($IN->{'MEMBER'}->{'MEMBER_NAME'});
	my $Insert   = { MEMBER_NAME => $IN->{'MEMBER'}->{'MEMBER_NAME'},
					 MEMBER_ID   => "$IdPart".'-'."$Time",
				   };
```
-- `Sources/Lib/FUNC.pm:1212-1215`

-- the ASCII code of the first letter of the name, a hyphen, and the UNIX
timestamp. This composite ID is what appears in every URL and every foreign key
in the database.

##### `FUNC::Mailer` (lines 1449-1632) -- 4 subs

Outbound mail. Conventionally `my $mail` or `$SEND`.

`parse_template` pulls a row from `email_templates`, prepends `EMAIL_HEADER`,
appends `EMAIL_FOOTER`, substitutes `<#TOKEN#>` markers, and returns HTML or
plain depending on `EMAIL_CONTENT`. `Send` dispatches by `EMAIL_TYPE`: `smtp`
loads the bundled `Mail::Sendmail`, `send_mail` hand-rolls a pipe to the
configured sendmail binary. Both then append to `Email-log.cgi` if `LOG_EMAILS`
is on -- and that log records the full message body, recipient and sender in
plain text. `my_gen_id` mints message IDs.

#### 4.2 `Lib/ADMIN.pm` -- 365 lines, `package FUNC::ADMIN`

The admin-side counterpart to `FUNC::Output`. Ten live subs: `new`, `Error`,
`Print`, `Output`, `static_screen`, `redirect`, `pure_redirect`,
`print_http_header`, `make_module`, `write_log`.

`Error` holds a nine-entry hard-coded English error table (there is no admin
language pack -- the entire control panel is English-only). `Output` wraps a
module's HTML in `Admin::SKIN::std_print`, picks the section icon from an
eleven-entry `%IMG` map keyed on the `WHERE` argument, and substitutes
`<#TITLE#>`, `<#NAV#>`, `<#OUTPUT#>`. Both `Print` and `Output` apply the
`AD=1` -> `CP=1` rewrite to URLs and form fields.

`make_module` (line 210) is the product's configuration-persistence mechanism and
deserves attention: it serializes a hash reference into a fresh Perl module on
disk --

```perl
print FH <<_END_PRINT_;
package $IN{'PKG_NAME'};
  
  sub new {
    my \$pkg = shift;
    my \$obj = {
_END_PRINT_
```

-- then one `'KEY' => q!value!,` line per key (or `qq!...!` when `INTERPOLATE` is
set, which is how `Styles.pm` gets runtime `$iB::INFO` interpolation), then the
`bless`/`return`/`1;` tail. It backs the old file up to `bak.<name>` first,
`chmod`s to 0777 if it cannot write, and `flock`s if `FLOCK` is configured. Every
settings save in the control panel, plus `Data/ForumJump.pm`, `Data/Stats.pm`,
`Skin/*/Styles.pm`, `gfx_data.cfg` and `Boardinfo.cgi` itself, is produced by
this one routine.

`write_log` is 55 lines of admin audit logging that begins with a bare `return;`
followed by `###### DEPRECIATED` (`Sources/Lib/ADMIN.pm:301-306`). It is still
called from `Admin::WebRing::SaveRing` and elsewhere; it does nothing.

#### 4.3 `Lib/Crypt.pm` -- 694 lines, `package Crypt`

A pure-Perl software implementation of the Unix `crypt()` DES function, with a
POD header naming its provenance precisely:

> Written by Martin Vorlaender, martin@radiogaga.harz.de, 11-DEC-1997
> Based upon Java source code written by jdumas@zgs.com, which in turn is
> based upon C source code written by Eric Young, eay@psych.uq.oz.au

Twelve subs, all faithful transliterations of the C/Java originals down to their
comment signatures (`sub PERM_OP # void PERM_OP(int a, int b, int n, int m, int
results[])`). It carries nineteen large lookup tables at file scope (`@SPtrans0`
... `@SPtrans7`, `@skb0` ... `@skb7`, `@con_salt`, `@cov_2char`, `@shifts2`), which
is why it is one of the two `Sources/` files with `use strict` commented out
(line 34: `#use strict;`).

It exists solely so that Ikonboard 2 password hashes -- DES `crypt`, salted with
the first two letters of the username -- can still be verified after an upgrade
on a host whose Perl lacks a working `crypt`. Loaded on demand at
`Sources/Sessions.pm:196`.

#### 4.4 `Lib/MD5.pm` -- 213 lines, `package Crypt::MD5`

Pure-Perl MD5, version 1.5, from an RCS-tracked original
(`#$Id: MD5.pm,v 1.16 2000/09/19 22:19:31 lackas Exp $`). Seventeen subs, most of
them constants (`A`, `B`, `C`, `D`, `MAX`) or internals (`padding`,
`rotate_left`, `gen_code`, `round`); the public surface is
`new`/`reset`/`add`/`addfile`/`digest`/`hexdigest` plus exportable `md5`/
`md5_hex`. `gen_code` builds the four round functions as strings and `eval`s
them, adapting the 32-bit masking to the local integer width.

`FUNC::Member::MD5` is the only caller. It hashes `password . lc(name)` -- an
unsalted, single-round MD5 of the password concatenated with the username, which
was ordinary practice in 2002 and is the reason a leaked `member_profiles` table
from an Ikonboard 3 board is trivially crackable today.

---

### 4.5 `Sources/iDatabase/` -- the database abstraction layer

13 files, 6,530 lines. Ikonboard 3's headline feature over Ikonboard 2 was that
the same code could run on flat DBM files or on a real SQL server. This directory
is that promise.

#### 5.1 `iDatabase/SQL.pm` -- 185 lines, `package iDatabase::SQL`

The front door, authored by Matthew Mecham. Instantiated exactly once, at
`ikonboard.cgi:289`, and passed as `$db` to every controller in the product.

`new` takes named arguments (`DATABASE`, `IP`, `PORT`, `USERNAME`, `PASSWORD`,
`DB_DIR`, `DB_PREFIX`, `DB_DRIVER`, `ATTR`), builds the driver class name by
string concatenation, `require`s the corresponding file, and calls the driver's
`newSQL`:

```perl
    my $class = "iDatabase::Driver::$args{DB_DRIVER}";
    # Make global
    $DRIVER   = $class;
    ...
        eval { require "$r_class.pm" };
    ...
    $obj->{driver} = $class->newSQL( \%args );
```
-- `Sources/iDatabase/SQL.pm:45-62`

Everything else goes through `AUTOLOAD`, which checks a `%SUBS` table of
lazily-compiled accessors and otherwise forwards the call to the driver object.
The lazily-compiled accessors are `driver`, `prefix`, `show_query`,
`query_count`, `matched_records` and `error` -- six one-line subs stored as
heredoc source and `eval`ed on first use.

**Notable -- a dead defaults block.** Lines 167-182 define five more entries:

```perl
## DEFAULTS ##
$SUBS{def_select} = <<'END_OF_SUB';
sub select { my $obj = shift; return $obj->{driver}->select( @_ ); }
END_OF_SUB
```

They are keyed `def_select`, `def_insert`, `def_update`, `def_query`,
`def_delete` -- but `AUTOLOAD` tests `exists $SUBS{$method}` where `$method` is
the bare method name (`select`, not `def_select`). The keys can never match, so
these five bodies are never compiled. The effect is benign -- the `AUTOLOAD`
fall-through does exactly the same forwarding -- but the block is unreachable
code, and `select`/`insert`/`update`/`query`/`delete` pay the `AUTOLOAD` cost on
every one of the ~800 call sites in the product rather than being compiled once.

**Notable -- a no-op teardown.** `reset` (line 103) is:

```perl
sub reset {
    undef %CONNECTED;
    # Kludge alert!
    no strict 'refs';
    eval {"$DRIVER::disconnect( \@_ )"};
}
```

The `eval BLOCK` evaluates a double-quoted *string* and discards it; no
subroutine is called. `$DRIVER::disconnect` would in any case be a package
variable named `disconnect` in package `$DRIVER`, not the driver's method. The
driver is therefore never explicitly disconnected -- which is harmless under
mod_cgi and intentional under mod_perl, where `Apache::DBI` pools the handle.

#### 5.2 `iDatabase/Driver/Base.pm` -- 300 lines, `package iDatabase::Driver::Base`

The abstract driver. Four of the five drivers inherit from it via
`@ISA = qw(iDatabase::Driver::Base)`.

It defines 28 subs in three groups:

**(a) Real shared implementations** -- the ones the subclasses actually rely on:

| Sub | Behavior |
|---|---|
| `make_hash_ref($sth)` | `fetchrow_hashref` with every column name upper-cased. Credited to "infection" |
| `parse_where($where)` | Translates the product's Perl-flavoured where syntax to SQL: `eq`->`=`, `ne`->`<>`, `==`->`=`, `!=`->`<>`, `=~`->`REGEXP`, `!~`->`NOT REGEXP`, `and`/`or`/`&&`->uppercase, and `/.../`->`'...'` |
| `parse_limit($range)` | `"0 to 24"` -> `" LIMIT 0,25"` (mySQL flavour; pgSQL overrides) |
| `load_cfg($table)` | `do`es `Database/config/<table>.cfg` and populates `cur_table`, `cur_p_key`, `cur_method`, `cur_update`, `cur_ID`, `cur_DBID`, `cur_INDEX`, `all_cols`, `col_name[]`, `total_cols` from the `$IMPORT::STRING` / `$IMPORT::COLS` globals the .cfg sets |
| `decode_record($string)` | Splits a stored row on the `\|^\|` delimiter into a column-named hash |
| `encode_record($values)` | The inverse; escapes newlines to `\n` |
| `rebuild_record` | `load_cfg` + `decode_record`, with caller-supplied overrides applied on top |
| `join_record` | `load_cfg` + `encode_record` |

**(b) Stubs returning `@_`** -- the interface contract that each driver overrides:
`connect`, `disconnect`, `create_table`, `drop_table`, `drop_tables`,
`drop_database`, `select`, `query`, `insert`, `delete`, `update`, `count`,
`back_up`, `table_import`, `lock_table`, `release_lock`, `update_index`,
`create_index`, `drop_index`.

**(c) A redefinition.** `rebuild_record` appears twice -- once as a stub at line
164 and once as the real implementation at line 182. The later definition wins,
with a "Subroutine rebuild_record redefined" warning at compile time.

The driver interface, then, is: a constructor named `newSQL(\%args)`; the CRUD
quintet `select` (one row by key) / `query` (many rows by criteria) / `insert` /
`update` / `delete`; `count`; DDL `create_table` / `drop_table` / `drop_tables` /
`drop_database`; index maintenance `create_index` / `drop_index` /
`update_index`; bulk `back_up` / `table_import`; and concurrency `lock_table` /
`release_lock`. Callers pass named arguments -- `TABLE`, `KEY`, `ID`, `DBID`,
`WHERE`, `COLUMNS`, `VALUES`, `SORT_KEY`, `SORT_BY`, `RANGE`, `MATCH`, `INDEX` --
and the driver maps them onto whatever its storage engine understands.

#### 5.3 The four live drivers

| File | Lines | Bytes | Package | Notes |
|---|---|---|---|---|
| `Driver/DBM.pm` | 1,270 | 38,214 | `iDatabase::Driver::DBM` | Default engine |
| `Driver/Oracle.pm` | 1,112 | 33,228 | `iDatabase::Driver::Oracle` | Contributed |
| `Driver/pgSQL.pm` | 1,045 | 29,178 | `iDatabase::Driver::pgSQL` + `::Bind` | |
| `Driver/mySQL.pm` | 1,028 | 29,595 | `iDatabase::Driver::mySQL` + `::Bind` | |

**`DBM.pm`** -- the largest driver, and the one that has to do the most work
because DBM gives it nothing but a key/value store. It sets up `AnyDBM_File`
with an explicit preference order:

```perl
BEGIN { @AnyDBM_File::ISA = qw(DB_File GDBM_File NDBM_File SDBM_File) }
```
-- `Sources/iDatabase/Driver/DBM.pm:16`

Rows are stored as `\|^\|`-delimited strings under a primary key, and every
feature SQL provides for free -- `WHERE` evaluation, sorting, ranges, counts,
secondary indexes -- is re-implemented in Perl here. That is what
`create_index` / `drop_index` / `update_index` / `query_index` are for: named
secondary indexes maintained as separate DBM files, which is how member lookup
by name or email works without a table scan. It declares
`@r_IGNORE = ( 'active_sessions' )` to suppress delete notices for the churn
table, and sets `$INFO->{'FLOCK'}` by probing the platform.

**`mySQL.pm`** -- the reference SQL driver by Matthew Mecham. `newSQL` builds the
object, `$CONN ||= $obj->connect($args)` memoizes the DBI handle in a package
global (deliberately, for mod_perl / `Apache::DBI`), and the CRUD methods compose
SQL, run it, and hand rows through `get_row`/`make_hash_ref`. `client_do`,
`client_do_do`, `client_exec`, `tables`, `table_attr` and `parse` are the
back-channel used by the admin SQL client. The second package in the file,
`iDatabase::Driver::mySQL::Bind`, is a tied-array class
(`TIEARRAY`/`FETCH`/`STORE`/`FETCHSIZE`/`PUSH`/`POP`/...) used to present result
sets lazily; its `use vars qw($OBJ $IDX); # for mod_perl` comment shows the same
persistence concern.

**`pgSQL.pm`** -- structurally a copy of the mySQL driver with the PostgreSQL
differences factored in. It overrides `parse_where` (which `mySQL` inherits from
Base) and carries its own `parse_limit` semantics -- Base's comment notes pgSQL
"needs to use it the other way around for some surreal reason", i.e.
`LIMIT n OFFSET m` rather than `LIMIT m,n`. It also carries a `mylog` debug sub
that the other drivers do not, and the same `::Bind` tied-array class.

**`Oracle.pm`** -- the only driver with a different named author:
`# Driver Author: Andrey Prokopenko <faceless@ortv.ru>`. It has the largest sub
count of the SQL drivers (29) because Oracle needed extra shims: `convert_field_name`
(Oracle's 30-character identifier limit and case folding), `parse_raw`, and a
distinct `parse_where`. It does not ship a `::Bind` class.

#### 5.4 `Driver/CSV.pm` -- 1,014 lines -- unreachable

The fifth driver is dead code. Three independent facts establish it:

1. **Wrong package.** It declares `package iDatabase;`, not
   `package iDatabase::Driver::CSV;`. `iDatabase::SQL::new` builds the class name
   as `"iDatabase::Driver::$args{DB_DRIVER}"` and calls `$class->newSQL(...)` --
   which would fail with "Can't locate object method newSQL via package
   iDatabase::Driver::CSV".
2. **Wrong constructor.** It provides `sub new`, not `sub newSQL`.
3. **No inheritance.** It does not `require` or `@ISA` `iDatabase::Driver::Base`,
   so it has no `load_cfg`, no `parse_where`, no `decode_record`.

It is also not offered anywhere. The installer's driver menu
(`install_modules/database.pl:44`) lists only DBM, mySQL, PostgreSQL and Oracle;
so does `Admin::Import`'s target selector (`Sources/Admin/Import.pm:114-119`);
and there is no `Search/API/api_CSV.pm` for it to pair with.

Its own header dates it precisely:

```perl
package iDatabase;
use strict;

################################################################
#
# iDatabase v1.0 (May 2001)
#
# Developed for Ikonboard.
# Author: Matthew Mecham <matt@ikonboard.com>
#
# Accessor methods to databases
#
# CSV: Interface to text files.
#
################################################################
```
-- `Sources/iDatabase/Driver/CSV.pm:1-15`

May 2001 puts it a year before the 3.1.1 release and before the
`iDatabase::Driver::*` / `newSQL` / `@ISA` convention that the other four drivers
follow -- it is iDatabase v1.0, orphaned when the driver API changed, and carried
forward into the 3.x tree unmodified. Its method list (`create_table`, `select`,
`query`, `insert`, `delete`, `update`, `rollback`, `ping`, `export`,
`lock_table`, `release_lock`, `decode_record`, `encode_record`, `load_cfg`,
`my_load_table_entry`, `my_update_table_entry`, `my_insert_table_entry`,
`my_delete_table_entry`, `_do_table_entry`) shows it was a complete, working
driver in its day -- including a `rollback` that no driver in 3.1.1 has.

It is the largest single piece of dead code in the product; see section 9.2.

#### 5.5 `iDatabase/Admin/` -- five files, 578 lines

The schema installer, separate from the runtime drivers. `a_base.pm` declares
`package iDatabase::Admin::a_base`; the four engine files (`a_DBM.pm`,
`a_mySQL.pm`, `a_pgSQL.pm`, `a_Oracle.pm`) all declare the **same** package,
`iDatabase::Admin::SQL`, so exactly one may be loaded per request. Each provides
`new` and `install_database`.

They are loaded dynamically by table-name-free string interpolation in the
control panel:

```perl
	require "iDatabase/Admin/a_$iB::IN{'DB'}.pm";

	my $aDB = iDatabase::Admin::SQL->new();

	$aDB->install_database( schema_dir => $iB::INFO->{'IKON_DIR'}."INSTALL_DATA",
	                        return_err => 1,
	                        create_tbl => $iB::IN{CREATE}, ... );
```
-- `Sources/Admin/dbHandler.pm:358-375`

`install_database` reads the appropriate `INSTALL_DATA/*_schema.txt` file
(`mysql_schema.txt` 436 lines, `oracle_schema.txt` 437, `postgres_schema.txt`
419) and executes it. Note that the require path is built from a request
parameter; the value is sanitized by `iB::_clean_value` but that routine does not
strip `.` or `/`.

---

### 4.6 `Sources/Admin/` -- the control panel

34 files, 24,088 lines -- a third of the entire product. Every module follows one
shape: the same four-line `BEGIN` block, the same five file-scoped singletons
(`$SKIN`, `$std`, `$mem`, `$ADMIN`, `$INFO`), a `splash` screen, a set of
`verb`/`do_verb` pairs, and a `process` that dispatches on `CODE` with `splash`
as the fallback. HTML is assembled almost entirely by calling `Admin::SKIN`
widget methods rather than by writing markup -- which is why `td_select` (279
calls), `td_input` (216), `section_header` (195), `begin_table` (120),
`end_table` (117), `form_start` (116), `form_end` (112) and `td_submit` (109)
rank so high in the product-wide method census.

#### 6.1 The table

| File | Lines | `act=` | Section icon | Purpose | `CODE=` values |
|---|---:|---|---|---|---|
| `Functions.pm` | 370 | -- | -- | CP entry, auth, frameset, 30 module loaders | (33 `act` values) |
| `SKIN.pm` | 707 | -- | -- | Admin HTML widget library (23 subs) | -- |
| `Index.pm` | 308 | `body` | WELCOME | CP welcome frame; prunes stale registrations | -- |
| `Menuadmin.pm` | 787 | `menuadmin` | -- | The CP navigation menu frame | `edit` |
| `Options.pm` | 2,298 | `ops` | OPTIONS | Every board-wide setting (see section 6.2) | 32 |
| `MemberGroups.pm` | 1,584 | `group` | MEMBERS/FORUMS | Group permissions and per-forum masks | `edit`, `doedit`, `default`, `dodefault`, `add`, `doadd`, `del`, `dodel`, `finaldelete`, `forummask`, `doforummask`, `setforummask` |
| `ForumControl.pm` | 1,575 | `forum` | FORUMS | Forum CRUD, ordering, recount, rules | `view`, `edit`, `add`, `do_add`, `del`, `dodel`, `fordel`, `count`, `dorecount`, `reorder`, `doreorder`, `list_rules`, `edit_rules`, `doedit_rules` |
| `SkinControl.pm` | 1,449 | `styles` | STYLES | Per-skin colors, CSS, JS, graphics, templates | `edit`, `doedit`, `do_skin_edit` |
| `MemberControl.pm` | 1,392 | `member` | MEMBERS | Member edit, ban filter, titles, mass email, CSV export | `edit`, `doedit`, `ban`, `doban`, `reg`, `doreg`, `titles`, `dotitles`, `del`, `delone`, `delfinal`, `outdate`, `email`, `doemail`, `show_outdate`, `del_outdate`, `list`, `do_list` |
| `Convert_ib.pm` | 1,272 | `convert` | DATABASE | The Ikonboard 2.1.9 importer | `one`, `members`, `cats`, `d_cats`, `forums`, `d_forums`, `mods`, `d_mods`, `posts`, `d_posts` |
| `Templates.pm` | 1,007 | `template` | STYLES | Board / email / SSI / news / ticker templates | `choose`, `doedit`, `news`, `donews`, `update`, `do_update`, `tiker`, `dotiker` |
| `dbHandler.pm` | 942 | `dbHandler` | DATABASE | Create schema, drop tables, switch driver | `setup`, `startsetup`, `drop`, `dodrop`, `switch`, `doswitch` |
| `SQLclient.pm` | 937 | `sqlclient` | SQLCLIENT | Interactive SQL console | `showtable`, `action`, `do_delete`, `do_drop_t`, `drop_i`, `drop_s`, `viewpolls`, `dellpolls`, `forum_info` |
| `Filemanager.pm` | 919 | `file` | OPTIONS | Browse, chmod, rename, upload, delete files | `main`, `bar`, `display`, `show`, `chmod`, `rename`, `delete`, `upload` |
| `ModControl.pm` | 913 | `mods` | FORUMS | Add/edit/remove forum moderators | `add`, `doadd`, `edit`, `doedit`, `confirmedit`, `del`, `dodel`, `moddel` |
| `Import.pm` | 906 | `import` | DATABASE | Restore an exported backup, optionally into another driver | `start`, `do_tables`, `prepare_posts`, `do_forums`, `do_messages`, `finish` |
| `Category.pm` | 832 | `cat` | CATS | Category CRUD and ordering | `00`, `01`, `02`, `03`, `delete`, `dodel`, `reorder`, `doreorder` |
| `Authorise.pm` | 810 | `auth` | MODERATE | Manual registration / lost-password approval queues | `reg_list`, `do_reg`, `list_lost`, `do_listlost`, `comp_listlost`, `preview`, `list_preview`, `do_preview`, `prune`, `list_prune`, `do_prune` |
| `LangControl.pm` | 731 | `lang` | LANGUAGES | Edit, create, import and export language packs | `list`, `view`, `apply_edit`, `new`, `do_new`, `export`, `do_export`, `import`, `do_import`, `remove`, `do_remove` |
| `SkinHandler.pm` | 703 | `skin` | STYLES | Skin package install/export/remove (tar) | `new`, `do_new`, `export`, `do_export`, `import`, `do_import`, `remove`, `do_remove` |
| `Tools.pm` | 463 | `tools` | MAINTAIN | Repair chores: dupe groups, custom titles, skins, calendar | `groups`, `do_groups`, `titles`, `do_titles`, `skin`, `do_skin`, `calendar`, `do_calendar` |
| `Backup.pm` | 456 | `bak` | DATABASE | Export the database to tar in `BACK_UP/` | `dobak` |
| `BoardTemplates.pm` | 405 | `board` | STYLES | The outer page templates (`<% IKONBOARD %>` shells) | `edit`, `do_edit`, `save_template`, `add` |
| `Helpcontrol.pm` | 382 | `help` | OPTIONS | Board help topic CRUD | `add`, `doadd`, `edit`, `editform`, `doedit`, `del`, `dodel`, `dodoagadoo` |
| `Tempfiles.pm` | 357 | `temps` | MAINTAIN | Clear the `Database/Temp` scratch directory | `del` |
| `EmoticonControl.pm` | 320 | `emoticon` | STYLES | Emoticon table CRUD | `edit`, `add`, `doadd`, `viewall` |
| `Adminlogs.pm` | 301 | `logs` | MAINTAIN | View and delete moderator logs | `del`, `view`, `dodel` |
| `Mimetypes.pm` | 276 | `mime` | STYLES | Attachment MIME type table and icons | `edit`, `add`, `doadd` |
| `DBMclient.pm` | 174 | `dbmclient` | OPTIONS | Rebuild DBM secondary indexes | `doindex` |
| `Stats.pm` | 145 | `stats` | MAINTAIN | Recount board statistics from the forums | `edit` |
| `Session.pm` | 138 | `sess` | MAINTAIN | View and purge active sessions | `delete` |
| `Online.pm` | 124 | `online` | MAINTAIN | Take the board online/offline, set the message | `edit` |
| `WebRing.pm` | 105 | `webring` | OPTIONS | Edit the Ikonboard web-ring link list | `savering` |

#### 6.2 `Options.pm` -- 2,298 lines, 107,861 bytes: why it is the largest file

`Sources/Admin/Options.pm` is the biggest file in the product by a wide margin --
40% larger than the next (`ModCP.pm`). It is not algorithmically complex; it is
large because it is **the entire board configuration surface, rendered as one
form per topic, in a single module**, and because every field is a separate
explicit call.

The structure is sixteen pairs of subs, each pair being "render this settings
screen" and "write this settings screen back":

| Pair | Screen |
|---|---|
| `show_paths` / `do_paths` | Board URLs and filesystem paths |
| `show_ops` / `do_ops` | Board name, cookies, time/date, load limit, security, misc |
| `defaults` / `do_defaults` | Default skin and language |
| `active` / `do_active` | Per-group highlighting of active-user names |
| `forum` / `do_forum` | Forum display options, default sort, thread prefixes |
| `topic` / `do_topic` | Topic display options |
| `post` / `do_post` | Post permissions, Flash permissions, word filter, etiquette filter |
| `email` / `do_email` | Addresses, transport, content type, logging |
| `search` / `do_search` | Search limits and skip-words |
| `register` / `do_register` | Registration permissions, board rules, required fields, reserved names |
| `member` / `do_member` | Member permissions |
| `pm` / `do_pm` | Messenger permissions |
| `report` / `do_report` | Post-report configuration |
| `event` / `do_event` | Calendar/event configuration |
| `password` / `do_password` | Set the secondary admin password |
| `password_set` / `do_password_set` | Choose which CP areas that password guards |

Three things multiply the line count:

1. **One call per setting.** A boolean is a five-to-eight-line
   `$SKIN->td_select(...)` with an inline two-element `DATA` array; a text field
   is a `td_input` with `TEXT`, `NAME`, `VALUE`, `REQ`. There are several hundred
   settings.
2. **Help text lives in the code.** `section_header( TITLE => ..., TEXT => ...)`
   carries paragraphs of prose as Perl string literals. The word-filter header
   alone (line 1445) is a five-clause explanation of the filter syntax; the load
   limit header (line 404) is four sentences on processor counts.
3. **The `do_*` half repeats the field list.** Each writer sub re-enumerates
   every field to build the `$OLD` hash for `FUNC::ADMIN::make_module`, so every
   setting appears at least twice in the file.

Its `process` (line 2237) is a flat 32-entry `%Mode`, and its error fallback is
blunt: `sub error { my ($obj, $db) = @_; die "Error!"; }` (line 2292) -- an
unrecognized `CODE` produces an "Ikonboard CGI Error" page reading "Error!".

#### 6.3 `MemberGroups.pm` -- 1,584 lines

The permission model. A member group row carries some three dozen boolean and
scalar permissions -- `ACCESS_CP`, `ACCESS_OFFLINE`, `USE_PM`, `MAX_MESSAGES`,
`POST_POLLS`, `VOTE_POLLS`, `AVOID_Q`, `IS_SUPMOD`, `OTHER_TOPICS`,
`INVITE_FRIEND` and so on -- each of which is consulted somewhere in the front
end as `$iB::MEMBER_GROUP->{NAME}`.

Its size comes from the same cause as `Options.pm` (one widget call per
permission, twice) plus one genuinely separate feature: the **forum mask**
(`forummask` / `doforummask` / `setforummask`), a grid of every group against
every forum with four permissions per cell (view, view threads, start threads,
reply). That grid is generated, parsed and written back into the
`FORUM_VIEW_THREADS` / `FORUM_START_THREADS` / `FORUM_REPLY_THREADS` columns,
which store either `*` (everyone) or a comma-separated group ID list -- the format
tested by `grep { $_ == $iB::MEMBER->{'MEMBER_GROUP'} }` in a dozen front-end
modules.

Deleting a group is a three-step confirmation: `del` -> `dodel` ->
`finaldelete` -> `yes_i_mean_it`.

#### 6.4 `ForumControl.pm` -- 1,575 lines

Forum CRUD. A forum row is wide -- name, description, category, position, status,
protection type and password, per-forum skin, per-forum sort key and order,
prune days, moderation flag, attachments flag, HTML flag, iB-code flag, the three
permission masks, plus six denormalized last-post columns -- and the add and edit
screens each render all of it.

Beyond CRUD it owns three operations with real logic: `reorder`/`doreorder`
(drag-free position renumbering within a category), `recount`/`dorecount`
(rebuild `FORUM_TOPICS`/`FORUM_POSTS` and the last-post denormalization by
scanning the topic table), and the rules editor
(`list_rules`/`edit_rules`/`doedit_rules`) writing the `forum_rules` table that
`Forum::ShowRules` displays.

Deletion is `del` -> `dodel` -> `fordel` -> `really_I_mean_it_this_time`.

#### 6.5 `MemberControl.pm` -- 1,392 lines

Everything that can be done to a member from the CP:

* `edit`/`do_edit` -- the full profile, group, title, warn level, post count;
* `ban`/`doban` -- the IP/name ban filter list (`IP_FILTER`, wildcard `*`
  supported, consumed by `Sessions::authenticate`);
* `reg`/`doreg` -- manually register a member;
* `titles`/`dotitles` -- the rank ladder (`member_titles`: post threshold, title,
  pip count, optional `ADVANCE_GROUP` for automatic promotion);
* `del`/`delone`/`delfinal` -- three-stage deletion;
* `outdate`/`show_outdate`/`del_outdate` -- find and purge inactive accounts;
* `email`/`doemail` -- mass email to a group or the whole membership;
* `list`/`do_list` -- export the entire member list as a CSV file into the
  `OUTGOING/` directory.

#### 6.6 `SkinControl.pm` -- 1,449 lines

The skin editor, distinct from `SkinHandler.pm` (which installs and exports whole
skin packages as tar files). `SkinControl` edits the *contents* of an installed
skin, in six modes selected by a secondary parameter rather than by `CODE`:
`TEMP`/`do_TEMP` (templates), `CSS`/`do_CSS`, `JS`/`do_JS`, `COLOR`/`do_COLOR`
(the ~140 color and image entries in `Styles.pm`), `GFX`/`do_GFX`
(`gfx_data.cfg` image properties), `HTML`/`do_HTML`. All six persist through
`FUNC::ADMIN::make_module` with `INTERPOLATE => 'yes'` so that `Styles.pm` can
hold `$iB::INFO->{'IMAGES_URL'}` references that resolve at runtime. It reads the
skin roster from `Data/SkinList.cfg`.

#### 6.7 `Convert_ib.pm` -- 1,272 lines: the Ikonboard 2 importer

The only importer for foreign data in the product. It converts an Ikonboard
2.1.9 installation -- the flat-file, `ikon.lib`-based predecessor -- into the
Ikonboard 3 database.

**Subs (21):** `new`, `splash`, `step_one`, `members`, `cats`, `do_cats`,
`forums`, `d_forums`, `mods`, `d_mods`, `posts`, `d_posts`, `process`,
`_load_db_cfg`, `_encode_record`, `_get_old_forums`, `_get_old_cats`,
`_split_mem`, `_load_config`, `_check_convert`, `_write_config`.

The flow is a wizard with five independently runnable stages, each of which
records completion in a converter datafile so the splash can show progress
(`[ Member Conversion Successful ]`):

1. **step_one** -- asks for the path to the iB2 installation ("Enter the full path
   to where ikon.lib resides") and the collision policy for member names that
   already exist in the iB3 board.
2. **members** -- reads the iB2 `members/` directory, splits each record with
   `_split_mem`, maps the fields, and inserts profiles. This is where the DES
   password hashes come across intact, which is what `Sources/Lib/Crypt.pm`
   exists to verify afterwards.
3. **cats / do_cats** -- presents each iB2 category with a mapping form; creates
   new iB3 categories rather than overwriting existing ones.
4. **forums / d_forums** and **mods / d_mods** -- the same pattern for forums and
   their moderators, with an explicit instruction to convert categories first.
5. **posts / d_posts** -- topics and posts, batched.

`_encode_record` and `_load_db_cfg` bypass the driver layer and write rows in the
storage format directly, which is how the importer stays fast on large boards.

#### 6.8 `Import.pm` -- 906 lines: not what the name suggests

Despite the name, `Admin::Import` does **not** import from other forum software.
It is the read side of `Admin::Backup`: it restores an Ikonboard export, and its
one interesting capability is that it can restore into a *different* database
engine than the one the data was exported from -- which is the product's supported
path for migrating DBM -> mySQL, mySQL -> pgSQL, and so on.

The splash scans `BACK_UP/` for files matching `EXPORT-<id>-<timestamp>.tar`, and
offers a target engine:

```perl
	$html .= $SKIN->td_select( TEXT     => "Import into...",
							   NAME     => 'DB_DRIVER',
							   REQ      => 1,
							   VALUES   => 'DBM',
							   DATA     => [
											  { NAME => 'DBM'   , VALUE => 'DBM'    },
											  { NAME => 'mySQL' , VALUE => 'mySQL'  },
											  { NAME => 'pgSQL' , VALUE => 'pgSQL'  },
											  { NAME => 'Oracle', VALUE => 'Oracle' },
										   ],
							 );
```
-- `Sources/Admin/Import.pm:110-120`

It uses the bundled `Archive::Tar` and `File::Path` to unpack, then walks an
18-entry `$TABLES` map of table names, importing each in turn, with topics and
posts handled separately and in configurable batches (`prepare_posts`,
`import_forums`, `_do_forum`, `convert_for_index`, `do_messages`). The `$TABLES`
map has seven table names commented out immediately below it
(`forum_polls`, `forum_poll_voters`, `forum_subscriptions`, `forum_topics`,
`message_data`, `search_log`, `forum_posts`) -- those are the ones handled by the
special-case routines rather than the generic loop.

Verified: a case-insensitive search of the whole tree for `phpBB`, `UBB`,
`vBulletin`, `YaBB`, `Snitz`, `Discus`, `ezboard`, `WWWBoard` and `Invision`
returns no hits outside unrelated prose. Ikonboard 2 is the only supported
migration source.

#### 6.9 `SQLclient.pm` -- 937 lines

An in-browser SQL console: browse tables, view and edit rows, run `SELECT`,
`UPDATE`, `DELETE`, `CREATE`, `ALTER`, `DROP`. It reaches through
`iDatabase::SQL` to the driver's `client_do` / `client_exec` / `tables` /
`table_attr` back-channel methods, which is why those exist on the SQL drivers
and not on `Base`. It also carries two purpose-built repair tools --
`viewpolls`/`dellpolls` (find and remove polls created incorrectly) and
`forum_info` (repair the forum table) -- which are effectively bug workarounds
promoted to features.

Statements the driver cannot handle fall to `not_yet`, which reports "Sorry, but
SQL client does not support this query!". The module is one of the areas that can
be put behind the secondary admin password (`P_SQL_CLIENT`).

#### 6.10 `Filemanager.pm` -- 919 lines

A web file manager over the board directory: directory listing with icons, file
view, `chmod`, rename, delete and upload. `_get_permissions`, `_get_date`,
`_get_size`, `_read_dir` and `_get_icon` are the listing helpers; `_set_state`
tracks the current directory. It is guarded by the secondary admin password
(`P_FILE_MANAGER`) and contains three separate copies of the ARC4 password
decryption block (lines 84-92, 142-150, 171-178).

#### 6.11 `Menuadmin.pm` -- 787 lines

The control panel's left-hand navigation frame -- a single generated HTML page
with collapsible sections whose open/closed state is persisted per administrator
in `Database/Temp/menu-<memberid>.cgi`. It has only four subs (`new`, `start`,
`edit`, `process`); nearly all 787 lines are one enormous interpolated HTML
string listing every CP destination.

**Notable:** its file mtime is 11/25/2002 -- four to five months later than every
other file in the tree, which cluster in June and July 2002. It is the only file
in the product dated after the 3.1.1 release window.

#### 6.12 `SKIN.pm` -- 707 lines, `package Admin::SKIN`

Not a skin in the front-end sense: this is the admin CP's HTML widget library,
and it is why the admin modules contain so little literal markup. Twenty-three
subs:

* **Page furniture:** `log_in`, `Error`, `std_print`, `static`, `do_frames`,
  `Redirect`, `top`, `body`, `title`.
* **Table and form structure:** `begin_table`, `section_header`, `end_table`,
  `form_start`, `form_end`, `hidden_fields`.
* **Fields:** `td_input`, `td_checkbox`, `td_select`, `td_textarea`,
  `td_submit`.
* **Escaping:** `formalize`, `htmlalize`.

The `log_in` screen is worth reading for its warning text, which describes a
behavior implemented in `Admin::Functions`:

> If you do not log in after **5** attempts, you will be banned from the Control
> Panel for 15 minutes.<br>Also note, that if you are not an administrator, you
> will have your posting rights removed upon log in.

#### 6.13 `Functions.pm` -- 370 lines: the CP gate

Beyond dispatch (section 1.1), `process` implements the control panel's access policy in
four checks (`Sources/Admin/Functions.pm:46-102`):

1. Not logged in -> the `Admin::SKIN::log_in` screen.
2. Logged in but `ACCESS_CP` is false -> append a timestamp to
   `Database/Temp/log-<memberid>.cgi`, **set `ALLOW_POST => 0` on the member's
   profile**, and show the `not_admin` error. Merely requesting `CP=1` as a
   non-admin costs a logged-in member their posting rights.
3. An admin session file `Database/Temp/admin-<memberid>.cgi` must exist, and its
   timestamp must be within 28,800 seconds (`60*480`, eight hours) -- otherwise
   the file is deleted and the log-in screen is shown. On success the timestamp
   is refreshed.
4. `dologin` creates that file and removes the failed-attempt log.

The admin session is therefore a filesystem artifact independent of the board
session, which is what makes `AD=1` safe to bookmark.

#### 6.14 The maintenance modules

The five smallest admin modules (`Stats`, `Session`, `Online`, `Tempfiles`,
`DBMclient`, `WebRing`) are each a single screen:

* **`Stats.pm`** -- `edit` recomputes `TOTAL_MEMBERS`, `TOTAL_TOPICS`,
  `TOTAL_REPLIES` and the last-registered member by summing `forum_info` and
  counting `member_profiles`, then writes them back through
  `FUNC::STD::ib_stats` with `RESET => 1`.
* **`Session.pm`** -- lists `active_sessions` and deletes selected rows.
* **`Online.pm`** -- toggles `B_ONLINE` and edits `OFFLINE_MESSAGE`.
* **`Tempfiles.pm`** -- empties `Database/Temp/`.
* **`DBMclient.pm`** -- `doindex` walks every member profile and rebuilds the DBM
  secondary indexes; reports "`$count` members re-indexed".
* **`WebRing.pm`** -- edits the `WEB_RING` string in `Boardinfo.cgi`, stored as
  `url|&|title` pairs separated by `|^|`. Its `SaveRing` contains an oddity:
  `my $web_ring = join //, @new_web_ring;` (line 80) uses an empty *match*
  operator where an empty string was intended.

#### 6.15 Joke handler names

The control panel's confirm-step handlers are named by whoever was writing at the
time, and shipped as-is:

| Module | `CODE=` | Sub |
|---|---|---|
| `ForumControl.pm` | `fordel` | `really_I_mean_it_this_time` |
| `MemberGroups.pm` | `finaldelete` | `yes_i_mean_it` |
| `ModControl.pm` | `moddel` | `oh_just_do_it_already` |
| `Helpcontrol.pm` | `dodoagadoo` | `indeed` |

These are live endpoints, not dead code -- `dodoagadoo` is a working URL parameter
in the shipped help-topic editor.

---

### 4.7 `UserCP/`, `Misc/`, `Search/`, `Mail/`, `SSI/`, `iPerl/`

#### 7.1 `Sources/UserCP/` -- 6 files, 2,528 lines

| File | Lines | `act=` | Purpose |
|---|---:|---|---|
| `Menu.pm` | 486 | `UserCP` | Member control panel root |
| `Messenger.pm` | 697 | `Msg` | PM inbox, address book, preferences |
| `Messsend.pm` | 676 | `MSS` | PM composer and sender |
| `Messview.pm` | 428 | `MSV` | PM reader |
| `Lostpass.pm` | 245 | `LostPass` | Password recovery |
| `index.html` | -- | -- | Directory-listing decoy |

**`UserCP::Menu`** -- nineteen subs. `Splash` is the CP home; `Personal` renders
the profile panel with `_personal_splash`, `_personal_avatar` and
`_personal_panel`; `Email`, `Subs`, `Cancelsub`, `Settings`, `Account` are the
other five tabs. It carries `LoadForum`, `LoadTopic` and `SetSession` -- copies of
the same helpers found in `Post`, `iPoll`, `Moderate` and `ModCP`, because
`Subs`/`Cancelsub` need to resolve a subscribed forum or topic. `get_daily`
computes the member's posts-per-day figure; `get_avatar` resolves the three
avatar storage styles (installed name, remote URL, uploaded file).
**View:** `MenuView` (616 lines, 20 subs). **Lang:** `UserCPWords`.

**`UserCP::Messenger`** -- the inbox. `Splash` renders the folder view;
`msg_list` paginates; `delete` and `multiact` handle single and batch operations;
`prefs`/`do_prefs` the messenger settings; `add_member`/`del_member`/
`edit_member`/`do_edit`/`contact` the address book. `Check_new`, `_get_new`,
`_get_blank` and `my_get_stats` maintain the `message_stats` counters that
`FUNC::Output::member_bar` reads to draw the new-mail icon.
**View:** `MessengerView` (635 lines, 28 subs). **Lang:** `MessengerWords`.

**`UserCP::Messsend`** -- composition. `send` (CODE=04) renders,
`send_msg` commits, `send_form` builds the form (reusing `PostView` for the
iB-code buttons and emoticon table, so it loads both `PostWords` and
`MessengerWords`), `td_select` builds the recipient picker, `notepad` saves a
draft into `member_notepads.SAVED_M`.

**`UserCP::Messview`** -- reading one message. `view_msg` is 190 lines; the
module also carries its own `get_avatar` and `do_member` copies rather than
sharing `Menu`'s.

**`UserCP::Lostpass`** -- a four-step recovery: `Splash` (enter name or email),
`step_b` (generate a token, mail it), `step_c` (consume the token, generate and
mail a new password via `FUNC::Member::RandomPassword`), and `unlock_box` (clear
the lockout after too many attempts). Uses `FUNC::Mailer` directly as `$SEND`.

#### 7.2 `Sources/Misc/` -- 12 files, 1,323 lines

Small single-purpose handlers. Eleven modules, each `Misc::Something`, each with
`new` + `Process` and rarely more.

| File | Lines | `act=` | Behavior |
|---|---:|---|---|
| `Report.pm` | 279 | `Report` | Report-a-post form and mail to the forum's moderators. `form`/`send`. View `ReportView`, lang `ReportWords` |
| `Track.pm` | 181 | `Subs` | Add/remove a topic or forum subscription. `Process`/`subs`. Lang `MailFunctionsWords` |
| `Forward.pm` | 142 | `Forward` | Mail a topic link to an address. `show_form` (CODE=00) / `send_mail` (CODE=01). View `ForwardView` |
| `MailMember.pm` | 133 | `Mail` | Email a member without revealing the address. `mail_member` (00) / `send_mail` (01). View `MailView` |
| `Invite.pm` | 114 | `Invite` | "Invite a friend" mail. Single `Process`. View `MailView`, lang `MailFunctionsWords` |
| `Attachments.pm` | 108 | `Attach` | Increment `ATTACH_HITS`, then HTTP-redirect to the file. See below |
| `ICQ.pm` | 89 | `ICQ` | ICQ pager pop-up. View `PagerView`, lang `PagerWords` |
| `MSN.pm` | 84 | `MSN` | MSN pager pop-up. Same view/lang |
| `AOL.pm` | 80 | `AOL` | AIM pager pop-up. Same view; **does not** load `PagerWords` -- the one asymmetry among the three |
| `Cookies.pm` | 61 | `Cookies` | With `f`: mark that forum read. Without: expire every board cookie |
| `PMarkers.pm` | 62 | `PMarkers` | Mark all posts read: update `LAST_LOG_IN`, reset the `lastvisit` cookie |

**`Attachments.pm`** is worth reading in full because of what it does *not* do.
It checks that `ID`, `f`, `t` and `p` are present, that the forum has
`ALLOW_ATTACH`, increments the download counter, looks up the attachment row, and
then:

```perl
    # Redirect..
    # [ I have a feeling I'll be changing this section shortly ]

    print $iB::CGI->redirect( -uri => "$iB::INFO->{'UPLOAD_URL'}/$at->{'FILE_NAME'}" );
```
-- `Sources/Misc/Attachments.pm:95-98`

The file is served by the web server from a public directory; the module's only
access check is the forum-wide `ALLOW_ATTACH` flag, not the visitor's permission
to read that forum. The author's parenthetical suggests he knew.

It also carries the tree's other pop-culture comment, immediately below the
copyright block:

```perl
#BENDER: "I'm gonna make my own theme park with hookers and blackjack..
#        "infact, forget the theme park!"
```
-- `Sources/Misc/Attachments.pm:25-26`

**`PMarkers.pm`** has a live defect at line 52:
`-domain => $iB::INFO{'COOKIE_DOMAIN'}` uses the hash `%iB::INFO` rather than the
hash reference `$iB::INFO`. The value is always empty, so the `lastvisit` cookie
this module writes is set without a domain -- on a board configured with an
explicit cookie domain, "mark all read" writes a cookie the rest of the board
does not read back.

**`Cookies.pm`** contains a stray joke at line 40: `my $toilets = 'Cookies';`,
assigned and never used.

#### 7.3 `Sources/Search/` -- 6 files, 1,683 lines

`Search/api.pm` (356 lines, `package Search::api`) is the controller. Its
distinguishing feature is that it selects its own back-end at load time:

```perl
require "Search/API/api_".$iB::INFO->{DB_DRIVER}.".pm";
```
-- `Sources/Search/api.pm:29`

Four back-ends exist -- `api_DBM.pm` (337), `api_mySQL.pm` (264),
`api_pgSQL.pm` (275), `api_Oracle.pm` (277) -- and **all four declare the same
package**, `Search::API::api_functions`, so exactly one is ever in memory. Each
provides the same three subs:

| Sub | Contract |
|---|---|
| `run_query` | Execute the search against this engine and write the matching post/topic IDs to a result file |
| `parse_results` | Read a page of that result file back and hydrate it into displayable rows |
| `get_new` | The "show me posts since my last visit" variant |

`api_pgSQL.pm` adds a fourth, `mylog`, for debugging. There is no `api_CSV.pm` --
further confirmation that the CSV driver was abandoned before 3.x.

`Search/API/api_global.pm` (175 lines, `package Search::API::api_global`) holds
the engine-independent parts: `clean_up` (create `Database/Temp/Searches/` if
missing and delete result files older than a week), `get_searchable_forums`
(filter the forum list down to those the visitor may search),
`keyword_filter` (apply the admin's skip-word list and minimum length),
`my_gen_id` (name the result file), `do_row` and `folder_icon` (row rendering).

The design is a cache-to-disk search: `do_search` (CODE=01) runs the query and
writes an ID list; `show_results` (CODE=02) pages through that file. This is why
a search result URL survives paging without re-running the query, and why the
week-old cleanup exists.

**View:** `SearchView`. **Lang:** `SearchWords`.

#### 7.4 `Sources/Mail/Sendmail.pm` -- 753 lines

Bundled third-party; see section 8.3.

#### 7.5 `Sources/SSI/Parser.pm` -- 280 lines, `package SSI::Parser`

A generator, not a parser, despite the name and the header comment ("Trivial SSI
file creator"). It has one sub, `parse`, taking `DB`, `TEMPLATE`, `VALUES` and
`EXTRA`. It pulls a row from the `ssi_templates` table, substitutes the supplied
values, and writes the result as a static file into the board's `ssi/` directory
so an external page can `#include` it.

Callers pass one of a small set of template names:

* `Boards.pm` -> `ONLINE_LIST`, `ONLINE_COUNT`, `ACTIVITY_LISTF`,
  `ACTIVITY_COUNT`
* `Post.pm`, `Moderate.pm`, `ModCP.pm` -> `news`
* `Admin::Templates` -> edits the templates themselves

The `news` case is special-cased inside `parse` (lines 50-90+) and branches on
`DB_DRIVER`, because the DBM driver cannot express a multi-forum `OR` in one
query and must loop.

All 26 `SSI::Parser::parse` call sites are rate-limited by an mtime lock file in
`Database/Temp/`, so the static files are regenerated at most once per minute (or
per `CALENDAR_SSI_TIME` minutes for the activity feed).

#### 7.6 `Sources/iPerl/mod_perl.pm` -- 82 lines, `package iPerl::mod_perl`

A mod_perl start-up handler, authored by Matthew Mecham, meant to be pulled in
from `httpd.conf`. It is **not** loaded by the board itself -- nothing `require`s
it -- and it ships with the developer's own paths still in it:

```perl
use lib ('/home/ikonboar/public_html/z_test',
         '/home/ikonboar/public_html/z_test/Data',
         ...
$URL = 'http://www.ikonboard.com/z_test';
$PATH = '/home/ikonboar/public_html/z_test';
```
-- `Sources/iPerl/mod_perl.pm:24-37`

It detects mod_perl by checking `$ENV{GATEWAY_INTERFACE} =~ /^CGI-Perl/`, loads
`Apache::DBI` in a `BEGIN`, preloads sixteen commonly used modules with `use`
(Boards, Forum, Post, Register, Search::api, Sessions, Topic, NotePad, Posters,
Newest, iTextparser, iDatabase::SQL and the five UserCP modules), then calls
`Apache::RegistryLoader` to precompile `ikonboard.cgi` itself. It prints progress
to STDERR and carries a blunt warning in its header: "Only use this script after
you have set up your board and you know that it works properly! If not, your
server may not restart..."

Its preload list is a useful artifact in its own right: it is the maintainer's
own opinion, in 2002, of which sixteen of the hundred-odd modules were on the hot
path.

---

### 4.8 Bundled third-party code

Ikonboard 3 vendors five CPAN modules into `Sources/`, plus two duplicated copies
in `install_modules/`. All are shipped verbatim except `Archive::Tar`, which was
patched twice.

#### 8.1 `Sources/Compress/Zlib.pm` -- 1,020 lines, version 1.13

```perl
# File	  : Zlib.pm
# Author  : Paul Marquess
# Created : 28th April 2001
# Version : 1.13
#
#     Copyright (c) 1995-2001 Paul Marquess. All rights reserved.
#     This program is free software; you can redistribute it and/or
#     modify it under the same terms as Perl itself.
```

The pure-Perl half of Compress::Zlib 1.13 -- the `.pm` that `bootstrap`s the XS
half. Shipping it without the compiled `Zlib.so` means it only works if the host
already has the XS component installed, which is why `Archive::Tar`'s compression
support is probed at load time and disabled if missing. Twelve subs, including
`gzopen`, `memGzip`, `memGunzip`, `compress`, `uncompress`, `deflateInit` and
`inflateInit`. Only `gzopen` is actually called from Ikonboard code (five sites,
all in the tar-handling paths).

`install_modules/Archive/Compress/Zlib.pm` is a **byte-identical** second copy at
a different path, so the installer can use it before `Sources/` is on `@INC`.

#### 8.2 `Sources/Archive/Tar.pm` -- 769 lines, version 0.072

The CPAN `Archive::Tar` of the 0.07x era. The shipped copy declares
`$VERSION = 0.072;` and carries a full `CHANGES` POD section back to version 0.04
but **no `=head1 AUTHOR` block** -- the module is attributable to its CPAN release
rather than to anyone named inside this copy. Twenty subs including `read`,
`write`, `add_files`, `add_data`, `extract`, `list_files`, `get_content`,
`replace_content`, `remove`.

Used by `Admin::Backup` (export), `Admin::Import` (restore),
`Admin::SkinHandler` (skin packages) and `Admin::LangControl` (language packs) --
each of which carries its own `do_tar` and `Find_Files` helpers.

**Two local modifications**, both visible by diffing against
`install_modules/Archive/Tar.pm` (which is the unmodified copy):

1. **Win32 hardening.** The `getpwuid`/`getgrgid` calls that populate the `uname`
   and `gname` tar header fields are commented out and replaced with the literal
   `"unknown"`, with the comment `# WinNT protection`. The `BEGIN` block is also
   wrapped in `local $@; local $SIG{__DIE__};` so that Ikonboard's global
   `$SIG{__DIE__}` handler does not fire on the module's own probe `eval`s.

2. **A community patch that was never wired up.** `Sources/Archive/Tar.pm:571-604`
   adds a sub that does not exist in the installer's copy:

```perl
##########################
# ADDITION BY KEVaholic00
# I have no clue what I 
# am doing but it's worth
# a try :|
sub add_to_existing {
    my ($filename) = shift;
    ...
    # change: ">>" instead of ">"... 
    # this PROBABLY won't do it..... 
	open(TAR, ">>".$filename) or drat;
```

A whole-tree grep for `add_to_existing` returns exactly one hit: its own
definition. It is never called. This is the third `KEVaholic00` contribution in
the product, alongside `NotePad.pm` (a whole feature) and the `IMAGES_URL`
ordering fix credited as bug fix #168 in `ikonboard.cgi:342-347` and `366-370`.

#### 8.3 `Sources/Mail/Sendmail.pm` -- 753 lines, version 0.78

```perl
package Mail::Sendmail;
# Mail::Sendmail by Milivoj Ivkovic <mi@alma.ch>
# see embedded POD documentation after __END__
# or http://alma.ch/perl/mail.htm
```

`Mail::Sendmail` 0.78 by Milivoj Ivkovic -- a pure-Perl SMTP client with no
dependencies, which is exactly why it was chosen: it lets a Windows-hosted board
send mail without a local MTA. Three subs (`sendmail`, `time_to_date`, `fail`)
and a `%mailcfg` configuration hash whose `smtp` list Ikonboard overrides at call
time.

Loaded on demand by `FUNC::Mailer::Send` when `EMAIL_TYPE` is `smtp`
(`Sources/Lib/FUNC.pm:1533`). The alternative branch pipes to the configured
sendmail binary directly and does not use this module at all.

#### 8.4 `Sources/MIME/Base64.pm` -- 61 lines

```perl
#
# $Id: Base64.pm,v 2.14 1999/02/27 20:40:04 gisle Exp $
package MIME::Base64;
...
$VERSION = '2.11';
```

Gisle Aas's `MIME::Base64`, in its pure-Perl fallback form (the `pack 'u'`-based
`encode_base64` and `tr`-based `decode_base64` rather than the XS versions). Note
the mismatch between the RCS revision in the `$Id$` line (2.14) and the declared
`$VERSION` (2.11) -- the file carries a later checkout than its own version
string.

This is the most-called package function in the entire product:
`MIME::Base64::decode_base64` has 54 call sites, `encode_base64` eight -- nearly
all of them in the ARC4 database-password encrypt/decrypt blocks that are
copy-pasted through `ikonboard.cgi`, `Admin/Filemanager.pm`, `Admin/Category.pm`
and `Admin/dbHandler.pm`. It is one of the two `Sources/` files without
`use strict`.

#### 8.5 `Sources/MIME/QuotedPrint.pm` -- 100 lines

```perl
#
# $Id: QuotedPrint.pm,v 1.1 1997/11/18 00:33:23 neeri Exp $
package MIME::QuotedPrint;
...
=head1 COPYRIGHT

Copyright 1995-1997 Gisle Aas.
```

Also Gisle Aas; two subs, `encode_qp` and `decode_qp`. It is not called from
anywhere in Ikonboard's own code -- it is present because `Mail::Sendmail` expects
`MIME::QuotedPrint` to be available for its MIME encoding path.

#### 8.6 The consequence of vendoring

All five modules are frozen at their 2002 state inside the tarball and load ahead
of any system copy, because `ikonboard.cgi:60-65` unshifts `./Sources` onto
`@INC`:

```perl
use lib ( './Data'   ,
          './Sources',
          './Skin'   ,
          './Languages',
          './',
        );
```

A host that later upgraded its system `MIME::Base64`, `Compress::Zlib`,
`Archive::Tar` or `Mail::Sendmail` -- for a security fix or otherwise -- would
still get Ikonboard's 2002 copies inside the board, silently, for the life of the
installation. `Archive::Tar` is the sharpest case: it is the module that unpacks
attacker-supplied archives in the skin and language import screens, it was
already locally patched (so a maintainer replacing it would lose the Win32 fix),
and it is the copy furthest from anything a distribution would have updated.

---

### 4.9 Dead, unreachable and unfinished code

This section collects what the previous nine do not reach. There is a lot of it,
and it is the most informative part of the tree: it shows what was in flight when
3.1.1 shipped.

#### 9.1 Whole files the dispatcher never names

`out_actions.txt` section 4.4 lists 31 modules not named in any `%Mode`. Most are
legitimately reachable by `require` from another module -- `Lib/FUNC.pm`,
`Sessions.pm`, `iTextparser.pm`, `Searchlog.pm`, the iDatabase drivers, the
Search API back-ends, the bundled CPAN modules. Three are not reachable at all,
and a fourth is reachable only in principle:

| File | Lines | Status |
|---|---:|---|
| `Sources/Post2.pm` | 1,609 | **Dead.** Superseded copy of `Post.pm` (section 3.5). Same package name; never required |
| `Sources/iTextparser2.pm` | 448 | **Dead.** Superseded copy of `iTextparser.pm` (section 3.29). Same package name; never required |
| `Sources/Makelog.pm` | 34 | **Dead and broken.** Ikonboard 2-era logging against a database API that no longer exists (section 3.32) |
| `Sources/iDatabase/Driver/CSV.pm` | 1,014 | **Unreachable.** Wrong package name, no `@ISA`, no `newSQL`, not offered by the installer. The largest dead file in the product -- full evidence in section 9.2 |
| `Sources/iPerl/mod_perl.pm` | 82 | Reachable only from `httpd.conf`, and ships with the vendor's own filesystem paths (section 7.6) |

That is 3,187 lines -- 4.4% of the product -- that cannot execute.

#### 9.2 `iDatabase/Driver/CSV.pm` -- 1,014 lines, 33,413 bytes

The single largest piece of dead code in Ikonboard 3.1.1, and the only one that
is a whole subsystem rather than a superseded copy of a live file. It is a
complete flat-file database driver -- the fifth entry in a directory that
otherwise contains four working ones -- and it cannot be loaded by this version of
the product under any configuration.

Four independent facts establish that, each sufficient on its own.

**1. It declares the wrong package.**

```perl
package iDatabase;
use strict;
```
-- `Sources/iDatabase/Driver/CSV.pm:1-2`

Every other driver declares the namespaced form that the loader expects:

| File | Package declared |
|---|---|
| `Driver/DBM.pm:10` | `iDatabase::Driver::DBM` |
| `Driver/mySQL.pm:11` | `iDatabase::Driver::mySQL` |
| `Driver/pgSQL.pm:1` | `iDatabase::Driver::pgSQL` |
| `Driver/Oracle.pm:11` | `iDatabase::Driver::Oracle` |
| `Driver/CSV.pm:1` | **`iDatabase`** |

`iDatabase::SQL::new` builds the class name by concatenation and calls a method
on it:

```perl
    my $class = "iDatabase::Driver::$args{DB_DRIVER}";
    ...
        eval { require "$r_class.pm" };
    ...
    $obj->{driver} = $class->newSQL( \%args );
```
-- `Sources/iDatabase/SQL.pm:45-62`

With `DB_DRIVER => 'CSV'` the `require` succeeds -- the file is there and compiles
-- and the very next statement dies, because nothing named
`iDatabase::Driver::CSV` was ever defined by loading it.

**2. It has no `@ISA`.** The other four each carry the same two lines:

```perl
require iDatabase::Driver::Base;
@ISA = qw(iDatabase::Driver::Base);
```
-- `Driver/DBM.pm:20-21`, `Driver/mySQL.pm:20-21`, `Driver/pgSQL.pm:6-7`,
`Driver/Oracle.pm:19-20`

`CSV.pm` has neither. It therefore inherits none of the shared machinery
described in section 5.2 -- no `load_cfg`, no `parse_where`, no `parse_limit`, no
`rebuild_record`, no `make_hash_ref`. It carries its own private `load_cfg`,
`decode_record` and `encode_record` instead, which is the signature of a file
written before `Base.pm` existed.

**3. It has no `newSQL`.** Its constructor is `sub new` (line 27), taking a flat
argument list rather than the single hash reference `newSQL` receives. The
constructor name is the whole of the driver contract's entry point; `CSV.pm`
does not implement it.

**4. Nothing offers it.** The installer's engine menu names four drivers and not
this one:

```perl
    my $select = "<select name='DB_DRIVER' class='forminput'><option value='DBM' selected>DBM Database</option><option value='mySQL'>mySQL Database</option><option value='pgSQL'>PostgreSQL Database</option><option value='Oracle'>Oracle Database</option>";
```
-- `install_modules/database.pl:44`

So do the control panel's two other places where an engine is chosen:
`Admin::Import`'s "Import into..." selector
(`Sources/Admin/Import.pm:114-119`) and `install_modules/functions.pm:120-129`,
which branches on `DBM`, `mySQL`, `pgSQL` and `Oracle` and has no `CSV` arm.
Nor is there a matching search back-end: `Sources/Search/api.pm:29` loads
`"Search/API/api_".$iB::INFO->{DB_DRIVER}.".pm"`, and the directory holds
`api_DBM.pm`, `api_mySQL.pm`, `api_pgSQL.pm` and `api_Oracle.pm` only. Even if
the package name were corrected, a CSV board would die at the first search.

**Why it matters to the record.** The one remaining consumer of
`package iDatabase` in the tree is `Sources/Makelog.pm`, which opens with
`use iDatabase; my $db = iDatabase->new();` and then calls
`connect`/`prepare`/`execute`/`done`/`disconnect` (section 3.32). Those are `CSV.pm`'s
sibling API from the same generation. The two dead files are dead together: they
are the surviving halves of the pre-3.x storage layer, left in the tarball when
the driver API was rewritten around `newSQL` and `iDatabase::Driver::Base`. At
33,413 bytes, `CSV.pm` alone is larger than 90% of the modules that do run.

#### 9.3 `%Mode` entries whose handler does not exist

Seven `CODE=` values in five front-end modules and one admin module point at
subroutines that are not defined in that file, or anywhere in the tree. Because
`\&name` is a legal reference to a not-yet-defined sub, Perl compiles these
cleanly; the failure is at call time, as
`Undefined subroutine &Package::name called`, which `iB::catch_die` renders as an
"Ikonboard CGI Error" page.

| Module | `CODE=` | Missing handler | Reading |
|---|---|---|---|
| `Moderate.pm` | `10` | `ForumModForm` | A whole-forum moderation form that was planned and never written |
| `Moderate.pm` | `11` | `Announcement` | Forum announcements -- a feature that never shipped in 3.x |
| `Online.pm` | `forum` | `list_forum` | Per-forum "who's online", the counterpart to `list_all` |
| `Register.pm` | `06` | `check_dumb_form` | The submit target for the anti-bot form rendered by `CODE=05` |
| `iPoll.pm` | `03` | `nullvote` | Superseded: the null vote is now a submit-button name handled inside `AddPoll` |
| `UserCP/Messsend.pm` | `13` | `send2` | `13` was reassigned to `Massmsend::send` |
| `ModSet.pm` | `edit` | `edit` | The module has `edit_e` but no `edit`; a rename that missed the table |
| `Admin/SkinHandler.pm` | `new` | `new_splash` | Copy-paste from `Admin/LangControl.pm`, which does define `new_splash` |

The `Register` case is the most consequential: the "dumb form" is a
challenge-response anti-bot screen that renders correctly and then cannot be
submitted.

#### 9.4 Placeholder error handlers

Every `%Mode` dispatch ends with a fallback for an unrecognized `CODE`. Four
distinct qualities of fallback shipped:

**(a) Correct** -- most modules call `FUNC::STD::Error` with a real message:

```perl
sub	LogInError  { my ($obj, $db) = @_; $std->Error(LEVEL=>'1',MESSAGE=>'no_action') }
```
-- `Sources/LogInOut.pm:141`

**(b) A developer placeholder that shipped:**

```perl
sub LegendError { die "I'm working on it!" }
```
-- `Sources/Legends.pm:174`

```perl
sub	FatalError { die "I'm working on it!" }
```
-- `Sources/Warn.pm:167`

Both produce an "Ikonboard CGI Error" page whose entire message is
"I'm working on it!".

**(c) Empty -- a blank page:**

```perl
sub	OnlineError { }
```
-- `Sources/Online.pm:237`

```perl
sub FatalError { }
```
-- `Sources/Upgrade.pm:124`

Neither prints an HTTP header, so the visitor gets a zero-byte response or a
server error, depending on the web server.

**(d) A bare `die`:**

```perl
sub error {
    my ($obj, $db) = @_;
    die "Error!";
}
```
-- `Sources/Admin/Options.pm:2292-2295`

#### 9.5 Stub handlers that are wired up and do nothing

`Moderate.pm` reserves `CODE=13` and points it at:

```perl
sub	Unlucky_for_some { my $luck = 'Usually very bad'; }
```
-- `Sources/Moderate.pm:1417`

It assigns a lexical and returns. `Process` then returns, `iB::Action` returns,
and the request ends having printed nothing at all. `13` was skipped as a
superstition and given a joke body rather than being omitted from the numbering.

#### 9.6 Code marked dead by its own author

**`FUNC::ADMIN::write_log`** -- 55 lines of admin audit logging, disabled by an
early return:

```perl
sub write_log {
    my $obj = shift;
    
    return;
    
    ###### DEPRECIATED
    my %IN = ( TITLE => "", EXTRA => "", @_, );
```
-- `Sources/Lib/ADMIN.pm:301-306`

It is still called from admin modules (`Admin::WebRing::SaveRing` among others),
and it still does nothing. The control panel therefore has no audit trail -- which
is worth noting alongside `Admin::Adminlogs`, which reads *moderator* logs
(written by `ModCP`/`Moderate`), not administrator logs.

**Six `FUNC::STD` methods** carry `DEPRECIATED` in their comment headers
(`Sources/Lib/FUNC.pm` lines 380, 427, 463, 478, 565, 671). Two are literally
empty:

```perl
sub	GetDate {}
```
-- `Sources/Lib/FUNC.pm:567`

```perl
sub	MaintenanceMode { }
```
-- `Sources/Lib/FUNC.pm:673`

The other four (`cgi_error`, `unHTML`, `CleanKey`, `CleanValue`) are live
forwarding shims kept for backward compatibility, and `cgi_error` in particular
is still called from dozens of places.

**`iDatabase::SQL`'s `## DEFAULTS ##` block** -- five subroutine bodies keyed so
that `AUTOLOAD` can never find them (section 5.1).

**`iDatabase::SQL::reset`** -- a teardown that evaluates a string in a block and
calls nothing (section 5.1).

**`Archive::Tar::add_to_existing`** -- defined by a contributor, never called
(section 8.2).

#### 9.7 `Sources/Upgrade.pm`: does it do anything real?

Yes -- but only one version step's worth, and with a bug.

`do_upgrade` performs three concrete actions (section 3.27): insert the `MASS_MAIL`
email template, delete the old `Email-log`, and add the `B_POLL_LOCKED` locked-poll
icon to `Skin/Default/Styles.pm` and `gfx_data.cfg`. Those correspond exactly to
three features that appear in 3.1.1 and not in 3.1.0 -- mass mailing, the
`Email-log.cgi` rename, and the locked-poll folder icon.

So it is a genuine, single-purpose migration script, not a stub. Its limitations:

* the `Email-log` deletion never fires, because line 70 dereferences
  `$iB::INFO` as a hash (`$iB::INFO{'DB_DIR'}`) instead of a hash reference;
* it hard-codes `Skin/Default/` and so patches only the default skin -- a board
  running a custom skin gets no locked-poll icon;
* it is reachable at `act=Upgrade` with no `CODE` at all, gated only by
  super-admin group membership (line 115), and it is not idempotent: running it
  twice attempts a second `insert` of the same `MASS_MAIL` primary key.

#### 9.8 Live bugs worth recording

Collected from the module readings above:

| Location | Defect |
|---|---|
| `Sources/Boards.pm:65` | `$iB::PTH` is never assigned anywhere in the tree, so the ForumJump cache guard always fails and `Data/ForumJump.pm` is regenerated on every board index view |
| `Sources/Boards.pm:488,491,510` | `next` used inside `render_forum`/`render_subcat`, which are subs, not loops -- a permission-denied forum terminates the caller's render loop instead of being skipped |
| `Sources/Upgrade.pm:70` | `$iB::INFO{'DB_DIR'}` instead of `$iB::INFO->{'DB_DIR'}` |
| `Sources/Misc/PMarkers.pm:52` | `$iB::INFO{'COOKIE_DOMAIN'}` instead of `$iB::INFO->{'COOKIE_DOMAIN'}` -- the cookie is written without a domain |
| `Sources/Posters.pm:25,27` | `my $output` declared twice in the same scope |
| `Sources/Posters.pm:67` | `@names` seeded with an empty placeholder, producing a `MEMBER_ID eq ""` term in the generated `WHERE` |
| `Sources/iDatabase/Driver/Base.pm:164,182` | `rebuild_record` defined twice; the stub is silently redefined |
| `Sources/Misc/Attachments.pm:98` | Attachment served by redirect to a public URL; only the forum-wide `ALLOW_ATTACH` flag is checked, not the requester's forum access |
| `Sources/NotePad.pm:193` | `CODE=91` runs `create_table` with no permission check of any kind |
| `Sources/Admin/dbHandler.pm:358` | `require "iDatabase/Admin/a_$iB::IN{'DB'}.pm"` builds a require path from a request parameter |

#### 9.9 Comments the authors left in the shipped source

Not defects, but part of the record:

| Location | Text |
|---|---|
| `Sources/iPoll.pm:22-23` | "This whole module is held together with duct tape. It works fine, it's just ugly and carries bad coding principles." |
| `Sources/iPoll.pm:138-139` | "This is horrible and I hate it. / First thing to do for v3.1? Rewrite the entire polling/posting system..." |
| `Sources/iPoll.pm:158` | "Cut off a piece of duct tape..." |
| `ikonboard.cgi:499` | `return "shut up complaining mod_perl or I'll kick your ass";` |
| `Sources/Lib/FUNC.pm:977-982` | "So, after many modules, routines, checks, regex's and other magic, all it takes is two words to make ikonboard appear.... / ... talk about an anti-climax." |
| `Sources/Misc/Attachments.pm:25-26` | The Futurama quote |
| `Sources/Misc/Attachments.pm:96` | "[ I have a feeling I'll be changing this section shortly ]" |
| `Sources/Online.pm:48-51` | A POD block containing a suggested one-line rewrite of `sub new` |
| `Sources/Misc/Cookies.pm:40` | `my $toilets = 'Cookies';` |
| `Sources/Admin/Index.pm:8` | `# Mor2001-e information available from <ib-license@jarvisgroup.net>` -- a year substitution that landed inside the word "More" |
| `Sources/iTextparser.pm:50-59` | The instructions of a hand-applied patch, preserved as comments |
| `Sources/iDatabase/SQL.pm:105` | "Kludge alert!" |
| `Sources/iDatabase/Driver/Base.pm:71` | pgSQL "needs to use it the other way around for some surreal reason" |
| `Sources/Lib/FUNC.pm:104-105` | The warn filter's rationale: `#Most annoying`, `#Very annoying` |

---

### 4.10 Module dependency notes

#### 10.1 The `BEGIN { require 'Lib/FUNC.pm' }` pattern

The single most common line in the product. It appears in essentially every
front-end and admin module:

```perl
BEGIN {
	require 'Lib/FUNC.pm';
}
```

Three properties make this work the way it does.

**It is a file `require`, not a module `require`.** The argument is a string
path, resolved against `@INC` -- which `ikonboard.cgi:60-65` has already loaded
with `./Sources`, `./Data`, `./Skin`, `./Languages` and `./`. So
`'Lib/FUNC.pm'` finds `Sources/Lib/FUNC.pm`. This is why the same file can be
written `require Lib::FUNC;` in `ikonboard.cgi:202` and `require 'Lib/FUNC.pm';`
everywhere else and load exactly once -- `%INC` keys on the resolved path.

**It runs at compile time.** By the time the module's file-scoped
`my $std = FUNC::STD->new();` executes, `FUNC::STD` must already be defined. Only
a `BEGIN` block guarantees that ordering, which is why every module uses one
rather than a plain top-level `require`.

**It transitively pulls in half the product.** `Lib/FUNC.pm`'s own `BEGIN`
requires `Boardinfo.cgi` and `Default/Universal.pm`; `FUNC::Member` requires
`Lib/MD5.pm`; `FUNC::Mailer` requires `Boardinfo.cgi` and `iTextparser.pm`. So a
module that requires nothing but `Lib/FUNC.pm` has, by the time its first
statement runs, loaded the configuration, the default skin's universal view, the
MD5 implementation and the text parser.

#### 10.2 The dependency layers

```
  ikonboard.cgi
      |
      +-- Boardinfo.cgi ............ generated config (absent in this tree)
      +-- CGI.pm, Benchmark ........ core Perl
      +-- Sources/ARC4.pm + MIME/Base64.pm ... DB password decrypt
      +-- Lib/FUNC.pm .............. FUNC::STD / Output / Member / Mailer
      |       +-- Boardinfo.cgi
      |       +-- Skin/Default/Universal.pm
      |       +-- Lib/MD5.pm
      |       +-- iTextparser.pm
      |       \-- Mail/Sendmail.pm (on demand, EMAIL_TYPE=smtp)
      +-- Sessions.pm .............. authentication
      |       +-- Lib/FUNC.pm
      |       \-- Lib/Crypt.pm (on demand, iB2 password hashes)
      +-- iDatabase/SQL.pm ......... the $db handle
      |       \-- iDatabase/Driver/<DB_DRIVER>.pm
      |               \-- iDatabase/Driver/Base.pm
      |
      +-- [act=]  one front-end controller
      |       +-- Lib/FUNC.pm
      |       +-- iTextparser.pm (most)
      |       +-- Skin/<skin>/<Name>View.pm
      |       \-- Languages/<lang>/<Name>Words.pm
      |
      \-- [AD/CP=] Admin/Functions.pm
              +-- Lib/FUNC.pm, Lib/ADMIN.pm, Admin/SKIN.pm
              \-- one of 30 Admin/*.pm
                      +-- Lib/FUNC.pm, Lib/ADMIN.pm, Admin/SKIN.pm
                      \-- (Archive/Tar.pm, Compress/Zlib.pm, iDatabase/Admin/a_*.pm)
```

#### 10.3 The four load styles, and why they differ

| Style | Used for | Example |
|---|---|---|
| `BEGIN { require 'Path/File.pm'; }` | Libraries needed before file-scoped initialization | `BEGIN { require 'Lib/FUNC.pm'; }` -- 100+ files |
| `require Module::Name;` | Occasional, mostly in `ikonboard.cgi` and `Admin/Functions::styles` | `require Admin::SkinControl;` (`Admin/Functions.pm:279`) -- the only admin loader written this way; the other 29 use the string form |
| `require $iB::SKIN->{'DIR'} . '/XView.pm' or die $!;` | Skin views, resolved at runtime | `Sources/Help.pm:161` |
| `do $path;` | Files that must be re-read on every request | `do $iB::SKIN->{'DIR'} . '/Universal.pm';` (`ikonboard.cgi:340`), `do ... '/Data/Stats.pm'` (`Sources/Lib/FUNC.pm:372`), `do $obj->{'base_dir'}.'config/'.$cfg.'.cfg'` (`Driver/Base.pm:270`) |

The `do` cases are deliberate: `require` caches in `%INC` and would return true
without re-reading, which is wrong for a file the running board rewrites -- the
stats file, the table configs, and the skin's universal view.

#### 10.4 Cross-controller `require`s

A handful of front-end modules load each other, which is where the real coupling
lives:

| Requirer | Requires | Why |
|---|---|---|
| `Topic.pm` | `iPoll.pm` | Render a poll inside a topic view |
| `iPoll.pm` | `Post.pm` | Poll creation reuses the post form and commit path |
| `Post.pm` | `ModSet.pm` | Notify moderators when a post enters the queue |
| `Post.pm` | `Misc/Track.pm` | Auto-subscribe the poster to the topic |
| `Post.pm`, `Moderate.pm`, `ModCP.pm` | `Searchlog.pm` | Maintain the search index on write |
| `Post.pm`, `Moderate.pm`, `ModCP.pm` | `SSI::Parser` | Regenerate the news SSI file after a post |
| `ModCP.pm` | `Moderate.pm` | Reuse the single-item moderation verbs for batch operations |
| `ModSet.pm` | `ModCP.pm` | Reuse `ModCP`'s forum splash screen |
| `Register.pm` | `Welcome.pm` | Send the welcome PM on account creation |
| `Boards.pm` | `Happybd.pm` | Send birthday PMs during the calendar sweep |
| `Admin/Authorise.pm` | `Welcome.pm` | Send the welcome PM on manual approval |
| `Admin/Session.pm` | `Sessions.pm` | Reuse the session model |
| `Admin/dbHandler.pm`, `Admin/Import.pm`, `Admin/DBMclient.pm` | `iDatabase::SQL` | Open a *second* connection to a different engine |

The last row is notable: those three admin modules construct their own
`iDatabase::SQL` object with different arguments than the one `ikonboard.cgi`
built, which is how "import into another driver" and "switch database driver"
work -- two live connections to two different engines within one request.

#### 10.5 Shared package globals

Beyond `require`, modules couple through package-global namespaces. The
significant ones:

| Global | Set by | Read by |
|---|---|---|
| `$iB::INFO` | `ikonboard.cgi:124` | Everything |
| `$iB::MEMBER` | `Sessions::authenticate` | Everything |
| `$iB::MEMBER_GROUP` | The session/auth path | Every permission check |
| `$iB::SKIN` | `FUNC::STD::LoadSkin` via `ikonboard.cgi:338` | Every view load, every color reference |
| `$iB::SESSION` | `Sessions` | Every generated URL |
| `%iB::IN` | `ikonboard.cgi:175` | Every handler |
| `$iB::ACTIVE` | `Sessions::active_users` | `Boards.pm` |
| `$iB::CONTENT->{'HTTP'}` | `_print_http_header` | The header-once guard, in three separate implementations |
| `$Universal::lang` | `FUNC::STD::new` | `iTextparser`, all `Universal::` views |
| `$Messenger::lang` | `UserCP::Messenger`, `Messsend`, `Messview`, `Massmsend` | All four -- one namespace, four writers |
| `$ModCP::lang` | `ModCP`, `ModSet`, `Welcome`, `Happybd` | All four |
| `$Post::lang` | `Post`, `iPoll`, `Massmsend`, `Messsend`, `SSI::Parser` | All five |

The shared `$Package::lang` globals are why `Welcome.pm` and `Happybd.pm` can
render through `ModCPView` without loading a language pack of their own -- and why
loading order matters if two of them are ever active in the same request.

---

### Appendix A -- module index by file

| File | Lines | Package(s) | Section |
|---|---:|---|---|
| `ikonboard.cgi` | 588 | `iB` | section 1 |
| `Sources/ARC4.pm` | 87 | `Crypt::ARC4` | section 3.31 |
| `Sources/Boards.pm` | 558 | `Boards` | section 3.1 |
| `Sources/Calendar.pm` | 287 | `Calendar` | section 3.12 |
| `Sources/Forum.pm` | 696 | `Forum` | section 3.2 |
| `Sources/Happybd.pm` | 281 | `Happybd` | section 3.11 |
| `Sources/Help.pm` | 172 | `Help` | section 3.20 |
| `Sources/Legends.pm` | 179 | `Legends` | section 3.19 |
| `Sources/LogInOut.pm` | 147 | `LogInOut` | section 3.15 |
| `Sources/Makelog.pm` | 34 | `Makelog` | section 3.32 |
| `Sources/Massmsend.pm` | 561 | `Massmsend` | section 3.21 |
| `Sources/Memberlist.pm` | 259 | `Memberlist` | section 3.17 |
| `Sources/ModCP.pm` | 2,132 | `ModCP` | section 3.8 |
| `Sources/ModSet.pm` | 600 | `ModSet` | section 3.9 |
| `Sources/Moderate.pm` | 1,492 | `Moderate` | section 3.7 |
| `Sources/Newest.pm` | 130 | `Newest` | section 3.23 |
| `Sources/NotePad.pm` | 224 | `NotePad` | section 3.22 |
| `Sources/Online.pm` | 240 | `Online` | section 3.18 |
| `Sources/Post.pm` | 1,624 | `Post` | section 3.4 |
| `Sources/Post2.pm` | 1,610 | `Post` (dead) | section 3.5 |
| `Sources/Posters.pm` | 101 | `Posters` | section 3.24 |
| `Sources/PrintPage.pm` | 232 | `PrintPage` | section 3.25 |
| `Sources/Profile.pm` | 963 | `Profile` | section 3.14 |
| `Sources/Register.pm` | 733 | `Register` | section 3.13 |
| `Sources/Searchlog.pm` | 97 | `Searchlog` | section 3.30 |
| `Sources/Sessions.pm` | 514 | `Sessions` | section 3.16 |
| `Sources/Topic.pm` | 812 | `Topic` | section 3.3 |
| `Sources/Upgrade.pm` | 126 | `Upgrade` | section 3.27, section 9.7 |
| `Sources/Warn.pm` | 192 | `Warn` | section 3.26 |
| `Sources/Welcome.pm` | 247 | `Welcome` | section 3.10 |
| `Sources/iPoll.pm` | 428 | `iPoll` | section 3.6 |
| `Sources/iTextparser.pm` | 449 | `iTextparser` | section 3.28 |
| `Sources/iTextparser2.pm` | 449 | `iTextparser` (dead) | section 3.29 |
| `Sources/Lib/ADMIN.pm` | 365 | `FUNC::ADMIN` | section 4.2 |
| `Sources/Lib/Crypt.pm` | 694 | `Crypt` | section 4.3 |
| `Sources/Lib/FUNC.pm` | 1,637 | `FUNC::STD`, `FUNC::Output`, `FUNC::Member`, `FUNC::Mailer` | section 4.1 |
| `Sources/Lib/MD5.pm` | 213 | `Crypt::MD5` | section 4.4 |
| `Sources/iDatabase/SQL.pm` | 185 | `iDatabase::SQL` | section 5.1 |
| `Sources/iDatabase/Driver/Base.pm` | 300 | `iDatabase::Driver::Base` | section 5.2 |
| `Sources/iDatabase/Driver/CSV.pm` | 1,014 | `iDatabase` (dead) | section 5.4, section 9.2 |
| `Sources/iDatabase/Driver/DBM.pm` | 1,270 | `iDatabase::Driver::DBM` | section 5.3 |
| `Sources/iDatabase/Driver/Oracle.pm` | 1,112 | `iDatabase::Driver::Oracle` | section 5.3 |
| `Sources/iDatabase/Driver/mySQL.pm` | 1,028 | `iDatabase::Driver::mySQL`, `::Bind` | section 5.3 |
| `Sources/iDatabase/Driver/pgSQL.pm` | 1,045 | `iDatabase::Driver::pgSQL`, `::Bind` | section 5.3 |
| `Sources/iDatabase/Admin/a_base.pm` | 58 | `iDatabase::Admin::a_base` | section 5.5 |
| `Sources/iDatabase/Admin/a_DBM.pm` | 68 | `iDatabase::Admin::SQL` | section 5.5 |
| `Sources/iDatabase/Admin/a_mySQL.pm` | 158 | `iDatabase::Admin::SQL` | section 5.5 |
| `Sources/iDatabase/Admin/a_pgSQL.pm` | 143 | `iDatabase::Admin::SQL` | section 5.5 |
| `Sources/iDatabase/Admin/a_Oracle.pm` | 154 | `iDatabase::Admin::SQL` | section 5.5 |
| `Sources/UserCP/Lostpass.pm` | 245 | `UserCP::Lostpass` | section 7.1 |
| `Sources/UserCP/Menu.pm` | 486 | `UserCP::Menu` | section 7.1 |
| `Sources/UserCP/Messenger.pm` | 697 | `UserCP::Messenger` | section 7.1 |
| `Sources/UserCP/Messsend.pm` | 676 | `UserCP::Messsend` | section 7.1 |
| `Sources/UserCP/Messview.pm` | 428 | `UserCP::Messview` | section 7.1 |
| `Sources/Misc/AOL.pm` | 80 | `Misc::AOL` | section 7.2 |
| `Sources/Misc/Attachments.pm` | 108 | `Misc::Attachments` | section 7.2 |
| `Sources/Misc/Cookies.pm` | 61 | `Misc::Cookies` | section 7.2 |
| `Sources/Misc/Forward.pm` | 142 | `Misc::Forward` | section 7.2 |
| `Sources/Misc/ICQ.pm` | 89 | `Misc::ICQ` | section 7.2 |
| `Sources/Misc/Invite.pm` | 114 | `Misc::Invite` | section 7.2 |
| `Sources/Misc/MSN.pm` | 84 | `Misc::MSN` | section 7.2 |
| `Sources/Misc/MailMember.pm` | 133 | `Misc::MailMember` | section 7.2 |
| `Sources/Misc/PMarkers.pm` | 62 | `Misc::PMarkers` | section 7.2 |
| `Sources/Misc/Report.pm` | 279 | `Misc::Report` | section 7.2 |
| `Sources/Misc/Track.pm` | 181 | `Misc::Track` | section 7.2 |
| `Sources/Search/api.pm` | 356 | `Search::api` | section 7.3 |
| `Sources/Search/API/api_global.pm` | 175 | `Search::API::api_global` | section 7.3 |
| `Sources/Search/API/api_DBM.pm` | 337 | `Search::API::api_functions` | section 7.3 |
| `Sources/Search/API/api_mySQL.pm` | 264 | `Search::API::api_functions` | section 7.3 |
| `Sources/Search/API/api_pgSQL.pm` | 275 | `Search::API::api_functions` | section 7.3 |
| `Sources/Search/API/api_Oracle.pm` | 277 | `Search::API::api_functions` | section 7.3 |
| `Sources/SSI/Parser.pm` | 280 | `SSI::Parser` | section 7.5 |
| `Sources/iPerl/mod_perl.pm` | 82 | `iPerl::mod_perl` | section 7.6 |
| `Sources/Mail/Sendmail.pm` | 753 | `Mail::Sendmail` | section 8.3 |
| `Sources/Archive/Tar.pm` | 769 | `Archive::Tar` | section 8.2 |
| `Sources/Compress/Zlib.pm` | 1,020 | `Compress::Zlib` | section 8.1 |
| `Sources/MIME/Base64.pm` | 61 | `MIME::Base64` | section 8.4 |
| `Sources/MIME/QuotedPrint.pm` | 100 | `MIME::QuotedPrint` | section 8.5 |
| `Sources/Admin/*.pm` (34 files) | 24,088 | `Admin::*` | section 6 |

### Appendix B -- named contributors found in the source

Only these names appear in the shipped `cgi-bin/` tree. Where a credit is inline,
the citation is given.

| Name | Contribution | Citation |
|---|---|---|
| Matthew Mecham | `iDatabase::SQL`, `iDatabase::Driver::Base`, `Driver/DBM.pm`, `Driver/mySQL.pm`, `Driver/CSV.pm`, `iPerl/mod_perl.pm` | Module headers, `matt@ikonboard.com` |
| Andrey Prokopenko | `iDatabase::Driver::Oracle` | `Driver/Oracle.pm` header, `faceless@ortv.ru` |
| Nurlan Mukhanov ("Infection") | `Calendar.pm`; `FUNC::STD::htmlcut`; `Driver::Base::make_hash_ref` | `Calendar.pm` header; `Lib/FUNC.pm:215`; `Driver/Base.pm:25` |
| Camil | `Newest.pm`; modifications to `Calendar.pm` | `ikonboard.cgi:464`; `Calendar.pm` header |
| KEVaholic00 | `NotePad.pm`; bug fix #168 in `ikonboard.cgi`; active-user totals in `Boards::render_subcat`; `Archive::Tar::add_to_existing` | `ikonboard.cgi:462`, `342-347`, `366-370`; `Boards.pm:432,440`; `Archive/Tar.pm:571` |
| Phil Gengler (LrdChaos) | `Upgrade.pm` | `Upgrade.pm:15`, `lrdchaos@codeallday.com` |
| Freakboy | AM/PM suffix fix in `FUNC::STD::get_date` | `Lib/FUNC.pm:598` |
| Martin Vorlaender | `Lib/Crypt.pm` (from Java by jdumas@zgs.com, from C by Eric Young) | `Lib/Crypt.pm` POD |
| Paul Marquess | `Compress::Zlib` 1.13 | `Compress/Zlib.pm:2` |
| Gisle Aas | `MIME::Base64` 2.11, `MIME::QuotedPrint` | `MIME/Base64.pm:2`, `MIME/QuotedPrint.pm:43` |
| Milivoj Ivkovic | `Mail::Sendmail` 0.78 | `Mail/Sendmail.pm:2` |
| Christian Lackas (RCS) | `Crypt::MD5` 1.5 | `Lib/MD5.pm:1` |

---

## 5. Data formats

Ikonboard 3.1.1 (Jarvis Entertainment Group, Inc., July 2002) is a Perl CGI forum
that stores its data through a homegrown abstraction layer called **iDatabase**.
Every table in the product is declared once, in an executable Perl fragment under
`Database/config/`, and a driver module under `Sources/iDatabase/Driver/`
translates that declaration into DBM hashes or SQL rows.

There are **four** working backends: **DBM** (the zero-dependency flat-file
option, and the installer's default), **MySQL**, **PostgreSQL** and **Oracle**.
A fifth driver, `CSV.pm`, ships in the tarball but cannot be loaded and has
never stored a byte of anyone's board; it is a fossil of the Ikonboard 3.0
storage engine and is documented as such in section 5.3.1. Where this chapter says
"the flat-file backend," it means DBM.

This chapter documents every format the software reads or writes: the table
declarations, all 29 tables field by field, what each storage backend actually
puts on disk, where the hand-maintained SQL schemas have drifted from the
declarations, the board configuration file, the skin format, the language
format, the miscellaneous data files, and finally a practical guide to reading
an abandoned board's data without Ikonboard.

**A note on evidence.** The reconstructed tree was **never installed**. Every
`Database/<table>/` directory is empty apart from the shipped `index.html` and
`.htaccess` stubs, and `Database/Temp/Searches` is an empty directory. There is
no `Data/Boardinfo.cgi`, because the installer generates it. There is no sample
board data anywhere. Everything below is therefore derived from the code and the
declarations, not from observed records -- with two exceptions, both called out
where they appear: the `INSTALL_DATA/*.dat` seed files, which are genuine
`|^|`-delimited records shipped with the product, and `Data/ib_data_file.dat`,
which is a real encoded payload. Where a fact could not be determined from the
source, this chapter says so explicitly.

**A note on two corrections.** Two pre-computed analyzes in the teardown
directory are wrong in ways that matter, and this chapter supersedes them:

1. `out_records.txt` section 5.2 silently drops every column whose type is
   `text` (declared with width `-1`), every column whose width is quoted, and
   every column whose entry ends in a trailing comma. It reports 305 columns
   across 29 tables. The true figure is **332**. Most visibly, it reports that
   `ssi_templates` and `templates` declare **zero** columns; they each declare
   **three**. Section 2 below re-extracts the schema from the `.cfg` files
   directly.
2. `out_records.txt` section 5.3 compares SQL table names such as
   `ib_forum_posts` against declared names such as `forum_posts` without
   stripping the `ib_` prefix, so it concludes that all 29 tables are missing
   from all three SQL backends and that "0 of 0 shared tables differ." A
   prefix-aware, case-insensitive comparison finds real drift in exactly two
   places. Section 4 reports it.

---

### 5.1 The declaration format

#### 1.1 What a `.cfg` file is

`Database/config/<table>.cfg` is not a data file. It is a Perl source file,
loaded with `do`, that assigns two package globals in the package `IMPORT`.

These declarations are the **complete and only** schema for the DBM backend. A
DBM file stores each record as a single opaque `|^|`-joined string keyed by its
primary key, with no column names and no type information anywhere in the file,
so the `.cfg` is the sole thing that turns a stored record back into named
fields. Lose it and a recovered `.db` file is an unlabeled list of values.

For the three SQL backends the relationship is weaker. The declarations still
drive the runtime -- column lists, primary keys, sort comparisons -- but the
tables themselves are created from the hand-written DDL in `INSTALL_DATA/`, not
from the `.cfg` files, and the database carries its own authoritative catalog.
That split is what allows the two to drift apart (section 5.4).
Here is `Database/config/forum_topics.cfg` in full -- it is representative, and
it exercises every `$STRING` key that the flat-file drivers read:

```perl
package IMPORT;

$STRING = { "TABLE"   => "forum_topics",
            "P_KEY"   => "TOPIC_ID",
            "MTD"     => "single",
            "UPDATE"  => "top",
            "ID"      => "FORUM_ID",
          };

%{ $COLS } = (        "TOPIC_ID"          => [0 ,  'update', 10 , 1],
                      "TOPIC_TITLE"       => [1 ,  'string', 70, 1],
                      "TOPIC_DESC"        => [2 ,  'string', 70,  ],
                      "TOPIC_STATE"       => [3 ,  'string', 8    ],
                      "TOPIC_POSTS"       => [4 ,  'num'   , 4    ],
                      "TOPIC_STARTER"     => [5 ,  'string', 32   ],
                      "TOPIC_START_DATE"  => [6 ,  'num'   , 10   ],
                      "TOPIC_LAST_POSTER" => [7 ,  'string', 32   ],
                      "TOPIC_LAST_DATE"   => [8 ,  'num'   , 10   ],
                      "TOPIC_ICON"        => [9 ,  'num'   , 2    ],
                      "TOPIC_STARTER_N"   => [10, 'string' , 32   ],
                      "TOPIC_LASTP_N"     => [11, 'string' , 32   ],
                      "POLL_STATE"        => [12, 'string' , 8    ],
                      "LAST_VOTE"         => [13, 'num'    , 10   ],
                      "TOPIC_VIEWS"       => [14, 'num'    , 5    ],
                      "FORUM_ID"          => [15, 'num'    , 5    ],
                      "APPROVED"          => [16, 'num'    , 1    ],
                      "TOPIC_AUTHOR_TYPE" => [17, 'num'    , 1    ],
                      "PIN_STATE"         => [18, 'num'    , 1    ],
                      "MOVED_TO"          => [19, 'string' , 64   ],
        	          "WATCHED"		=> [20, 'num'    , 1    ],
             );


1;
```

Three structural details are worth stating up front because they trip up
naive parsers, including the one that produced `out_records.txt`:

* **Column names are sometimes quoted and sometimes bare.** `forum_topics.cfg`
  quotes them; `forum_posts.cfg`, `templates.cfg`, `mod_email.cfg` and eight
  others use bare Perl words. Both are legal hash keys.
* **Widths are sometimes quoted.** `templates.cfg:9` and `ssi_templates.cfg:9`
  write `'20'`, `'-1'`, `'128'` as strings. Nothing in the code cares, but a
  regex expecting a bare integer will skip the entire table.
* **The fourth element is frequently an empty slot.** `"TOPIC_DESC" => [2,
  'string', 70,  ]` has a trailing comma and nothing after it. In Perl this is a
  three-element array; the "required" flag is simply absent.

`%{ $COLS } = (...)` assigns through an undeclared scalar. `$COLS` starts
undefined, and using `%{ $COLS }` in lvalue context autovivifies it into a hash
reference. This works only because the `.cfg` files are loaded outside `use
strict`; `do` compiles the file in its own scope, and `$IMPORT::COLS` is a
package global.

The trailing `1;` is required -- `do` returns the value of the last statement
evaluated, and `Base.pm:270` treats a false return as a load failure:

```perl
do $obj->{'base_dir'}.'config/'.$cfg.'.cfg' or die "Cannot open file : $cfg.cfg ($!)";
```

`categories.cfg` additionally has an `__END__` marker after the `1;`. It is
inert.

#### 1.2 The `$STRING` keys

`Sources/iDatabase/Driver/Base.pm:266-288` is the canonical consumer:

```perl
sub load_cfg  {
    my ($obj, $cfg) = @_;
    die "Invalid Number of ARGVS" unless @_ == 2;
    $obj->{'cur_p_key'} = undef;
    do $obj->{'base_dir'}.'config/'.$cfg.'.cfg' or die "Cannot open file : $cfg.cfg ($!)";

    $obj->{'cur_table'}  = $IMPORT::STRING->{'TABLE'};
    $obj->{'cur_p_key'}  = $IMPORT::STRING->{'P_KEY'};
    $obj->{'cur_method'} = $IMPORT::STRING->{'MTD'};
    $obj->{'cur_update'} = $IMPORT::STRING->{'UPDATE'} || 'bottom';
    $obj->{'cur_ID'}     = $IMPORT::STRING->{'ID'};
    $obj->{'cur_DBID'}   = $IMPORT::STRING->{'DBID'};
    $obj->{'cur_INDEX'}  = $IMPORT::STRING->{'INDEX'};
    $obj->{'all_cols'}   = $IMPORT::COLS;
    $obj->{'total_cols'} = 0;
    $obj->{'col_name'}   = [];
    foreach (sort { $obj->{'all_cols'}->{$a}[0] <=> $obj->{'all_cols'}->{$b}[0] } keys %{$obj->{'all_cols'}} ) {
        push @{$obj->{'col_name'}}, $_;
        ++$obj->{'total_cols'};
    }
    $obj->{'total_cols'}--;
    undef $IMPORT::STRING;
}
```

| Key | Required | Meaning |
|-----|----------|---------|
| `TABLE` | yes | The table's own name. Always equals the `.cfg` basename in the shipped set; nothing verifies this. |
| `P_KEY` | yes | Column name of the primary key. Used as the DBM hash key, the SQL `WHERE` target, and the line-prefix match in the flat-file driver. |
| `MTD` | no | Storage *method*: `single` or `multiple`. Absent on three tables. See below. |
| `UPDATE` | no | Insert position for the flat-file driver: `top` or `bottom`. Defaults to `bottom` when absent or false. |
| `ID` | no | Name of the column whose value partitions the table into separate files. |
| `DBID` | no | Name of the column whose value partitions the table into separate *sub-directories*. Declared on exactly one table. |
| `INDEX` | no | Hash of `{ indexed_column => column_to_return }`. Declared on exactly two tables. |

Note that the brief for this chapter mentions five keys; there are **seven**.
`DBID` and `INDEX` are read by `Base.pm:277-278` and used by the DBM driver, and
they are declared in the shipped `.cfg` files.

**`MTD` -- `single` versus `multiple`.** The live DBM driver and the fossil
`CSV.pm` interpret this key in opposite directions, which is one of the more
confusing aspects of the codebase and a good reason not to read `CSV.pm` for
guidance on how a real board behaves.

In the DBM driver (`Sources/iDatabase/Driver/DBM.pm:111-115`, and identically at
lines 201-205, 341-345, 440-444, 548-552, 648-652, 989-993, 1022-1026,
1058-1062), `single` means "this table *is* partitioned, so honor the caller's
`ID`", and `multiple` means "ignore `ID` entirely, there is one file for the
whole table":

```perl
    if ($obj->{'cur_method'} eq 'single') {
        $IN->{'ID'} =  $IN->{'ID'}   ne '' ? '-'.$IN->{'ID'}   : '';
    } else {
        $IN->{'ID'} = undef;
    }
```

In the flat-file `CSV.pm` driver, `multiple` selects an entirely different
storage strategy -- one Perl-source file per record, loaded with `do` -- while
`single` selects the line-per-record text file (`CSV.pm:126-129`, `382-384`,
`508-510`, `600-603`). Since `CSV.pm` is unreachable in 3.1.1 (see section 5.3.1),
the DBM reading is the one that governs a real board.

Three tables declare no `MTD` at all: `member_profiles`, `member_notepads` and
`moderator_logs`. `$obj->{'cur_method'}` is then `undef`, every `eq 'single'`
test fails, and they behave as `multiple` -- a single unpartitioned file each.
This is almost certainly deliberate for the two member tables (both also carry
`INDEX`), and looks accidental for `moderator_logs`, whose sibling log tables
all declare `single`.

**`UPDATE` -- `top` versus `bottom`.** Only meaningful to the line-oriented
flat-file writer, `CSV.pm:404-406`:

```perl
        print NEW $entry."\n" if $obj->{'cur_update'} eq 'top';
        while (<OLD>) { print NEW $_; }
        print NEW $entry."\n" if $obj->{'cur_update'} eq 'bottom';
```

Exactly one table sets `top`: `forum_topics`. New topics are prepended so that
the file is already in newest-first order. Everything else appends. A DBM hash
has no order, so on a real 3.1.1 board this key has **no effect whatsoever**.

**`ID` and `DBID` -- partitioning.** These name the columns whose values are
baked into file and directory names, so that a busy board does not end up with
one enormous table. `CSV.pm:58-61` explains the intent in a comment:

```perl
    # DBID is the table sub dir. (Table_name/dbid/)

    # iB3 has DBIDs for forum_posts and forum_polls  (forum_posts/f0 would be the table/dbid for the forum posts)
    # As a point of reference, the SQL version won't bother with that, it'll merely use "f0_forum_posts" as a table name
```

The comment is out of date: in the shipped 3.1.1 declarations only
`forum_posts.cfg:9` carries `DBID`, and it is `FORUM_ID`; `forum_polls.cfg` has
neither `ID` nor `DBID`. Six tables declare `ID`: `forum_topics` (`FORUM_ID`),
`forum_posts` (`TOPIC_ID`), `address_books` (`MEMBER_ID`), `message_data`
(`MEMBER_ID`), `search_log` (`FORUM_ID`) and `topic_views` (`FORUM_ID`).

Critically, the declared `ID`/`DBID` values are *documentation*. The drivers
never read `cur_ID` or `cur_DBID` to compute a path; the calling module passes
`ID` and `DBID` explicitly on every query. `Sources/Topic.pm:326-328` is typical:

```perl
    my $total_posts = $db->query( TABLE    => 'forum_posts',
                                  DBID     => 'f'.$obj->{'.forum_id'},
                                  ID       => $obj->{'.topic_id'},
```

Note the `f` prefix, which is supplied by the caller, not by the declaration.
So the real partitioning of `forum_posts` is directory `f<FORUM_ID>`, file
suffix `-<TOPIC_ID>`. `Sources/Forum.pm:224-225` passes only `ID` for
`forum_topics`, giving one file per forum.

**`INDEX` -- secondary lookup.** Declared on `member_profiles.cfg:5-8`:

```perl
            "INDEX"   => {
                            'MEMBER_NAME'  => 'MEMBER_ID',
                            'MEMBER_EMAIL' => 'MEMBER_ID',
                         },
```

and, empty, on `member_notepads.cfg:5` (`"INDEX" => {}`). The DBM driver
maintains one extra DBM file per indexed column
(`DBM.pm:995`, `1028`, `1064`), named `<INDEX_KEY><-ID>.idx` inside the table
directory, mapping the indexed value to the primary key. `DBM.pm:173-192` uses
it to turn "find the member named X" into a single hash lookup followed by a
single `select`, instead of a full scan. This is the only secondary index in the
product, and it exists because member lookup by name happens on every request.

#### 1.3 The `%{$COLS}` entries and the four types

Each entry is `NAME => [ ordinal, type, width, required ]`.

| Element | Consumed by | Effect |
|---------|-------------|--------|
| ordinal | `Base.pm:282` / `CSV.pm:864` | **Sort key only.** Determines field order on the wire. |
| type | `CSV.pm:371`, `DBM.pm:349`, `CSV.pm:283`, `DBM.pm:272`, and the SQL drivers | Selects auto-increment behavior and sort comparison operator. |
| width | SQL DDL generation only | Ignored entirely by the flat-file and DBM drivers. Nothing truncates. |
| required | nothing at runtime | Present in the declarations, never enforced. |

The declared types are:

| Type | Count | Meaning |
|------|------:|---------|
| `num` | 168 | Integer. Sorted with `<=>`. In SQL, `tinyint`/`smallint`/`int`/`bigint` by width. |
| `string` | 131 | Text. Sorted with `cmp`. In SQL, `varchar(width)`. |
| `update` | 19 | Auto-incrementing integer primary key. |
| `text` | 14 | Unbounded text, always declared with width `-1`. In SQL, `text`/`blob`. |

That is 332 columns across 29 tables. 94 of them carry the "required" flag.

`text` is the type `out_records.txt` lost, and its fourteen instances are
precisely the payload of the board:

```
email_templates.TEMPLATE     member_notepads.SAVED_M      mod_posts.POST
forum_polls.POLL_ANSWERS     member_notepads.SAVED_P      search_log.POST
forum_posts.POST             message_data.MESSAGE         ssi_templates.TEMPLATE
forum_rules.RULES_TEXT       mod_email.TEXT               templates.TEMPLATE
help.TEXT                    member_notepads.NOTEPAD_TEXT
```

Dropping them produced a schema report in which posts have no post body and
private messages have no message.

The nineteen `update` columns are the primary keys of every table that
auto-numbers its rows. The ten tables that do *not* use `update` key on a
natural value instead: `active_sessions.ID` (a session hash),
`calendar.MEMBER_ID`, `email_templates.ID`, `forum_info.FORUM_ID`,
`forum_rules.ID`, `member_notepads.MEMBER_ID`, `member_profiles.MEMBER_ID`,
`message_stats.MEMBER_ID`, `ssi_templates.ID` and `templates.ID`. Note that
`forum_rules.ID` is declared `num`, not `update`, which is why the shipped seed
record in `INSTALL_DATA/board_rules.dat` carries an explicit ID of `00` while
`mem_groups.dat` omits its ID entirely.

**`update` is the only type with runtime behavior.** The DBM driver tests it on
insert (`DBM.pm:349`; `CSV.pm:371` is identical):

```perl
    if ($obj->{'all_cols'}->{ $obj->{'cur_p_key'} }[1] eq 'update') {
        open (CNT, $obj->{'base_dir'} . "$IN->{'TABLE'}/$IN->{'DBID'}$IN->{'TABLE'}$IN->{'ID'}.cnt.db");
        flock (CNT, LOCK_SH) if $INFO->{'FLOCK'};
        $counter = <CNT> || 0;
        close (CNT);

        ++$counter;

        $IN->{'VALUES'}->{ $obj->{'cur_p_key'} } = $counter;
    }
```

The counter lives in a plain text file alongside the data --
`<table><-ID>.cnt.db` under DBM (and `file<-ID>.cnt.cgi` in `CSV.pm`'s
scheme) -- containing nothing but a decimal integer and no newline. Because the counter is per-partition, `forum_posts` post IDs
restart at 1 in every topic file, and `forum_topics` topic IDs restart at 1 in
every forum. **Post and topic IDs are only unique within their partition.** Any
recovery tool that assumes a global post ID will silently merge unrelated posts.

Note also that the counter is read under a *shared* lock and written later under
an exclusive one, with the record insert in between. Two simultaneous posts to
the same topic can read the same counter value and produce two records with the
same primary key. On DBM the second write simply overwrites the first: the post
is lost, not duplicated.

The other three types affect only sorting. `CSV.pm:283` and `DBM.pm:272` branch
on the *sort column's* declared type:

```perl
        if ($obj->{'all_cols'}->{ $IN->{'SORT_KEY'} }[1] eq 'string') {
```

Anything not literally `string` -- including `num`, `update` and `text` -- sorts
numerically with `<=>`. Sorting a `text` column is therefore a numeric
comparison of two post bodies, which under Perl's numeric coercion makes them
all equal to zero. No shipped call site does this, but nothing prevents it.

#### 1.4 Ordinals are a sort key, not a position

`Base.pm:282-286` builds `col_name` by sorting the column names on their
declared ordinal and pushing them onto an array. The array is dense: it has
exactly as many slots as there are declared columns, indexed `0 .. n-1`. The
declared ordinal never indexes anything.

This matters because one declaration has a gap. `forum_moderators.cfg` declares
`MOVE_TOPIC` **twice**:

```perl
                 CLOSE_TOPIC      => [10, 'num',    1 ],
                 MOVE_TOPIC       => [11, 'num',    1 ],
                 MASS_MOVE        => [12, 'num',    1 ],
                 MASS_PRUNE       => [13, 'num',    1 ],
                 MOVE_TOPIC       => [14, 'num',    1 ],
```

Perl hash semantics apply: the second assignment wins, `MOVE_TOPIC` has ordinal
14, and ordinal 11 is unoccupied. The table has **21** columns, not 22. Because
ordinals are only a sort key, the gap collapses harmlessly -- `MASS_MOVE`
occupies wire position 11, `MASS_PRUNE` position 12, `MOVE_TOPIC` position 13 --
and the on-disk record has 21 fields with no hole. The bug is invisible at
runtime and visible only in the fact that the SQL schemas, written by hand from
the same list, place `MOVE_TOPIC` after `MASS_PRUNE`, matching by luck.

`forum_moderators` is the only table with a gap. Every other table's ordinals
run contiguously from 0.

#### 1.5 Column order determines the wire format

Because `col_name` is built by sorting on ordinal, and because both
`encode_record` and `decode_record` iterate `0 .. total_cols` over that array,
**the field order in a stored record is the declared columns sorted by
ordinal**. For every table in the shipped set this is identical to the order the
columns appear in the `.cfg` file, since the files are written in ordinal order.
That is a convenience, not a guarantee -- a hand-edited `.cfg` that reorders the
lines without changing the numbers would produce the same wire format.

`load_cfg` calls `undef $IMPORT::STRING` on exit but deliberately does not undef
`$IMPORT::COLS`. This is safe: `%{ $COLS } = (...)` is a whole-hash assignment,
which discards the previous table's keys. Had any `.cfg` used `$COLS->{X} = ...`
instead, columns would leak between tables.


---

### 5.2 The 29 tables

Every table below is re-extracted from `Database/config/*.cfg`. The **pos**
column is the zero-based position of the field in a stored record -- the number
that matters when parsing. The **ord** column is the ordinal as written in the
`.cfg`; the two differ only for `forum_moderators`, for the reason given in
section 5.1.4. Widths are shown as declared; nothing enforces them outside SQL.

#### 2.1 Content tables

These seven hold the forum itself.

##### `forum_info` -- one row per forum

Primary key `FORUM_ID` (`num`, not `update`: forum IDs are assigned by the
admin control panel, not by the counter file). Method `single`, no `ID`, so the
whole table lives in one file. This is the board's structural spine: every
forum's name, description, permissions and denormalized counters.

| pos | ord | column | type | width | req |
|----:|----:|--------|------|------:|:---:|
| 0 | 0 | `FORUM_ID` | num | 5 | yes |
| 1 | 1 | `FORUM_TOPICS` | num | 6 |  |
| 2 | 2 | `FORUM_POSTS` | num | 6 |  |
| 3 | 3 | `FORUM_LAST_POST` | num | 10 |  |
| 4 | 4 | `FORUM_LAST_POSTER` | string | 32 |  |
| 5 | 5 | `FORUM_LAST_POSTER_N` | string | 32 |  |
| 6 | 6 | `FORUM_NAME` | string | 128 | yes |
| 7 | 7 | `FORUM_DESC` | string | 512 |  |
| 8 | 8 | `FORUM_POSITION` | num | 2 |  |
| 9 | 9 | `FORUM_IBC` | num | 1 |  |
| 10 | 10 | `FORUM_HTML` | num | 1 |  |
| 11 | 11 | `FORUM_STATUS` | string | 10 |  |
| 12 | 12 | `FORUM_START_THREADS` | string | 32 |  |
| 13 | 13 | `FORUM_REPLY_THREADS` | string | 32 |  |
| 14 | 14 | `FORUM_VIEW_THREADS` | string | 32 |  |
| 15 | 15 | `FORUM_PROTECT` | string | 32 |  |
| 16 | 16 | `CATEGORY` | num | 2 | yes |
| 17 | 17 | `L_TOPIC_TITLE` | string | 32 |  |
| 18 | 18 | `L_TOPIC_ID` | num | 6 |  |
| 19 | 19 | `SORT_KEY` | string | 32 |  |
| 20 | 20 | `SORT_ORDER` | string | 32 |  |
| 21 | 21 | `PRUNE_DAYS` | num | 3 |  |
| 22 | 22 | `SHOW_RULES` | num | 1 |  |
| 23 | 23 | `ALLOW_ATTACH` | num | 1 |  |
| 24 | 24 | `MODERATE` | num | 1 |  |

`FORUM_TOPICS`, `FORUM_POSTS`, `FORUM_LAST_POST`, `FORUM_LAST_POSTER`,
`FORUM_LAST_POSTER_N`, `L_TOPIC_TITLE` and `L_TOPIC_ID` are all denormalized
cache fields, maintained by the posting code so that the board index can be
rendered without touching `forum_topics` or `forum_posts`. On a board that has
been edited by hand or partially restored they routinely disagree with reality;
treat them as hints, not facts.

`FORUM_START_THREADS`, `FORUM_REPLY_THREADS`, `FORUM_VIEW_THREADS` and
`FORUM_PROTECT` are `string(32)` rather than numeric because they hold
comma-joined lists of member group IDs, not single values. `CATEGORY` points at
`categories.CAT_ID`. `SORT_KEY` and `SORT_ORDER` are passed straight through to
the driver's `SORT_KEY`/`SORT_BY` arguments, which is why `SORT_ORDER` holds the
literal strings `A-Z` or `Z-A` rather than a boolean.

`FORUM_IBC` and `FORUM_HTML` toggle iB-code and raw HTML in posts;
`ALLOW_ATTACH` and `MODERATE` gate uploads and the post queue; `PRUNE_DAYS`
drives automatic topic expiry; `SHOW_RULES` selects between the three rule
display modes in `ForumView`.

##### `categories` -- forum groupings

Primary key `CAT_ID` (`update`). One file.

| pos | ord | column | type | width | req |
|----:|----:|--------|------|------:|:---:|
| 0 | 0 | `CAT_ID` | update | 3 | yes |
| 1 | 1 | `CAT_POS` | num | 3 |  |
| 2 | 2 | `CAT_STATE` | string | 10 |  |
| 3 | 3 | `CAT_NAME` | string | 128 | yes |
| 4 | 4 | `CAT_DESC` | string | 512 |  |
| 5 | 5 | `SUB_CAT_ID` | num | 1 |  |
| 6 | 6 | `VIEW` | string | 50 |  |
| 7 | 7 | `IMAGE` | string | 128 |  |
| 8 | 8 | `URL` | string | 128 |  |

`SUB_CAT_ID` is a single digit, and `VIEW` is a `string(50)` group list in the
same comma-joined form as the `forum_info` permission columns. `IMAGE` and `URL`
let a category act as a link to somewhere else entirely rather than a container.
`CAT_STATE` holds the expanded/collapsed default that `BoardsView` renders via
its `CatHeader_Expanded` / `CatHeader_Collapsed` templates.

##### `forum_topics` -- one row per topic

Primary key `TOPIC_ID` (`update`), `ID` = `FORUM_ID`, `UPDATE` = `top`. This is
the only table in the product that prepends new rows.

| pos | ord | column | type | width | req |
|----:|----:|--------|------|------:|:---:|
| 0 | 0 | `TOPIC_ID` | update | 10 | yes |
| 1 | 1 | `TOPIC_TITLE` | string | 70 | yes |
| 2 | 2 | `TOPIC_DESC` | string | 70 |  |
| 3 | 3 | `TOPIC_STATE` | string | 8 |  |
| 4 | 4 | `TOPIC_POSTS` | num | 4 |  |
| 5 | 5 | `TOPIC_STARTER` | string | 32 |  |
| 6 | 6 | `TOPIC_START_DATE` | num | 10 |  |
| 7 | 7 | `TOPIC_LAST_POSTER` | string | 32 |  |
| 8 | 8 | `TOPIC_LAST_DATE` | num | 10 |  |
| 9 | 9 | `TOPIC_ICON` | num | 2 |  |
| 10 | 10 | `TOPIC_STARTER_N` | string | 32 |  |
| 11 | 11 | `TOPIC_LASTP_N` | string | 32 |  |
| 12 | 12 | `POLL_STATE` | string | 8 |  |
| 13 | 13 | `LAST_VOTE` | num | 10 |  |
| 14 | 14 | `TOPIC_VIEWS` | num | 5 |  |
| 15 | 15 | `FORUM_ID` | num | 5 |  |
| 16 | 16 | `APPROVED` | num | 1 |  |
| 17 | 17 | `TOPIC_AUTHOR_TYPE` | num | 1 |  |
| 18 | 18 | `PIN_STATE` | num | 1 |  |
| 19 | 19 | `MOVED_TO` | string | 64 |  |
| 20 | 20 | `WATCHED` | num | 1 |  |

Partitioned by forum: `Sources/Forum.pm:224` passes `ID => $forum_id`, so there
is one file per forum and `TOPIC_ID` restarts at 1 in each of them. A topic is
identified by the pair `(FORUM_ID, TOPIC_ID)`, never by `TOPIC_ID` alone.

`TOPIC_DESC` is the subtitle shown under the title in `ForumView`. It is the
column `out_records.txt` dropped from this table, because its declaration ends
in a trailing comma.

`TOPIC_STARTER` and `TOPIC_LAST_POSTER` hold member *IDs*; `TOPIC_STARTER_N` and
`TOPIC_LASTP_N` hold the display *names* at the time of posting. The `_N` copies
are never refreshed, so a member who renames themselves leaves stale names
scattered through the topic list -- useful for dating a board, unhelpful for
joining.

`TOPIC_STATE` (`string(8)`) holds open/closed as a word rather than a flag;
`POLL_STATE` likewise. `PIN_STATE`, `APPROVED` and `WATCHED` are booleans.
`MOVED_TO` is non-empty only for the tombstone rows left behind when a topic is
moved, and encodes the destination. `TOPIC_AUTHOR_TYPE` distinguishes registered
from guest starters.

##### `forum_posts` -- one row per post

Primary key `POST_ID` (`update`), `DBID` = `FORUM_ID`, `ID` = `TOPIC_ID`. The
only table in the product that declares `DBID`.

| pos | ord | column | type | width | req |
|----:|----:|--------|------|------:|:---:|
| 0 | 0 | `POST_ID` | update | 10 | yes |
| 1 | 1 | `AUTHOR` | string | 32 |  |
| 2 | 2 | `ENABLE_SIG` | num | 1 |  |
| 3 | 3 | `ENABLE_EMO` | num | 1 |  |
| 4 | 4 | `IP_ADDR` | string | 16 | yes |
| 5 | 5 | `POST_DATE` | num | 10 | yes |
| 6 | 6 | `POST_ICON` | num | 2 |  |
| 7 | 7 | `POST` | text | unbounded |  |
| 8 | 8 | `AUTHOR_TYPE` | num | 1 |  |
| 9 | 9 | `QUEUED` | num | 1 |  |
| 10 | 10 | `TOPIC_ID` | num | 10 | yes |
| 11 | 11 | `FORUM_ID` | num | 5 | yes |
| 12 | 12 | `ATTACH_ID` | string | 64 |  |
| 13 | 13 | `ATTACH_HITS` | num | 5 |  |
| 14 | 14 | `ATTACH_TYPE` | string | 128 |  |

This is the most heavily partitioned table on the board: one directory per
forum, one file per topic. `Sources/Topic.pm:326-328` shows the call shape, with
the `f` prefix on the directory supplied by the caller:

```perl
    my $total_posts = $db->query( TABLE    => 'forum_posts',
                                  DBID     => 'f'.$obj->{'.forum_id'},
                                  ID       => $obj->{'.topic_id'},
```

`POST_ID` therefore restarts at 1 in every topic. A post is identified by the
triple `(FORUM_ID, TOPIC_ID, POST_ID)`.

`POST` is the `text` column holding the body, pre-escaped by `_clean_value` (see
section 5.9). `AUTHOR` is a member ID; there is no cached author name here, so
rendering a topic requires a `member_profiles` lookup per distinct poster.
`ENABLE_SIG` and `ENABLE_EMO` are the per-post overrides offered on the post
form. `QUEUED` marks posts awaiting moderation in a `MODERATE`d forum.
`AUTHOR_TYPE` distinguishes registered from guest.

`ATTACH_ID` references `attachments.ID`; `ATTACH_TYPE` duplicates the MIME type
already stored there, and `ATTACH_HITS` counts downloads. `ATTACH_ID`,
`ATTACH_HITS` and `ATTACH_TYPE` are all empty on the overwhelming majority of
rows.

##### `forum_polls` -- poll definitions

Primary key `ID` (`update`). Note that `ID` and `POLL_ID` are different columns:
`ID` is the row's own auto-number, `POLL_ID` is the topic-facing identifier that
`forum_topics.POLL_STATE` and `forum_poll_voters.POLL_ID` refer to.

| pos | ord | column | type | width | req |
|----:|----:|--------|------|------:|:---:|
| 0 | 0 | `ID` | update | 10 | yes |
| 1 | 1 | `POLL_ID` | num | 10 | yes |
| 2 | 2 | `POLL_TITLE` | string | 128 | yes |
| 3 | 3 | `POLL_DESC` | string | 512 |  |
| 4 | 4 | `POLL_STARTED` | num | 10 |  |
| 5 | 5 | `POLL_ANSWERS` | text | unbounded |  |
| 6 | 6 | `POLL_STARTER` | string | 32 |  |
| 7 | 7 | `POLL_STARTER_N` | string | 32 |  |
| 8 | 8 | `TOTAL_VOTES` | num | 5 |  |
| 9 | 9 | `FORUM_ID` | num | 5 |  |

`POLL_ANSWERS` is a `text` column -- one of the fourteen `out_records.txt`
lost, and the one that makes this table useless without it, since it holds the
answer options and their vote tallies. The exact sub-encoding inside
`POLL_ANSWERS` is not declared anywhere in `Database/config/`; it is imposed by
`Sources/iPoll.pm`. Recovering polls therefore requires reading that module, not
just the schema.

`TOTAL_VOTES` is a denormalized count that should equal the number of
`forum_poll_voters` rows for the same `POLL_ID`, and frequently does not.

##### `forum_poll_voters` -- one row per vote cast

Primary key `ID` (`update`). Exists solely to enforce one-vote-per-person.

| pos | ord | column | type | width | req |
|----:|----:|--------|------|------:|:---:|
| 0 | 0 | `ID` | update | 10 | yes |
| 1 | 1 | `VOTER_IP` | string | 16 | yes |
| 2 | 2 | `DATE` | num | 10 | yes |
| 3 | 3 | `POLL_ID` | num | 10 | yes |
| 4 | 4 | `MEMBER_ID` | string | 32 |  |
| 5 | 5 | `FORUM_ID` | num | 5 |  |

`MEMBER_ID` is the column `out_records.txt` dropped here (trailing comma). It is
optional, because guest voting is identified by `VOTER_IP` alone -- which is why
`VOTER_IP` is one of the four required columns. This table is the reason a
recovered board can tell you which member voted in a poll, but not what they
voted for: the choice is aggregated into `forum_polls.POLL_ANSWERS` and never
stored per voter.

##### `forum_rules` -- board and forum rule text

Primary key `ID`, declared `num` rather than `update`.

| pos | ord | column | type | width | req |
|----:|----:|--------|------|------:|:---:|
| 0 | 0 | `ID` | num | 6 | yes |
| 1 | 1 | `RULES_TITLE` | string | 128 | yes |
| 2 | 2 | `RULES_TEXT` | text | unbounded |  |
| 3 | 3 | `LAST_UPDATE` | num | 10 |  |
| 4 | 4 | `SHOW_ALL` | num | 1 |  |

`RULES_TEXT` is `text`. `SHOW_ALL` selects whether the rule set applies board-wide
or only to the forums that reference it. The single seed row shipped in
`INSTALL_DATA/board_rules.dat` is:

```
00|^|Board Rules|^|Please respect fellow members...
```

Three fields for a five-column table: `LAST_UPDATE` is supplied by
`install_modules/populate.pl:161` as `time`, and `SHOW_ALL` is left unset.

#### 2.2 People tables

##### `member_profiles` -- one row per member

Primary key `MEMBER_ID`. No `MTD`, so unpartitioned. Carries an `INDEX`
declaration -- the only table with a populated one:

```perl
            "INDEX"   => {
                            'MEMBER_NAME'  => 'MEMBER_ID',
                            'MEMBER_EMAIL' => 'MEMBER_ID',
                         },
```

At 41 columns this is the widest table in the product.

| pos | ord | column | type | width | req |
|----:|----:|--------|------|------:|:---:|
| 0 | 0 | `MEMBER_ID` | string | 32 | yes |
| 1 | 1 | `MEMBER_NAME` | string | 32 | yes |
| 2 | 2 | `MEMBER_GROUP` | num | 2 | yes |
| 3 | 3 | `MEMBER_PASSWORD` | string | 32 | yes |
| 4 | 4 | `MEMBER_EMAIL` | string | 60 | yes |
| 5 | 5 | `MEMBER_JOINED` | num | 10 | yes |
| 6 | 6 | `MEMBER_LEVEL` | num | 5 |  |
| 7 | 7 | `MEMBER_IP` | string | 16 | yes |
| 8 | 8 | `MEMBER_AVATAR` | string | 128 |  |
| 9 | 9 | `AVATAR_DIMS` | string | 9 |  |
| 10 | 10 | `MEMBER_POSTS` | num | 7 |  |
| 11 | 11 | `PHOTO` | string | 128 |  |
| 12 | 12 | `CANCEL_SUBS` | num | 1 |  |
| 13 | 13 | `AOLNAME` | string | 40 |  |
| 14 | 14 | `ICQNUMBER` | num | 40 |  |
| 15 | 15 | `LOCATION` | string | 128 |  |
| 16 | 16 | `SIGNATURE` | string | 1024 |  |
| 17 | 17 | `WEBSITE` | string | 70 |  |
| 18 | 18 | `YAHOONAME` | string | 32 |  |
| 19 | 19 | `MEMBER_TITLE` | string | 64 |  |
| 20 | 20 | `LAST_UPDATE` | num | 10 |  |
| 21 | 21 | `ALLOW_ADMIN_EMAILS` | num | 1 |  |
| 22 | 22 | `TIME_ADJUST` | string | 3 |  |
| 23 | 23 | `INTERESTS` | string | 512 |  |
| 24 | 24 | `HIDE_EMAIL` | num | 1 |  |
| 25 | 25 | `PM_REMINDER` | string | 3 |  |
| 26 | 26 | `EMAIL_FULL_POST` | string | 3 |  |
| 27 | 27 | `MEMBER_SKIN` | string | 32 |  |
| 28 | 28 | `WARN_LEVEL` | num | 2 |  |
| 29 | 29 | `LANGUAGE` | string | 32 |  |
| 30 | 30 | `MSNNAME` | string | 32 |  |
| 31 | 31 | `LAST_POST` | string | 32 |  |
| 32 | 32 | `ALLOW_POST` | num | 1 |  |
| 33 | 33 | `VIEW_SIGS` | num | 1 |  |
| 34 | 34 | `VIEW_IMG` | num | 1 |  |
| 35 | 35 | `VIEW_AVS` | num | 1 |  |
| 36 | 36 | `LAST_LOG_IN` | num | 10 |  |
| 37 | 37 | `LAST_ACTIVITY` | num | 10 |  |
| 38 | 38 | `GENDER` | num | 1 |  |
| 39 | 39 | `MEMBER_NAME_R` | string | 40 |  |
| 40 | 40 | `POST_FONT_COLOR` | string | 15 |  |

`MEMBER_NAME_R` at ordinal 39 is the "real name" field, and it is the column
`out_records.txt` dropped from this table (trailing comma). The `.cfg` also
preserves the only third-party attribution in the whole `Database/config`
directory, immediately above the last column:

```perl
                      "MEMBER_NAME_R"       => [39 , 'string',    40,  ],
# added by kevaholic00
                      "POST_FONT_COLOR"     => [40,  'string',    15   ],
# end add
```

`POST_FONT_COLOR` is a community patch that was merged into the shipped
declarations, comment markers and all. It appears in all three SQL schemas too,
so the merge was done consistently.

`MEMBER_PASSWORD` is `string(32)`, sized for an MD5 hex digest.
`MEMBER_GROUP` references `mem_groups.ID`. `MEMBER_POSTS` is a denormalized
counter and `MEMBER_TITLE` a denormalized copy of the title earned from
`member_titles`, so neither can be trusted after a restore.

`AVATAR_DIMS` is `string(9)` holding a packed `WWWxHHH` pair. `TIME_ADJUST` is
`string(3)` holding a signed hour offset, and is multiplied by 3600 at
`Sources/Lib/FUNC.pm:582`. `PM_REMINDER` and `EMAIL_FULL_POST` are both
`string(3)`, and `EMAIL_FULL_POST` is a genuinely packed field: `Track.pm:96-97`
splits it on `&` into two independent flags.

```perl
	my $full = (split/&/, $iB::MEMBER->{'EMAIL_FULL_POST'})[0]; # if you want the full post first byte
	my $once = (split/&/, $iB::MEMBER->{'EMAIL_FULL_POST'})[1]; # if you want the reminder once secound byte
```

`VIEW_SIGS`, `VIEW_IMG`, `VIEW_AVS`, `HIDE_EMAIL`, `ALLOW_ADMIN_EMAILS`,
`ALLOW_POST` and `CANCEL_SUBS` are per-member display and privacy toggles;
`WARN_LEVEL` is the moderation warning counter driven by `WarnView`.
`MEMBER_SKIN` and `LANGUAGE` override the board defaults per member.
`LAST_POST` is `string(32)`, not a timestamp -- it stores a post reference used
for flood control, distinct from `LAST_ACTIVITY` and `LAST_LOG_IN`, which are
both `num(10)` epochs.

##### `mem_groups` -- permission groups

Primary key `ID` (`update`). 34 columns, of which 30 are one-bit permission
flags. This is the board's entire authorization model.

| pos | ord | column | type | width | req |
|----:|----:|--------|------|------:|:---:|
| 0 | 0 | `ID` | update | 2 | yes |
| 1 | 1 | `VIEW_BOARD` | num | 1 |  |
| 2 | 2 | `MEM_INFO` | num | 1 |  |
| 3 | 3 | `OTHER_TOPICS` | num | 1 |  |
| 4 | 4 | `USE_SEARCH` | num | 1 |  |
| 5 | 5 | `EMAIL_FRIEND` | num | 1 |  |
| 6 | 6 | `INVITE_FRIEND` | num | 1 |  |
| 7 | 7 | `EDIT_PROFILE` | num | 1 |  |
| 8 | 8 | `POST_NEW_TOPICS` | num | 1 |  |
| 9 | 9 | `REPLY_OWN_TOPICS` | num | 1 |  |
| 10 | 10 | `REPLY_OTHER_TOPICS` | num | 1 |  |
| 11 | 11 | `EDIT_OWN_POSTS` | num | 1 |  |
| 12 | 12 | `DELETE_OWN_POSTS` | num | 1 |  |
| 13 | 13 | `OPEN_CLOSE_TOPICS` | num | 1 |  |
| 14 | 14 | `DELETE_OWN_TOPICS` | num | 1 |  |
| 15 | 15 | `POST_POLLS` | num | 1 |  |
| 16 | 16 | `VOTE_POLLS` | num | 1 |  |
| 17 | 17 | `USE_PM` | num | 1 |  |
| 18 | 18 | `IS_SUPMOD` | num | 1 |  |
| 19 | 19 | `ACCESS_CP` | num | 1 |  |
| 20 | 20 | `TITLE` | string | 32 | yes |
| 21 | 21 | `CAN_REMOVE` | num | 1 |  |
| 22 | 22 | `READ_AD_LOGS` | num | 1 |  |
| 23 | 23 | `DELETE_AD_LOGS` | num | 1 |  |
| 24 | 24 | `EDIT_GROUPS` | num | 1 |  |
| 25 | 25 | `APPEND_EDIT` | num | 1 |  |
| 26 | 26 | `ACCESS_OFFLINE` | num | 1 |  |
| 27 | 27 | `AVOID_Q` | num | 1 |  |
| 28 | 28 | `AVOID_FLOOD` | num | 1 |  |
| 29 | 29 | `TEAM_ICON` | string | 64 |  |
| 30 | 30 | `ATTACH_MAX` | num | 20 |  |
| 31 | 31 | `ADD_EVENT` | string | 3 |  |
| 32 | 32 | `UPLOAD_AVATARS` | num | 1 |  |
| 33 | 33 | `MAX_MESSAGES` | num | 3 |  |

The four groups seeded by `INSTALL_DATA/mem_groups.dat` are, in order,
*Awaiting Authorisation*, *Guests*, *Members* and *Super Administrators*, which
matches the `AUTHORISE_GROUP = 1`, `GUEST_GROUP = 2`, `MEMBER_GROUP = 3` and
`SUPAD_GROUP = 4` defaults in `ikonboard.conf`.

The seed file is worth examining, because it is the clearest surviving example
of the delimiter in use:

```
1|^|1|^|1|^|1|^|0|^|0|^|0|^|0|^|0|^|0|^|0|^|0|^|0|^|0|^|0|^|0|^|0|^|0|^|0|^|Awaiting Authorisation|^|0|^|0|^|0|^|0|^|0|^|0|^|0|^|0
```

It is **not** a table dump. `install_modules/populate.pl:86-115` maps it field by
field, and the mapping is offset by one: `VIEW_BOARD` is hardcoded to `1`,
`MEM_INFO` takes `$entry[1]`, and so on, so `$entry[N]` lands in ordinal `N+1`.
`$entry[0]` is read by nothing. `CAN_REMOVE` (ordinal 21) is hardcoded to `0`
and `$entry[20]` is likewise skipped. The mapping stops at `AVOID_FLOOD`
(`$entry[27]`, ordinal 28), so `TEAM_ICON`, `ATTACH_MAX`, `ADD_EVENT`,
`UPLOAD_AVATARS` and `MAX_MESSAGES` are **never seeded** and are empty on a
fresh install. The fourth row of the seed file carries a 29th field with the
value `50` -- plainly intended as `MAX_MESSAGES` for administrators -- and
`populate.pl` never reads `$entry[28]`. It is dead data shipped in the box.

`ATTACH_MAX` is declared `num(20)` and used at `Sources/Post.pm:1095` as
kilobytes: `$iB::MEMBER_GROUP->{'ATTACH_MAX'} * 1024`. With the seed leaving it
empty, the effective upload limit on a fresh board is zero.

`IS_SUPMOD`, `ACCESS_CP`, `EDIT_GROUPS`, `READ_AD_LOGS` and `DELETE_AD_LOGS` are
the administrative bits; `AVOID_Q` bypasses the moderation queue and
`AVOID_FLOOD` the flood timer; `ACCESS_OFFLINE` is what lets an administrator
see the board while `OFFLINE_MESSAGE` is being shown to everyone else.

##### `member_titles` -- post-count ranks

Primary key `ID` (`update`).

| pos | ord | column | type | width | req |
|----:|----:|--------|------|------:|:---:|
| 0 | 0 | `ID` | update | 5 | yes |
| 1 | 1 | `POSTS` | num | 10 |  |
| 2 | 2 | `TITLE` | string | 128 |  |
| 3 | 3 | `ADVANCE_GROUP` | num | 5 |  |
| 4 | 4 | `PIPS` | num | 2 |  |

`POSTS` is the threshold, `TITLE` the label copied into
`member_profiles.MEMBER_TITLE`, `PIPS` the number of star images to render, and
`ADVANCE_GROUP` an optional automatic promotion into a different
`mem_groups.ID` on reaching the threshold. Nothing seeds this table; a fresh
board has no ranks until an administrator creates them.

##### `member_notepads` -- per-member scratch storage

Primary key `MEMBER_ID`. No `MTD`. Declares an empty `"INDEX" => {}`.

| pos | ord | column | type | width | req |
|----:|----:|--------|------|------:|:---:|
| 0 | 0 | `MEMBER_ID` | string | 32 | yes |
| 1 | 1 | `NOTEPAD_TEXT` | text | unbounded |  |
| 2 | 2 | `SAVED_P` | text | unbounded |  |
| 3 | 3 | `SAVED_M` | text | unbounded |  |

Three of the four columns are `text`, which is why `out_records.txt` reported
this table as having a single column. `NOTEPAD_TEXT` is the user-facing notepad
from `NotePadView`; `SAVED_P` and `SAVED_M` are the auto-saved drafts of an
in-progress post and private message respectively, which is why
`NotePadView.cfg` has exactly three subs named `pad_text_area`,
`post_text_area` and `mess_text_area`.

This table is a recovery prize: it can contain post drafts that were never
submitted, and messages that were never sent.

##### `authorisation` -- pending registrations

Primary key `ID` (`update`). The staging area between "filled in the form" and
"is a member".

| pos | ord | column | type | width | req |
|----:|----:|--------|------|------:|:---:|
| 0 | 0 | `ID` | update | 10 | yes |
| 1 | 1 | `UNIQUE_CODE` | string | 32 | yes |
| 2 | 2 | `DATE_ENTERED` | num | 10 | yes |
| 3 | 3 | `MEMBER_ID` | string | 32 | yes |
| 4 | 4 | `MEMBER_NAME` | string | 32 | yes |
| 5 | 5 | `THIS_IP` | string | 16 | yes |
| 6 | 6 | `MEMBER_EMAIL` | string | 128 | yes |
| 7 | 7 | `_WHERE` | string | 64 | yes |
| 8 | 8 | `MEMBER_GROUP` | num | 2 |  |

Eight of nine columns are required -- the highest proportion in the schema.
`UNIQUE_CODE` is the token mailed to the registrant and echoed back in the
`REG` email template as `<#CODE#>`. `_WHERE` (leading underscore, `string(64)`)
records which flow the row came from. `DATE_ENTERED` is compared against
`AUTHORISE_PRUNE` (default 30 days) to expire abandoned registrations.

Because the row carries `MEMBER_ID`, `MEMBER_NAME`, `MEMBER_EMAIL` and
`THIS_IP`, a surviving `authorisation` table on an abandoned board is a list of
people who tried to join and never completed -- data that exists nowhere else.

#### 2.3 Messaging tables

##### `message_data` -- private messages

Primary key `MESSAGE_ID` (`update`), `ID` = `MEMBER_ID`. Partitioned per member.

| pos | ord | column | type | width | req |
|----:|----:|--------|------|------:|:---:|
| 0 | 0 | `MESSAGE_ID` | update | 10 | yes |
| 1 | 1 | `DATE` | num | 10 |  |
| 2 | 2 | `READ_STATE` | num | 1 |  |
| 3 | 3 | `TITLE` | string | 128 |  |
| 4 | 4 | `MESSAGE` | text | unbounded |  |
| 5 | 5 | `MESSAGE_ICON` | num | 2 |  |
| 6 | 6 | `FROM_ID` | string | 32 |  |
| 7 | 7 | `FROM_NAME` | string | 32 |  |
| 8 | 8 | `REPLY` | num | 1 |  |
| 9 | 9 | `REPLY_DATE` | num | 10 |  |
| 10 | 10 | `VIRTUAL_DIR` | string | 32 |  |
| 11 | 11 | `MEMBER_ID` | string | 32 |  |
| 12 | 12 | `RECIPIENT_ID` | string | 32 |  |
| 13 | 13 | `RECIPIENT_NAME` | string | 32 |  |

`MESSAGE` is the `text` body. Note that both `MEMBER_ID` and `RECIPIENT_ID` are
present in the same row: because the table is partitioned by `MEMBER_ID`, a
message that has been sent and received exists as **two rows in two different
files**, one in the sender's partition and one in the recipient's, each with its
own `MESSAGE_ID`. Deleting from one inbox does not touch the other copy.

`VIRTUAL_DIR` (`string(32)`) is the user-created folder the message has been
filed into; the folder list itself lives in `message_stats.VIRTUAL_DIR`.
`READ_STATE`, `REPLY` and `REPLY_DATE` track the read/replied status.

##### `message_stats` -- per-member messenger state

Primary key `MEMBER_ID`, method `multiple` -- one of only two tables declared
that way.

| pos | ord | column | type | width | req |
|----:|----:|--------|------|------:|:---:|
| 0 | 0 | `MEMBER_ID` | string | 32 | yes |
| 1 | 1 | `LAST_READ` | num | 10 |  |
| 2 | 2 | `NEW_MESSAGES` | num | 2 |  |
| 3 | 3 | `LAST_FROM_NAME` | string | 32 |  |
| 4 | 4 | `LAST_FROM_ID` | string | 32 |  |
| 5 | 5 | `LAST_MSG_TITLE` | string | 128 |  |
| 6 | 6 | `LAST_MSG_ID` | num | 10 |  |
| 7 | 7 | `LAST_SENT` | num | 10 |  |
| 8 | 8 | `TOTAL_MESSAGES` | num | 1 |  |
| 9 | 9 | `VIRTUAL_DIR` | string | 512 |  |
| 10 | 10 | `SHOW_POPUP` | num | 1 |  |

`VIRTUAL_DIR` here is `string(512)`, sixteen times the width of the same-named
column in `message_data`, because this one holds the member's entire folder
list rather than a single folder name. The delimiter within it is imposed by
`Sources/Messenger.pm`, not by the schema.

`TOTAL_MESSAGES` is declared `num(1)` -- a single digit -- while
`mem_groups.MAX_MESSAGES` is `num(3)` and `ikonboard.conf` ships
`MAX_MESSAGES = 40`. The width is meaningless outside SQL, and in MySQL and
PostgreSQL it maps to a type that comfortably holds 40, so nothing breaks; it is
simply wrong.

##### `address_books` -- messenger contact lists

Primary key `ID` (`update`), method `multiple`, `ID` = `MEMBER_ID`. The other
`multiple` table.

| pos | ord | column | type | width | req |
|----:|----:|--------|------|------:|:---:|
| 0 | 0 | `ID` | update | 10 | yes |
| 1 | 1 | `IN_MEMBER_ID` | string | 32 | yes |
| 2 | 2 | `MEMBER_ID` | string | 32 | yes |
| 3 | 3 | `IN_MEMBER_NAME` | string | 32 | yes |
| 4 | 4 | `RECEIVE_MSG` | num | 1 |  |
| 5 | 5 | `IN_MEMBER_DESC` | string | 50 | yes |

Five of six columns required. `MEMBER_ID` is the owner; `IN_MEMBER_ID` and
`IN_MEMBER_NAME` the contact. `RECEIVE_MSG` is the per-contact block flag -- the
board's ignore list. `IN_MEMBER_DESC` is a free-text note the owner attaches to
the contact.

##### `mod_email` -- queued moderator mailings

Primary key `ID` (`update`).

| pos | ord | column | type | width | req |
|----:|----:|--------|------|------:|:---:|
| 0 | 0 | `ID` | update | 5 | yes |
| 1 | 1 | `FORUM_ID` | num | 5 | yes |
| 2 | 2 | `FORUM_NAME` | string | 128 |  |
| 3 | 3 | `EMAIL` | string | 128 |  |
| 4 | 4 | `MEMBER_ID` | string | 32 | yes |
| 5 | 5 | `MEMBER_NAME` | string | 32 |  |
| 6 | 6 | `TEXT` | text | unbounded | yes |
| 7 | 7 | `CHOICE` | num | 1 | yes |
| 8 | 8 | `WHENE` | num | 1 |  |
| 9 | 9 | `SENT` | num | 1 |  |

`TEXT` is the `text` body, and it is the **only** one of the fourteen `text`
columns in the schema that carries the required flag. `CHOICE` selects the
recipient set, `WHENE` (sic) the
timing, and `SENT` is the dispatch flag that keeps the queue from re-sending.
Both `FORUM_NAME` and `MEMBER_NAME` are denormalized copies.

#### 2.4 Moderation tables

##### `forum_moderators` -- per-forum moderator grants

Primary key `MODERATOR_ID` (`update`). 21 columns; see section 5.1.4 for the
duplicated `MOVE_TOPIC` declaration that makes it 21 rather than 22.

| pos | ord | column | type | width | req |
|----:|----:|--------|------|------:|:---:|
| 0 | 0 | `MODERATOR_ID` | update | 3 | yes |
| 1 | 1 | `FORUM_ID` | num | 5 | yes |
| 2 | 2 | `MEMBER_NAME` | string | 32 | yes |
| 3 | 3 | `MEMBER_ID` | num | 32 | yes |
| 4 | 4 | `EDIT_POST` | num | 1 |  |
| 5 | 5 | `EDIT_TOPIC` | num | 1 |  |
| 6 | 6 | `DELETE_POST` | num | 1 |  |
| 7 | 7 | `DELETE_TOPIC` | num | 1 |  |
| 8 | 8 | `VIEW_IP` | num | 1 |  |
| 9 | 9 | `OPEN_TOPIC` | num | 1 |  |
| 10 | 10 | `CLOSE_TOPIC` | num | 1 |  |
| 11 | 12 | `MASS_MOVE` | num | 1 |  |
| 12 | 13 | `MASS_PRUNE` | num | 1 |  |
| 13 | 14 | `MOVE_TOPIC` | num | 1 |  |
| 14 | 15 | `PIN_TOPIC` | num | 1 |  |
| 15 | 16 | `UNPIN_TOPIC` | num | 1 |  |
| 16 | 17 | `POST_Q` | num | 1 |  |
| 17 | 18 | `TOPIC_Q` | num | 1 |  |
| 18 | 19 | `ALLOW_WARN` | num | 1 |  |
| 19 | 20 | `ADD_TOPIC_WATCH` | num | 1 |  |
| 20 | 21 | `REMOVE_TOPIC_WATCH` | num | 1 |  |

A row grants one member one set of powers in one forum, so a moderator of three
forums has three rows. `MEMBER_ID` is declared `num(32)` here while
`member_profiles.MEMBER_ID` is `string(32)` -- a type mismatch that is invisible
on DBM (everything is a string on the wire) but produces an integer column in
all three SQL schemas. The same mismatch appears in `moderator_logs`.

The 18 permission flags mirror a subset of `mem_groups`, plus the four
topic-watch and queue powers (`POST_Q`, `TOPIC_Q`, `ADD_TOPIC_WATCH`,
`REMOVE_TOPIC_WATCH`) that only make sense per-forum.

##### `mod_posts` -- the moderation queue

Primary key `ID` (`update`).

| pos | ord | column | type | width | req |
|----:|----:|--------|------|------:|:---:|
| 0 | 0 | `ID` | update | 8 | yes |
| 1 | 1 | `AUTHOR` | string | 32 |  |
| 2 | 2 | `IP_ADDR` | string | 16 | yes |
| 3 | 3 | `POST_DATE` | num | 10 | yes |
| 4 | 4 | `POST` | text | unbounded |  |
| 5 | 5 | `AUTHOR_TYPE` | num | 1 |  |
| 6 | 6 | `TOPIC_ID` | num | 10 | yes |
| 7 | 7 | `FORUM_ID` | num | 5 | yes |
| 8 | 8 | `TYPE` | string | 3 |  |
| 9 | 9 | `POST_ID` | num | 10 |  |
| 10 | 10 | `ATTACH_ID` | string | 64 |  |

Holds posts and topics awaiting approval in a forum with `forum_info.MODERATE`
set. The shape deliberately mirrors `forum_posts` -- `AUTHOR`, `IP_ADDR`,
`POST_DATE`, `POST`, `AUTHOR_TYPE`, `TOPIC_ID`, `FORUM_ID`, `ATTACH_ID` -- so
that approval is a copy rather than a transformation. `TYPE` (`string(3)`)
distinguishes a queued new topic from a queued reply, and `POST_ID` is the
target post for an edit.

`out_records.txt` reported seven columns for this table; there are eleven. It
lost `POST` (`text`) and then `TYPE`, `POST_ID` and `ATTACH_ID`, all three of
which end in a trailing comma.

##### `moderator_logs` -- moderator action audit trail

Primary key `ID` (`update`). Declares **no** `MTD` and no `UPDATE` -- the
shortest `$STRING` block in the set:

```perl
$STRING = { "TABLE"   => "moderator_logs",
            "P_KEY"   => "ID",
          };
```

| pos | ord | column | type | width | req |
|----:|----:|--------|------|------:|:---:|
| 0 | 0 | `ID` | update | 5 | yes |
| 1 | 1 | `FORUM_ID` | num | 5 | yes |
| 2 | 2 | `TOPIC_ID` | num | 10 |  |
| 3 | 3 | `POST_ID` | num | 10 |  |
| 4 | 4 | `MEMBER_ID` | num | 32 | yes |
| 5 | 5 | `MEMBER_NAME` | string | 32 | yes |
| 6 | 6 | `REMOTE_ADDR` | string | 32 | yes |
| 7 | 7 | `HTTP_REFERER` | string | 128 |  |
| 8 | 8 | `TIME` | num | 10 |  |
| 9 | 9 | `TOPIC_TITLE` | string | 128 |  |
| 10 | 10 | `ACTION` | string | 128 |  |
| 11 | 11 | `QUERY_STRING` | string | 128 |  |

`TOPIC_ID` and `POST_ID` are the two columns `out_records.txt` dropped here.
`ACTION` is a free-text description, `QUERY_STRING` the raw request that
triggered it, and `HTTP_REFERER` the page it was triggered from -- so this table
records complete moderator request URLs, including whatever parameters they
carried. Together with `REMOTE_ADDR` it is the most privacy-sensitive table in
the schema after `member_profiles`.

#### 2.5 System tables

##### `active_sessions` -- logged-in sessions

Primary key `ID` (`string(32)`, a session hash -- not `update`).

| pos | ord | column | type | width | req |
|----:|----:|--------|------|------:|:---:|
| 0 | 0 | `ID` | string | 32 | yes |
| 1 | 1 | `MEMBER_NAME` | string | 32 |  |
| 2 | 2 | `MEMBER_PASSWORD` | string | 32 |  |
| 3 | 3 | `MEMBER_ID` | string | 32 |  |
| 4 | 4 | `THIS_IP` | string | 16 | yes |
| 5 | 5 | `LAST_LOG_IN` | num | 10 |  |
| 6 | 6 | `USER_AGENT` | string | 80 | yes |
| 7 | 7 | `RUNNING_TIME` | num | 10 |  |
| 8 | 8 | `MEMBER_LOGSTATE` | num | 1 |  |
| 9 | 9 | `LOCATION` | string | 160 |  |
| 10 | 10 | `LOG_IN_TYPE` | num | 1 |  |
| 11 | 11 | `MEMBER_GROUP` | num | 3 |  |

This table stores `MEMBER_PASSWORD` alongside `MEMBER_ID`, so a surviving
session file leaks credentials for every member who was logged in when the board
stopped. It is also the only table in `@r_IGNORE` at `DBM.pm:40`:

```perl
# Which tables do you want to ignore from warns/notices?
@r_IGNORE = ( 'active_sessions' );
```

so its deletions are excluded from the DBM driver's notice log -- sessions churn
constantly and would otherwise flood it.

`LOCATION` (`string(160)`) is the "what this user is doing" string shown in the
Active Users list; `USER_AGENT` is required and is compared on every request when
`CHECK_USER_AGENT = 1`. `RUNNING_TIME` and `LAST_LOG_IN` drive expiry against
`SESSION_EXPIRATION` (default 3000 seconds). `LOG_IN_TYPE` distinguishes cookie
from form login, and `MEMBER_LOGSTATE` the invisible-mode flag.

##### `topic_views` -- per-member read and watch state

Primary key `ID` (`update`), `ID` = `FORUM_ID`. Partitioned per forum.

| pos | ord | column | type | width | req |
|----:|----:|--------|------|------:|:---:|
| 0 | 0 | `ID` | update | 20 | yes |
| 1 | 1 | `TOPIC_ID` | num | 10 | yes |
| 2 | 2 | `FORUM_ID` | num | 5 | yes |
| 3 | 3 | `MEMBER_ID` | string | 32 |  |
| 4 | 4 | `VIEWED` | num | 10 |  |
| 5 | 5 | `POSTED_IN` | num | 1 |  |
| 6 | 6 | `SENT` | num | 1 |  |

Records that a given member has viewed a given topic at a given time, which is
what drives the new-post markers (`B_NEW`, `B_NORM_IN`, and the rest of the
`gfx_data` marker set). `POSTED_IN` is the flag behind the "..._IN" marker
variants -- the board draws a different icon for topics you have posted in.
`SENT` is reused as the subscription-notification latch: `Sources/Post.pm:1355`
sets it to 1 after mailing a subscriber, so that `SEND_ONCE` subscriptions do
not re-fire.

On an active board this is the highest-cardinality table in the product -- one
row per member per topic viewed -- and it is the first thing to prune.

##### `forum_subscriptions` -- topic and forum subscriptions

Primary key `ID` (`update`).

| pos | ord | column | type | width | req |
|----:|----:|--------|------|------:|:---:|
| 0 | 0 | `ID` | update | 9 | yes |
| 1 | 1 | `MEMBER_ID` | string | 32 | yes |
| 2 | 2 | `MEMBER_NAME` | string | 32 |  |
| 3 | 3 | `EMAIL_ADDRESS` | string | 32 |  |
| 4 | 4 | `FORUM_ID` | num | 5 | yes |
| 5 | 5 | `TOPIC_ID` | num | 5 | yes |
| 6 | 6 | `DATE_STARTED` | num | 10 |  |
| 7 | 7 | `SEND_ONCE` | num | 1 |  |
| 8 | 8 | `PRUNE` | num | 2 |  |
| 9 | 9 | `rFULL` | num | 1 |  |

`rFULL` is the odd one out: the only mixed-case column name in the entire
schema. It means "send the full post body rather than a notification", and it is
at the center of the backend drift documented in section 5.4.2.
`Sources/Misc/Track.pm:99-110` writes it, and `Sources/Post.pm:1349` reads it:

```perl
        my $message = $Row->{'rFULL'} ? $with : $without;
```

`SEND_ONCE` and `rFULL` are both derived from the single packed
`member_profiles.EMAIL_FULL_POST` field. `PRUNE` is copied from
`member_profiles.CANCEL_SUBS` and defaults to 30 days. `TOPIC_ID` is declared
`num(5)` while `forum_topics.TOPIC_ID` is `num(10)` -- harmless on DBM, a
narrower SQL column on the three RDBMS backends.

##### `attachments` -- uploaded file registry

Primary key `ID` (`update`). Three columns, and it stores no file contents.

| pos | ord | column | type | width | req |
|----:|----:|--------|------|------:|:---:|
| 0 | 0 | `ID` | update | 10 | yes |
| 1 | 1 | `MIME_TYPE` | string | 128 | yes |
| 2 | 2 | `FILE_NAME` | string | 64 |  |

The bytes live on the filesystem under `PUBLIC_UPLOAD`;
`Sources/Post.pm:1105-1112` derives the stored name:

```perl
        $file_to_attach =~ /([^\\\/\:]+)$/;
        $file_name = $1;
        $file_name =~ s/[^\w\.]/\_/g;

        $file_name = "post-$iB::IN{f}-".substr(time, 5,10)."-".$file_name;

        # Make perl/php scripts safe

        if ( ($file_name =~ /\.(cgi|pl|js|asp)$/i) or ($file_name =~ /\.php\d{0,2}$/i) ) {
            $file_name =~ s!\.!-!g;
            $file_name .= '.txt';
        }
```

So an attachment on disk is named
`post-<FORUM_ID>-<NNNNN>-<sanitized original name>`, where `NNNNN` is
`substr(time, 5, 10)` -- characters 5 onward of the decimal epoch, i.e. the last
five digits of a ten-digit timestamp. This is recoverable information: from a
bare uploads directory alone you can read off which forum each file was posted
in, and a five-digit slice of when. Non-word characters in the original name
become underscores, and script extensions are defanged by turning every dot into
a hyphen and appending `.txt`.

`MIME_TYPE` is validated against `Data/MimeTypes.cfg` before the upload is
accepted (`Post.pm:1087`).

##### `calendar` -- birthdays and events

Primary key `MEMBER_ID`. The `.cfg` carries a one-word comment, `# birthday`,
which is the only clue to its original purpose.

| pos | ord | column | type | width | req |
|----:|----:|--------|------|------:|:---:|
| 0 | 0 | `MEMBER_ID` | string | 32 | yes |
| 1 | 1 | `MEMBER_NAME` | string | 32 | yes |
| 2 | 2 | `DAY` | num | 2 | yes |
| 3 | 3 | `MONTH` | num | 2 | yes |
| 4 | 4 | `YEAR` | num | 4 | yes |
| 5 | 5 | `TIME_ADJUST` | string | 4 | yes |
| 6 | 6 | `UTIME` | num | 10 |  |
| 7 | 7 | `FORUM_ID` | num | 5 |  |
| 8 | 8 | `TOPIC_ID` | num | 10 |  |

Six of nine columns required. Keying on `MEMBER_ID` means a member can have
exactly one calendar row, which works for a birthday and not for events -- and
the `FORUM_ID` / `TOPIC_ID` columns show that events were bolted on afterwards,
linking a calendar entry to the topic that announced it. `DAY`, `MONTH` and
`YEAR` are stored as separate integers rather than as an epoch, with `UTIME`
added later as a computed epoch alongside them. `TIME_ADJUST` duplicates the
member's timezone offset.

Note that `mem_groups.ADD_EVENT` is `string(3)`, not a flag, which suggests it
was also intended to carry packed sub-options in the manner of
`EMAIL_FULL_POST`.

##### `help` -- help topic database

Primary key `ID` (`update`).

| pos | ord | column | type | width | req |
|----:|----:|--------|------|------:|:---:|
| 0 | 0 | `ID` | update | 2 | yes |
| 1 | 1 | `TITLE` | string | 128 | yes |
| 2 | 2 | `TEXT` | text | unbounded |  |

`TEXT` is `text`; `out_records.txt` reported two columns. Seeded from
`INSTALL_DATA/help.txt`, which is an INI-like format parsed at
`install_modules/populate.pl:258-268`: a line of the form `[Title]` opens a
topic and every following line accumulates into its body.

```
[Registration]
Depending on how this board is set up, you may have to register to post in certain forums. ...
[Posting]
Posting refers to either starting a new topic of conversation, or replying to an existing topic ...
```

##### `email_templates` -- outbound mail bodies

Primary key `ID` (`string(20)` -- a symbolic name, not a number).

| pos | ord | column | type | width | req |
|----:|----:|--------|------|------:|:---:|
| 0 | 0 | `ID` | string | 20 | yes |
| 1 | 1 | `TYPE` | string | 1 | yes |
| 2 | 2 | `TEMPLATE` | text | unbounded |  |

Seeded from `INSTALL_DATA/email_template.dat`, eleven rows keyed
`INVITE_FRIEND`, `LOST_PASS_ONE`, `LOST_PASS_TWO`, `MEM_TO_MEM`, `REG`, `SUBS`,
`E_CH`, `USER_NOTIFY`, `SEND_FRIEND`, `MASS_MAIL` and `OUT`. `TYPE` is a single
character and is `t` in every shipped row. Substitution tokens are written
`<#TOKEN#>` -- `<#MEMBER_NAME#>`, `<#BOARD_ADDRESS#>`, `<#THE_LINK#>`,
`<#CODE#>`, `<#SIGNATURE#>` and so on.

##### `templates` -- board HTML templates

Primary key `ID` (`string(20)`). Declared with three columns, not zero.

| pos | ord | column | type | width | req |
|----:|----:|--------|------|------:|:---:|
| 0 | 0 | `ID` | string | 20 | yes |
| 1 | 1 | `TEMPLATE` | text | unbounded |  |
| 2 | 2 | `NAME` | string | 128 |  |

```perl
%{ $COLS }  = (  ID                => [0, 'string', '20', 1],
                 TEMPLATE          => [1, 'text'  , '-1'],
                 NAME              => [2, 'string', '128'],
              );
```

Two rows are seeded, `global` (from `INSTALL_DATA/global_template.html`) and
`register` (from `INSTALL_DATA/register.html`), at
`install_modules/populate.pl:222` and `:241`. These are the wrapper documents the
board renders its pages inside -- distinct from the skin, which supplies the
fragments.

The quoted widths `'20'`, `'-1'`, `'128'` are the reason `out_records.txt`
reported this table as empty. Nothing at runtime reads the width, so the quoting
is harmless.

##### `ssi_templates` -- server-side-include fragments

Primary key `ID` (`string(20)`). Also three columns, also reported as zero.

| pos | ord | column | type | width | req |
|----:|----:|--------|------|------:|:---:|
| 0 | 0 | `ID` | string | 20 | yes |
| 1 | 1 | `TEMPLATE` | text | unbounded |  |
| 2 | 2 | `EXPORT_FILENAME` | string | 32 |  |

Seeded from `INSTALL_DATA/ssi_templates.dat` plus one extra row for `news`.
`EXPORT_FILENAME` is the name of the flat file the fragment is written out to,
so that a non-Perl page elsewhere on the site can include it. The seed rows are:

| ID | EXPORT_FILENAME |
|----|-----------------|
| `ONLINE_COUNT` | `online_count.txt` |
| `ONLINE_FORMAT` | `online_format.txt` |
| `ONLINE_LIST` | `online_list.txt` |
| `POST_TOTALS` | `post_totals.txt` |
| `LAST_POSTED` | `last_posted.txt` |
| `ACTIVITY_COUNT` | `activity_count.txt` |
| `ACTIVITY_LIST` | `activity_list.txt` |
| `ACTIVITY_LISTF` | `activity_listf.txt` |
| `news` | `newsdata.txt` |

Note that `ssi_templates.dat` stores its fields in the order
`ID | EXPORT_FILENAME | TEMPLATE`, while the declaration puts `TEMPLATE` at
ordinal 1 and `EXPORT_FILENAME` at 2. The seed file is not in wire order;
`populate.pl:202-206` maps it by name. This is a good illustration of why a
`|^|`-delimited file cannot be assumed to be a table dump.

The token syntax inside these templates is `<%...%>` with spaces, which is
unlike anything else in the product:

```
<b>Number of users active on our forum</b>\n<br>\n<%number of guests%> guests, <%number of members%> members making a total of <%total online%> users
```

##### `search_log` -- cached search results

Primary key `LOG_ID` (`update`), `ID` = `FORUM_ID`.

| pos | ord | column | type | width | req |
|----:|----:|--------|------|------:|:---:|
| 0 | 0 | `LOG_ID` | update | 20 | yes |
| 1 | 1 | `AUTHOR_ID` | string | 32 |  |
| 2 | 2 | `MEMBER_NAME` | string | 32 |  |
| 3 | 3 | `DATE` | num | 10 |  |
| 4 | 4 | `TOPIC_TITLE` | string | 128 |  |
| 5 | 5 | `POST` | text | unbounded |  |
| 6 | 6 | `FORUM_ID` | num | 5 | yes |
| 7 | 7 | `TOPIC_ID` | num | 10 | yes |
| 8 | 8 | `POST_ID` | num | 10 | yes |
| 9 | 9 | `POSTER_IP` | string | 16 |  |

Despite the name this is not a log of queries; it is a materialized copy of the
posts that matched, so that paging through results does not re-run the search.
`POST` is a `text` copy of the matched post body, another of the columns
`out_records.txt` dropped. `POSTER_IP` is copied along with it.

The consequence for recovery is significant: `search_log` can contain full
copies of posts that have since been deleted from `forum_posts`. Working files
live under `Database/Temp/Searches/`, which ships as an empty directory.

The board strips noise words before indexing using `SKIP_WORDS` from the
configuration; `Sources/Searchlog.pm:60` interpolates the whole pipe-delimited
list directly into a regex:

```perl
   $Txt =~ s!\b($iB::INFO->{'SKIP_WORDS'})\b!!ig;
```

---

### 5.3 The storage backends: four live, one fossil

`Sources/iDatabase/Driver/` contains five driver modules -- `CSV.pm`, `DBM.pm`,
`mySQL.pm`, `Oracle.pm`, `pgSQL.pm` -- plus the `Base.pm` superclass. Only
**four** are live backends. The flat-file option on an Ikonboard 3.1.1 board is
**DBM**; `CSV.pm` ships in the tarball but cannot be loaded, and no board has
ever used it.

#### 3.1 `CSV.pm` -- a fossil of the 3.0 storage engine

`Sources/iDatabase/Driver/CSV.pm` is 1014 lines (33 KB) implementing a complete
line-oriented flat-file backend. It is unreachable in 3.1.1 for five independent
reasons.

**It is not offered.** `install_modules/database.pl:44` builds the backend
chooser, and CSV is not in it:

```perl
    my $select = "<select name='DB_DRIVER' class='forminput'><option value='DBM' selected>DBM Database</option><option value='mySQL'>mySQL Database</option><option value='pgSQL'>PostgreSQL Database</option><option value='Oracle'>Oracle Database</option>";
```

Four options: DBM (pre-selected), mySQL, pgSQL, Oracle. `Sources/Admin/dbHandler.pm`,
which offers the same choice after installation, presents the same four.

**Its package name is wrong.** `iDatabase::SQL::new` builds the class name from
the configured driver (`SQL.pm:45`) and `require`s the corresponding file:

```perl
    my $class = "iDatabase::Driver::$args{DB_DRIVER}";
```

`DBM.pm:10`, `mySQL.pm:11`, `Oracle.pm:11` and `pgSQL.pm:1` all declare
`package iDatabase::Driver::<name>;`. `CSV.pm:1` declares `package iDatabase;`.
The file would load, but the class would be empty.

**Its constructor is wrong.** `SQL.pm:62` calls:

```perl
    $obj->{driver} = $class->newSQL( \%args );
```

The four live drivers each define `sub newSQL` exactly once. `CSV.pm:27` defines
`sub new`, and defines `newSQL` nowhere.

**It does not inherit.** `DBM.pm:20-21` is the pattern the four live drivers
follow:

```perl
#Inherit from the base class
require iDatabase::Driver::Base;
@ISA = qw(iDatabase::Driver::Base);
```

Each of the four mentions `@ISA` twice -- once in its `use vars`, once to assign
it. `CSV.pm` never mentions `@ISA` at all, so even a corrected `CSV.pm` would
inherit none of the shared `Base.pm` machinery. It instead carries its own
private copies of `load_cfg`, `encode_record` and `decode_record`
(`CSV.pm:813-870`), which is why those routines exist twice in the tree.

**And the search layer has no counterpart for it.** `Sources/Search/API/`
contains `api_DBM.pm`, `api_mySQL.pm`, `api_pgSQL.pm`, `api_Oracle.pm` and
`api_global.pm`. There is no `api_CSV.pm`. Even with the driver repaired,
searching a CSV-backed board would have nothing to dispatch to.

**Finally, it does not compile.** `CSV.pm:471`, inside `sub delete`, is missing a
closing brace, and `@keys` is never declared in a file that opens with `use
strict` at line 2:

```perl
        for (@ids) {
            push @keys, $_->{$obj->{'cur_p_key'};
        }
```

Verified against Perl 5.26.2:

```
$ perl -c Driver/CSV.pm
syntax error at Driver/CSV.pm line 472, near "}"
Global symbol "@keys" requires explicit package name (did you forget to declare "my @keys"?) at Driver/CSV.pm line 475.
Global symbol "@keys" requires explicit package name (did you forget to declare "my @keys"?) at Driver/CSV.pm line 478.
Missing right curly or square bracket at Driver/CSV.pm line 970, at end of line
Driver/CSV.pm had compilation errors.
```

The other four compile cleanly once their external dependencies (`DBI`,
`AnyDBM_File`) are satisfied; `Base.pm` and `SQL.pm` compile as shipped.

`CSV.pm`'s own header dates it:

```perl
################################################################
#
# iDatabase v1.0 (May 2001)
#
# Developed for Ikonboard.
# Author: Matthew Mecham <matt@ikonboard.com>
#
# Accessor methods to databases
#
# CSV: Interface to text files.
#
################################################################
```

It is the Ikonboard 3.0 storage engine, written before the
`iDatabase::Driver::` interface and its `newSQL`/`@ISA`/`Base.pm` conventions
existed, carried forward in the tarball and never updated or removed. Nothing in
the shipped product can invoke it, so **no Ikonboard 3.1.1 board has ever
written a CSV-format table.** A reader browsing the source tree should not be
misled by its presence, its size, or the fact that it is alphabetically first.

It is nonetheless the most valuable file in the directory for an archivist,
because it is a preserved snapshot of how Ikonboard stored data one major
version earlier -- and because it is the clearest statement of the record format
anywhere in the product.

Two of its routines -- `encode_record` and `decode_record` -- were carried into
`Base.pm` almost verbatim, and it is from `Base.pm` that the DBM driver inherits
them. The `|^|` record format documented throughout this chapter is therefore
genuinely shared between the two, and the small differences between the two
copies are the subject of section 5.9.3.

Had `CSV.pm` been reachable, it would have produced: one directory per table under
`Database/`, an optional `DBID` sub-directory inside that, and files named
`file.cgi`, `file-<ID>.cgi`, `file-<ID>.cnt.cgi` (the auto-increment counter) and
`file-<ID>.bak.cgi` (the previous generation, left behind by every write and used
by `rollback`, `CSV.pm:666-693`). Each record would be one line, fields joined
by `|^|`, terminated by `\n`.

#### 3.2 `DBM.pm` -- the real flat-file backend

This is what a zero-dependency Ikonboard 3.1.1 install actually runs, and it is
pre-selected in the installer.

**Which DBM.** `DBM.pm:16-17` sets the preference order explicitly before
loading `AnyDBM_File`:

```perl
BEGIN { @AnyDBM_File::ISA = qw(DB_File GDBM_File NDBM_File SDBM_File) }
use AnyDBM_File;
```

Berkeley DB first, then GDBM, then NDBM, then SDBM as the guaranteed-present
fallback. **The concrete format of a recovered file therefore depends on which
of these was compiled into the Perl on the original server, and the file itself
is the only evidence.** All subsequent `tie` calls use
`$AnyDBM_File::ISA[0]` -- the first entry in the list, not the first one that
loaded -- so on a host with `DB_File` available every file is Berkeley DB. In
practice, boards on shared hosting of the era very often landed on SDBM, which
is why the code checks for both `$file` and `$file.pag` throughout
(`DBM.pm:120`, `208`, `449`, `555`, `657`, `684`): SDBM stores a pair of
`.dir`/`.pag` files beside the requested name, while DB_File and GDBM produce a
single file.

**Paths.** Every access builds the filename the same way (`DBM.pm:119`, `207`,
`361`, `448`, `554`, `656`, `683`, `772`):

```perl
	my $file = $obj->{'base_dir'} . "$IN->{'TABLE'}/$IN->{'DBID'}$IN->{'TABLE'}$IN->{'ID'}.db";
```

with `DBID` already suffixed with `/` and `ID` already prefixed with `-`. So:

| Table shape | Path |
|-------------|------|
| plain | `Database/member_profiles/member_profiles.db` |
| `ID` only | `Database/forum_topics/forum_topics-5.db` |
| `ID` + `DBID` | `Database/forum_posts/f5/forum_posts-1234.db` |
| counter | `Database/forum_topics/forum_topics-5.cnt.db` |
| index | `Database/member_profiles/MEMBER_NAME.idx` |

The `.cnt.db` counter files are **plain text** despite the extension --
`DBM.pm:378-381` opens them with `open`/`print`, not `tie`, and writes a bare
decimal integer with no newline.

**Key and value.** The DBM hash key is the primary key value, and the hash value
is the whole record encoded as one `|^|`-joined string (`DBM.pm:364-370`):

```perl
	my $entry = $obj->encode_record($IN->{'VALUES'});
	...
    $DB{ $IN->{'VALUES'}->{ $obj->{'cur_p_key'} } } = $entry;
```

`encode_record`/`decode_record` are inherited from `Base.pm:228-260`. There is
no per-column storage: a DBM record is a single opaque string that must be split
on `|^|` and positionally mapped through the `.cfg`. **The DBM file contains no
column names.** Without the matching `.cfg` a recovered `.db` file is an
unlabeled list of fields.

**Reads are full scans.** `DBM.pm:246-258` iterates the entire hash with `each`
and evaluates a dynamically compiled `Check` sub against every record:

```perl
	while (($k, $v) = each(%DB)) {
		my $data = $obj->decode_record($v);
		if ($obj->Check($data)) {
```

The `Check` sub is built by string-substituting column names into the caller's
`WHERE` clause and `eval`-ing it (`DBM.pm:214-241`). This is why the partitioning
scheme matters so much: without `ID`/`DBID`, every topic listing would scan every
post on the board.

**Deletion removes the file.** `DBM.pm:474-490` counts the surviving keys after a
delete and unlinks both the data file and its counter if none remain. A recovered
board is therefore missing files for every topic whose posts were all deleted --
absence of `forum_posts/f3/forum_posts-88.db` does not mean topic 88 never
existed.

**The notice log.** `DBM.pm:599-629` appends a human-readable line to
`Database/Notice-Log` on every key deletion (and `Database/Error-Log` on errors),
excluding the tables in `@r_IGNORE`:

```
[Notice] 4:07 33 pm on Aug. 13, 2002 Deleted Key (Table: forum_topics, DBID:, ID:-5) Primary Key: TOPIC_ID. DBM Key: 42
```

On a recovered board these two files are a plain-text audit trail of every
deletion, and they survive independently of the data.

**Backup format.** `DBM.pm:826-948` exports each table to a single text file
`<DESTINATION>/<table>.txt`, one record per line, prefixed with the partition it
came from:

```
DBID-ID|*|<record encoded with |^|>
```

`table_import` (`DBM.pm:719-822`) reads it back, filtering on the `|*|` prefix and
recovering the DBM key from the first field with `/^([\d\w\-\+]+)\|\^\|/`. If you
find a directory of `<table>.txt` files next to a dead board, this is what they
are, and the `|*|` prefix tells you which forum and topic each row belongs to.

**Locking.** `DBM.pm:952-971` creates `Database/Temp/<table>.lck` around each
operation. The wait loop is broken: it tests `-e $file` against the *bare*
filename while the lock is created at `$obj->{'base_dir'}.'Temp/'.$file`, so it
never observes an existing lock and never waits. Combined with the shared-lock
read of the counter file, this is the mechanism behind lost posts on busy boards.

#### 3.3 `mySQL.pm`, `pgSQL.pm`, `Oracle.pm`

All three are DBI drivers inheriting from `Base.pm`, and all three store data as
ordinary rows in ordinary tables -- no `|^|` encoding, no partitioning, one table
per declaration. `create_table` on the SQL backends is a no-op:
`ikonboard.cgi:286-287` and `Sources/Admin/dbHandler.pm:175-176` both gate table
creation and dropping on the driver being DBM:

```perl
my $create = $iB::INFO->{DB_DRIVER} eq 'DBM' ? 1 : 0;
my $drop   = $iB::INFO->{DB_DRIVER} eq 'DBM' ? 1 : 0;
```

SQL tables are created instead by running one of the hand-written DDL files in
`INSTALL_DATA/` -- `mysql_schema.txt`, `postgres_schema.txt`, `oracle_schema.txt`
-- which is precisely why they can drift (section 5.4).

Table names are prefixed. `SQL.pm:42` stores `DB_PREFIX` and the drivers
interpolate it (`mySQL.pm:185`):

```perl
    $db_query .=' FROM '.$obj->{'PREFIX'}.$IN->{'TABLE'}.' WHERE '.$obj->{'cur_p_key'}.'='.$DB->quote($IN->{'KEY'});
```

The shipped DDL uses the prefix `ib_`, so `forum_posts` becomes `ib_forum_posts`.
The `DBID`/`ID` partitioning is discarded entirely -- `CSV.pm:60-61` anticipated
"`f0_forum_posts`" as a table name, but no such thing is generated; all posts for
all forums live in one `ib_forum_posts` table, and `FORUM_ID`/`TOPIC_ID` become
ordinary indexed columns. **This means `POST_ID` and `TOPIC_ID` are globally
unique on an SQL board and only partition-unique on a DBM board.** Any tool that
migrates between them has to renumber.

`WHERE` clauses are Perl-flavored and get rewritten per driver.
`Base.pm:44-63` provides the default translation:

```perl
    my %ops = ( 'eq' => '=',
                'ne' => '<>',
                '==' => '=',
                '!=' => '<>',
                '=~' => 'REGEXP',
                '!~' => 'NOT REGEXP'
              );
```

Note `=~` maps to `REGEXP`, which is MySQL-specific; `pgSQL.pm` and `Oracle.pm`
override `parse_where` accordingly. `Base.pm:73-84` similarly provides a
MySQL-shaped `LIMIT`, with the comment "pgSQL needs to use it the other way
around for some surreal reason."

**Column name case is normalized on read -- mostly.** `Base.pm:24-36`:

```perl
sub make_hash_ref {
    # Routine written by Nurlan ("infection")
	my ($obj, $sth) = @_;
	return undef unless($sth);
    my $result = {};
    $result = $sth->fetchrow_hashref;
	return undef unless($result);
	my %ret;
	foreach(keys %{$result}){
		$ret{uc($_)} = $result->{$_};
	}
	return \%ret;
}
```

This exists because PostgreSQL folds unquoted identifiers to lower case, and
`postgres_schema.txt` declares every column in lower case. `pgSQL.pm` routes its
`select` and `query` results through it (`pgSQL.pm:126`, `186`, `242`).
`mySQL.pm` and `Oracle.pm` mostly do not -- `mySQL.pm:191-192` says so:

```perl
    # No need to use the make_hash_ref
    $return    = $sth->fetchrow_hashref();
```

The upshot is that on every SQL backend, hash keys arrive **upper-cased**: via
`make_hash_ref` on PostgreSQL, via the DDL's own casing on MySQL, and via
Oracle's identifier folding. On DBM, by contrast, keys come from `col_name`,
which is the `.cfg` spelling. That difference is harmless for 331 of the 332
columns, and section 5.4.2 is about the one where it is not.

---

### 5.4 Schema drift

The `.cfg` declarations and the three SQL DDL files are maintained separately by
hand. `out_records.txt` section 5.3 attempts to diff them but compares `ib_`-prefixed
SQL names against bare declared names, so it reports every table as missing from
every backend and concludes "0 of 0 shared tables differ." Re-running the
comparison with the prefix stripped and column names compared case-insensitively
gives a much smaller and much more interesting result.

#### 4.1 Summary

All three DDL files define all 29 tables, with the `ib_` prefix, and every table
has the full declared column set. The drift is confined to two column names.

| Backend | Tables | Tables differing | What differs |
|---------|-------:|-----------------:|--------------|
| MySQL | 29 | 1 | `forum_subscriptions.rFULL` spelled `RFULL` |
| PostgreSQL | 29 | 2 | `rFULL` spelled `RFULL`; `member_profiles.LAST_LOG_IN` spelled `LAST_LOG_IB` |
| Oracle | 29 | 1 | `forum_subscriptions.rFULL` spelled `RFULL` |

That is the whole of it -- no missing tables, no missing columns, no extra
columns, and no column-order differences. Whoever maintained the DDL kept it in
step. But both surviving discrepancies are live bugs, not cosmetic ones.

#### 4.2 `rFULL` -- the "email me the full post" preference does nothing on any SQL backend

`forum_subscriptions.cfg:17` declares the column in mixed case:

```perl
                 rFULL         => [9,  'num'   , 1     ],
```

It is the only mixed-case column name in the 332. All three SQL schemas declare
it as `RFULL`.

On DBM this is fine: `decode_record` labels fields from `col_name`, which comes
from the `.cfg`, so the key really is `rFULL`. On every SQL backend the key
arrives upper-cased, for the three different reasons given in section 5.3.3. And
the reader spells it in mixed case (`Sources/Post.pm:1349` and `1369`,
`Sources/Post2.pm:1335` and `1355`):

```perl
        my $message = $Row->{'rFULL'} ? $with : $without;
```

`$Row->{'rFULL'}` is therefore always undefined on MySQL, PostgreSQL and Oracle,
the ternary always takes the `$without` branch, and **subscribers on an SQL board
never receive the full post body in their notification emails, no matter what
they set in their profile.** The write side works -- `Track.pm:108` passes
`rFULL => $full` into an `INSERT`, and all three databases resolve the unquoted
identifier case-insensitively to the `RFULL` column -- so the preference is
faithfully stored and then never read back.

On a DBM board the same feature works correctly. This is the cleanest example in
the product of the same board behaving differently depending on the backend the
operator picked, and it is invisible: nothing errors, nothing is logged, the
emails simply arrive shorter.

#### 4.3 `LAST_LOG_IB` -- a typo that breaks last-visit tracking on PostgreSQL only

`postgres_schema.txt:297`, inside `CREATE TABLE ib_member_profiles`:

```sql
  view_avs               integer,
  last_log_ib  integer,
  last_activity  integer,
```

The declared column (`member_profiles.cfg:41`) is `LAST_LOG_IN`, and so is the
column in `mysql_schema.txt` and `oracle_schema.txt`. `last_log_ib` appears
exactly once in the entire distribution -- in that one line.

The code reads and writes `LAST_LOG_IN` in five places:
`Sources/LogInOut.pm:68`, `Sources/Misc/PMarkers.pm:49`,
`Sources/Sessions.pm:268` and `:454` (both writes), and
`Sources/Sessions.pm:246`/`:432` and `Sources/Profile.pm:795` (reads). On
PostgreSQL every one of those statements references a column that does not exist.

The visible consequences are that the "last visit" marker used to decide which
topics are new is never persisted, and `ProfileView.pm:171`, which renders
`$Profile->{'LAST_LOG_IN'}` directly into the profile page, shows an empty cell.
Note also the mismatched indentation on that line -- two spaces where every
neighbouring line has the schema's aligned column -- which is consistent with a
hand-edit made in a hurry.

#### 4.4 Drift that is not drift

Three differences show up in a naive comparison and are harmless:

* **PostgreSQL lowercases everything.** All 332 column names and all 29 table
  names are lower case in `postgres_schema.txt`. This is deliberate and correct
  for unquoted PostgreSQL identifiers, and `Base.pm::make_hash_ref` exists
  precisely to undo it on read.
* **The `ib_` prefix.** Applied uniformly by all three DDL files and supplied at
  runtime from `DB_PREFIX`.
* **`ib_member_NOTepads`.** `out_records.txt` reports this odd casing for
  PostgreSQL. It is an artifact of that report's own processing, not of the
  schema file.

#### 4.5 What the SQL schemas add that the declarations cannot express

The DDL files carry information that has no representation in the `.cfg` format
at all, which means the DBM backend simply does without it:

* **Explicit indexes.** `mysql_schema.txt` declares named secondary indexes on
  the columns that matter -- for example, in `ib_authorisation`:

  ```sql
    PRIMARY KEY (ID),
    INDEX authorisation_idx1(DATE_ENTERED),
    INDEX authorisation_idx2(MEMBER_ID)
  ```

  The DBM backend has only the two `INDEX` entries declared on
  `member_profiles`, and answers every other query by scanning.
* **`NOT NULL` and `DEFAULT`.** The declarations' "required" flag is never
  enforced at runtime (section 5.1.3); on SQL it becomes a real constraint. A row
  that the DBM backend would accept with an empty required field will be
  rejected by MySQL.
* **Integer widths.** The `.cfg` width is inert outside SQL. On SQL it selects
  `tinyint`/`smallint`/`int`/`bigint`, so `forum_subscriptions.TOPIC_ID`
  (declared `num(5)`) really is narrower than `forum_topics.TOPIC_ID`
  (`num(10)`) on an SQL board, and really is not on a DBM board.

#### 4.6 The consequence, stated plainly

An operator choosing a backend in `install_modules/database.pl` is choosing more
than a storage engine:

| | DBM | MySQL | PostgreSQL | Oracle |
|---|---|---|---|---|
| `TOPIC_ID`/`POST_ID` uniqueness | per partition | global | global | global |
| Required-field enforcement | none | yes | yes | yes |
| Secondary indexes | 2 (member name, email) | as per DDL | as per DDL | as per DDL |
| Full-post subscription emails | works | **broken** | **broken** | **broken** |
| Last-visit tracking | works | works | **broken** | works |
| Deleted-topic files vanish | yes | n/a | n/a | n/a |
| `UPDATE = top` ordering | ignored | ignored | ignored | ignored |

None of these differences is documented anywhere in the product.

---

### 5.5 `ikonboard.conf` and `Boardinfo.cgi`

#### 5.1 Two files, one setting store

`ikonboard.conf` is a plain `KEY = VALUE` template shipped in the distribution
root's `cgi-bin/`. It exists only during installation. The installer reads it,
merges the operator's answers over it, writes the result as a Perl module at
`Data/Boardinfo.cgi`, and then **deletes the template**
(`install_modules/admin.pl:166`):

```perl
	unlink "$iB::INFO->{'IKON_DIR'}ikonboard.conf"; # deleting config file.
```

immediately after writing `install.lock`. That is why the reconstructed tree has
`ikonboard.conf` and no `Boardinfo.cgi`: this board was never installed. The two
copies present -- `teardown/board/cgi-bin/ikonboard.conf` and
`Upload_Files/cgi-bin/ikonboard.conf` -- are byte-identical, 118 lines, one
setting per line.

Everything the running board knows about itself comes from `Boardinfo.cgi`,
loaded once at `ikonboard.cgi:123-124` into the global `$iB::INFO`:

```perl
require "Boardinfo.cgi";
$iB::INFO = Boardinfo->new();
```

#### 5.2 The generated module

`install_modules/functions.pm:433-478` writes it:

```perl
    for my $key (sort { $a cmp $b } keys %{$data}) {
        my $space = " " x (20 - (length($key)));
        $data->{$key} =~ s|!|&#33;|g;
        print FH qq~'$key' $space => q!$data->{ $key }!,\n~;
    }
```

So `Boardinfo.cgi` is a Perl package whose `new` returns a hash reference of
settings, keys sorted alphabetically, values quoted with `q!...!`. Because `!`
is the quote delimiter, every `!` in a value is rewritten to `&#33;` **before**
quoting -- and never rewritten back. A board name containing an exclamation mark
is stored, and displayed, as an HTML entity.

The same routine derives five paths rather than taking them from the form:

```perl
    $data->{'PUBLIC_UPLOAD'} = $data->{'HTML_DIR'}.'uploads';
    $data->{'HTML_DIR'}       = $data->{'HTML_DIR'}.'non-cgi/';
    $data->{'UPLOAD_URL'}     = $data->{'IMAGES_URL'}.'/uploads';
    $data->{'IMAGES_URL'}     = $data->{'IMAGES_URL'}.'/non-cgi';
    $data->{'BACKUP_DIR'}     = $data->{'IKON_DIR'}.'BACK_UP';
```

Note the ordering bug: `PUBLIC_UPLOAD` is built from `HTML_DIR` *before*
`HTML_DIR` has `non-cgi/` appended, and `UPLOAD_URL` from `IMAGES_URL` before
`IMAGES_URL` has `/non-cgi` appended. That is deliberate -- uploads sit beside
`non-cgi`, not inside it -- but it means the four values are order-dependent and
re-running the routine over its own output would produce different paths.

#### 5.3 How the template is parsed

`install_modules/functions.pm:294-316`:

```perl
    my %conf = ();
    for (@data) {
        next if /^#/;
        next if /^\$/;
        next if /^\s+$/;
        /^(\S+)\s*=\s*(.+?)\s*$/;
        my $k = $1;
        my $v = $2 || '';
        $v =~ s!^\s+!!;
        $conf{ $k } = $v;
    }
```

There is a latent hazard here worth recording, because it will bite anyone who
hand-edits a recovered `ikonboard.conf`. The value group `(.+?)` requires **at
least one character** after the `=`, and the match result is never checked. 27 of
the 118 shipped settings have empty values -- and every one of them survives
parsing only because the shipped file leaves a trailing space after the `=`:

```
blank lines WITH trailing whitespace after '=': 27
blank lines with NOTHING after '=': 0
```

All 118 keys parse from the file as shipped. Strip those trailing spaces -- which
many editors do automatically on save -- and the regex fails, `$1` and `$2` retain
the *previous* line's values, and the setting silently disappears from the hash
while the preceding one is harmlessly re-stored. The failure mode is a missing
setting with no error message.

#### 5.4 The 118 settings

Values shown are the shipped defaults. "(blank)" marks the 27 settings the
template leaves empty for the installer to fill in.

**Identity and paths**

| Setting | Default | Meaning |
|---------|---------|---------|
| `BOARDNAME` | (blank) | Board title, used in page titles and mail templates. |
| `BOARD_DESC` | (blank) | Board description, rendered by `Universal`. |
| `BOARD_URL` | (blank) | Absolute URL of the `cgi-bin` directory. Interpolated into 23 of the 31 skin views. |
| `HOME_URL` | (blank) | "Back to the site" link target. |
| `HOME_NAME` | (blank) | Label for that link. |
| `IKON_DIR` | (blank) | Filesystem path to `cgi-bin`. Installer appends a trailing slash. |
| `HTML_DIR` | (blank) | Filesystem path; installer rewrites to `<path>/non-cgi/`. |
| `DB_DIR` | (blank) | Filesystem path to `Database/`. Becomes the drivers' `base_dir`. |
| `IMAGES_URL` | (blank) | URL base; installer rewrites to `<url>/non-cgi`. |
| `UPLOAD_URL` | (blank) | Derived: `<IMAGES_URL>/uploads`. |
| `PUBLIC_UPLOAD` | (blank) | Derived: filesystem path attachments are written to. |
| `BACKUP_DIR` | (blank) | Derived: `<IKON_DIR>BACK_UP`. |
| `CGI_EXT` | (blank) | Script extension (`cgi` or `pl`). Interpolated into 23 skin views. |
| `PERL_PATH` | `/usr/bin/perl` | Written into shebang lines by the installer. |
| `COPYRIGHT_INFO` | `2001 Ikonboard.com` | Footer text. **Stale -- see 5.5.** |

**Database**

| Setting | Default | Meaning |
|---------|---------|---------|
| `DB_DRIVER` | (blank) | One of `DBM`, `mySQL`, `pgSQL`, `Oracle`. |
| `DB_NAME` | (blank) | SQL database name. |
| `DB_IP` | (blank) | SQL host. |
| `DB_PORT` | (blank) | SQL port. |
| `DB_USER` | (blank) | SQL user. |
| `DB_PASS` | (blank) | SQL password, stored in plain text in `Boardinfo.cgi`. |
| `FLOCK` | `1` | Whether to use `flock`. Disabled automatically on MacOS and Win95. |

**Registration and authorization**

| Setting | Default | Meaning |
|---------|---------|---------|
| `ALLOW_REGISTER` | `1` | Whether new registrations are accepted. |
| `VALIDATE_REGISTER` | `0` | Require email validation before activation. |
| `VERIFY_MAIL` | `0` | Require the address to be confirmed. |
| `PREVIEW_REG` | `0` | Show a preview step during registration. |
| `AUTHORISE_GROUP` | `1` | `mem_groups.ID` for pending members. |
| `AUTHORISE_PRUNE` | `30` | Days before an unconfirmed `authorisation` row expires. |
| `GUEST_GROUP` | `2` | `mem_groups.ID` for anonymous visitors. |
| `MEMBER_GROUP` | `3` | `mem_groups.ID` assigned on successful registration. |
| `SUPAD_GROUP` | `4` | `mem_groups.ID` treated as super administrator. |
| `SAVED_NAMES` | `guest` | Reserved names nobody may register. |
| `MEMBER_NAME_SP` | `0` | Permit spaces in member names. |
| `FORCE_LOGIN` | `0` | Require login before viewing anything. |
| `SEND_WELCOME` | `0` | Mail the `REG` template on signup. |
| `USER_NOTIFY` | `0` | Mail administrators when someone registers. |

**Sessions and security**

| Setting | Default | Meaning |
|---------|---------|---------|
| `COOKIE_ID` | (blank) | Prefix on every cookie name; also the filter at `ikonboard.cgi:189`. |
| `COOKIE_PATH` | (blank) | Cookie path scope. |
| `SESSION_EXPIRATION` | `3000` | Session lifetime in seconds (50 minutes). |
| `CHECK_USER_AGENT` | `1` | Bind a session to the `User-Agent` that created it. |
| `FLOOD_CONTROL` | `25` | Minimum seconds between posts from one member. |
| `B_ONLINE` | `1` | Board online/offline switch. |
| `OFFLINE_MESSAGE` | `We are currently upgrading to Ikonboard 3, please check back later` | Shown while offline. **See 5.5.** |

**Posting and content**

| Setting | Default | Meaning |
|---------|---------|---------|
| `MAX_POST_LENGTH` | `75` | Maximum post length in kilobytes; multiplied by 1024 at `Sources/Moderate.pm:271` and `Sources/NotePad.pm:102`. |
| `MAX_CHARS` | `250` | Longest unbroken run before a forced line break. |
| `MAX_SIG_LENGTH` | `300` | Signature character limit. |
| `MAX_INTEREST_LENGTH` | `300` | Profile "interests" limit. |
| `MAX_LOCATION_LENGTH` | `300` | Profile "location" limit. |
| `MAX_FONT` | `10` | Largest font size selectable in iB-code. |
| `MAX_IMAGES` | `10` | Images permitted per post. |
| `MAX_EMOS` | `5` | Emoticons permitted per post; exceeded, raises `too_many_emoticons`. |
| `ALLOW_IMAGES` | `1` | Permit `[img]`. |
| `ALLOW_DYNAMIC_IMG` | `0` | Permit images from query-string URLs. |
| `ALLOW_FLASH` | `0` | Permit embedded Flash. |
| `MAX_W_FLASH` | `200` | Maximum Flash width. |
| `MAX_H_FLASH` | `400` | Maximum Flash height. Note width defaults smaller than height. |
| `IMG_EXT` | `gif\|jpeg\|jpg\|swf` | Permitted image extensions, pipe-separated. |
| `IMG_ATT_SHOW` | `1` | Render image attachments inline. |
| `EMO_PER_ROW` | `3` | Emoticons per row in the picker. |
| `EMOTICONS` | see 5.6 | The emoticon table. |
| `WORD_FILTER` | `hell:e:\|damn:e:\|asshole:e:` | The censor list. See 5.7. |
| `SKIP_WORDS` | see 5.8 | Search stop-word list. |
| `COMPRESS_HTML` | `0` | Strip whitespace from output. |

**Forums and display**

| Setting | Default | Meaning |
|---------|---------|---------|
| `DISPLAY_MAX_TOPICS` | `15` | Topics per forum page. |
| `DISPLAY_MAX_POSTS` | `10` | Posts per topic page. |
| `HOT_TOPIC` | `15` | Reply count at which a topic gets the "hot" marker. |
| `SORT_KEY` | `TOPIC_LAST_DATE` | Default topic sort column. |
| `TOPIC_SORT_ORDER` | `A-Z` | Default topic sort direction. |
| `FORUM_SORT_ORDER` | `Z-A` | Default forum sort direction. |
| `PRUNE_DAYS` | `30` | Default topic retention. |
| `ICON_TOP_VIEW` | `1` | Show topic icons in the topic view. |
| `ICON_FOR_VIEW` | `1` | Show topic icons in the forum view. |
| `SHOW_BOARD_RULES` | `1` | Display the `forum_rules` text. |
| `SHOW_STATS` | `1` | Display board statistics on the index. |
| `SHOW_ONLINE` | `1` | Display the online-users block. |
| `ALLOW_ONLINE_LIST` | `1` | Permit the full Active Users page. |
| `ALLOW_SEARCH` | `1` | Enable search. |
| `HISTORIC_LIMIT` | `60` | Days of history the search will consider. |
| `REPORT_POST_SUPMOD` | `1` | Route post reports to super moderators. |

**Polls**

| Setting | Default | Meaning |
|---------|---------|---------|
| `ALLOW_POLLS` | `1` | Enable polls. |
| `ALLOW_CREATOR_VOTE` | `1` | Let the poll's author vote in it. |
| `ALLOW_POLL_BUMP` | `1` | Bump a topic when its poll is voted in. |

**Avatars, skins and language**

| Setting | Default | Meaning |
|---------|---------|---------|
| `AVATARS` | `1` | Enable avatars. |
| `AV_ALLOW_URL` | `1` | Permit remote avatar URLs as well as uploads. |
| `AV_DIMS` | `64x64` | Maximum avatar dimensions. |
| `DEF_AV_DIMS` | `64x64` | Dimensions assumed when the real ones are unknown. |
| `AV_EXT` | `gif\|jpeg\|jpg\|swf` | Permitted avatar extensions. |
| `ALLOW_SKINS` | `1` | Let members choose a skin. |
| `SKINS` | `1:Default:Standard Ikonboard Skin` | Skin registry: `id:directory:description`. |
| `LANGUAGES` | `en:English` | Language registry: `code:name`. |
| `CHARSET` | `ISO-8859-1` | Emitted in the `Content-Type` header. |

**Time and date**

| Setting | Default | Meaning |
|---------|---------|---------|
| `BASE_TIME` | `GMT` | Label only; the arithmetic uses `TIME_ZONE`. |
| `TIME_ZONE` | `0` | Server offset in hours, multiplied by 3600. |
| `CLOCK_TYPE` | `24h` | `24h` or `12h`. Selects zero-padding and am/pm. |
| `CLOCK_STYLE` | see 5.9 | The three date formats. |

**Messaging**

| Setting | Default | Meaning |
|---------|---------|---------|
| `MAX_MESSAGES` | `40` | Inbox capacity. |
| `MAX_MSG_SIZE` | `40` | Message size limit. |
| `MSG_PRUNE_DAYS` | `30` | Message retention. |
| `MSG_ALLOW_CODE` | `1` | Permit iB-code in messages. |
| `MSG_ALLOW_HTML` | `0` | Permit raw HTML in messages. |
| `MSG_ALL_MESS_CONT` | `0` | Include full message text in notifications. |

**Email**

| Setting | Default | Meaning |
|---------|---------|---------|
| `ADMIN_EMAIL_IN` | (blank) | Address that receives board mail. |
| `ADMIN_EMAIL_OUT` | (blank) | Envelope sender. |
| `EMAIL_TYPE` | (blank) | `smtp` or `sendmail`. |
| `SEND_MAIL` | (blank) | Path to the `sendmail` binary. |
| `SMTP_SERVER` | (blank) | SMTP host. |
| `EMAIL_CONTENT` | `text` | `text` or `html`. |
| `EMAIL_HEADER` | `Email Generated by Ikonboard<br>` | Prepended to every message -- note the `<br>` even though the default content type is `text`. |
| `EMAIL_FOOTER` | (blank) | Appended to every message. |
| `SIGNATURE` | `The Ikonboard Team` | Fills `<#SIGNATURE#>` in the mail templates. |
| `USE_MAIL_FORM` | `1` | Route member-to-member mail through a form. |
| `INVITE_FRIEND` | `1` | Enable the "invite a friend" feature. |
| `LOG_INVITE` | `1` | Log invitations. |
| `LOG_EMAILS` | `0` | Log all outbound mail. |

(Note that `EMAIL_FRIEND` is a `mem_groups` permission column, not a
configuration setting; only `INVITE_FRIEND` exists in both places.)

**Calendar and SSI**

| Setting | Default | Meaning |
|---------|---------|---------|
| `CALENDAR` | `0` | Enable the calendar. |
| `CALENDAR_SSI` | `0` | Export calendar data as SSI. |
| `CALENDAR_SSI_TIME` | `60` | Export interval, minutes. |
| `HAPPY_BD` | `0` | Show birthday greetings. |

Six settings appear in the template but are read nowhere useful, or are read
under a different name: `MEMBER_GROUP` collides in spirit with the
`mem_groups`-derived `$iB::MEMBER_GROUP` object but is a distinct scalar, and
`DEFAULT_LANGUAGE` -- which `Sources/Lib/FUNC.pm:195` consults to choose a
language directory -- **is not in the template at all**. It only comes into
existence when an administrator saves the Options panel
(`Sources/Admin/Options.pm:216`). Until then `LoadLanguage` falls through to its
hardcoded `'en'`.

#### 5.5 Two stale defaults

**`COPYRIGHT_INFO = 2001 Ikonboard.com`.** The product is copyright 2002 Jarvis
Entertainment Group, Inc., and says so throughout the source. The shipped footer
default names the wrong year and the wrong entity. It is not an isolated slip:
the shipped `index.html` blocking stub carries the same drift, with the year
wrong and the entity right, and an unterminated entity for good measure:

```html
Ikonboard &copy 2001 Jarvis Entertainment Group, Inc.
```

**`OFFLINE_MESSAGE = We are currently upgrading to Ikonboard 3, please check
back later`.** This is the message shown to visitors when an administrator takes
the board offline -- and it ships as the default *inside Ikonboard 3*. It is a
verbatim leftover from the 3.0 beta period, when the people writing this file
were themselves upgrading their own boards to Ikonboard 3. Any 3.1.1 board whose
administrator took it offline without editing the setting told its members it was
upgrading to the version it was already running.

Both are dated fingerprints. Together with the `CSV.pm` header ("May 2001"),
`Data/ib_data_file.dat` (August 2001) and `Data/MemberTitles.pm` (July 2001),
they show how much of 3.1.1 was carried forward untouched from the 3.0 line.

#### 5.6 `EMOTICONS` -- a two-level mini-language

Records are separated by `|&|`; fields within a record by `|`:

```
:)|smile.gif|1|&|:(|sad.gif|1|&|:D|biggrin.gif|1|&| ... :ghostface:|ghostface.gif|0|&|
```

The three fields are **trigger text**, **image filename**, and a
**show-in-picker** flag. `Sources/iTextparser.pm:264-265` parses it in a double
`map`:

```perl
        my %smilies = map  {      $_->[0] => [ $_->[1],  $_->[2] ]       }
                      map  {            [ split (/\|/) ]                 }   ( split (/\|&\|/,$iB::INFO->{'EMOTICONS'}) );
```

Thirteen emoticons ship. The last three -- `:blues:`, `:unclesam:`,
`:ghostface:` -- have the flag set to `0`, so they work when typed but are hidden
from the clickable picker. The string ends with a trailing `|&|`, producing an
empty final record that `next unless $type and $image` discards.

Two details matter. First, **substitution is longest-trigger-first**
(`iTextparser.pm:267`):

```perl
        for my $type ( sort { length($b) <=> length($a) } keys %smilies ) {
```

with the comment "We need to sort by length, otherwise :poke: may be confused
with :p". Second, the trigger is re-escaped before matching, and one of those
escapes is a reversal:

```perl
            $type =~ s/&#124;/\|/g;
```

Because `_clean_value` turns every `|` in user input into `&#124;`, and because
`|` is the field separator in this very setting, a trigger containing a literal
pipe can only be expressed as the entity -- and must be turned back before it can
match text. The `???` trigger for `rock.gif` is a genuine oddity: three question
marks, which will match aggressively in ordinary prose.

#### 5.7 `WORD_FILTER` -- the `:e:` suffix

Entries are separated by `|`, and each entry is a colon-separated triple
`word:method:replacement`:

```
hell:e:|damn:e:|asshole:e:
```

`Sources/iTextparser.pm:310-323` applies them:

```perl
        for (@words) {
            my ($original, $method, $replacement) = split /\:/, $_;
            $replacement = "#" x length($original) unless $replacement;

            if ($method eq 'e') {
                $Txt =~ s!(\A|\b)$original(\b|\Z|\!|\?|\.)!$replacement!ig;
            } else {
                $Txt =~ s!\Q$original\E!$replacement!ig;
            }
        }
```

* method `e` -- **exact**: match on a word boundary, additionally allowing `!`,
  `?` or `.` as the terminator. The pattern is interpolated **unescaped**, so an
  `e`-method entry is a live regular expression.
* method empty (`::`) -- **substring**: `\Q...\E` quoted, so it is literal, and it
  matches inside longer words.
* empty replacement -- the word becomes `#` repeated to its own length. The three
  shipped entries therefore render as `####`, `####` and `#######`.

`Sources/Admin/Options.pm:1494-1507` shows the administrator-facing syntax and
how it compiles to the stored form:

| Typed in the admin panel | Stored |
|--------------------------|--------|
| `{word}` | `word:e:` |
| `word` | `word::` |
| `{word=replacement}` | `word:e:replacement` |
| `word=replacement` | `word::replacement` |

Braces mean "whole word". Because the parser is `split /\:/`, a replacement
containing a colon truncates, and because entries are pipe-separated, no filtered
word may contain a pipe.

#### 5.8 `SKIP_WORDS`

A pipe-separated stop-word list, interpolated directly into a regex at
`Sources/Searchlog.pm:60`, `Sources/Admin/Import.pm:560` and
`Sources/Admin/Convert_ib.pm:953`:

```perl
   $Txt =~ s!\b($iB::INFO->{'SKIP_WORDS'})\b!!ig;
```

The pipe separator is chosen precisely because it is already regex alternation --
the setting *is* the pattern. The shipped list contains 46 entries, three of
which are stored HTML-escaped by `_clean_value`; that is what
`i&#39;ll`, `won&#39;t` and `can&#39;t` are:

```
was|but|any|lets|we|let|left|here|i&#39;ll|i|must|say|some|forget|only|are|as|a|is|not|will|be|and|or|this|that|when|then|it|of|the|should|could|course|cant|wont|won&#39;t|can&#39;t|what|see|to|too|hope|will|because|just
```

This is the single best illustration in the configuration of the escaping rule in
section 5.9: the apostrophes are `&#39;` on disk because the value made a round
trip through a web form. Note also that `will` is the one duplicated entry, and
that `cant`/`wont` appear in bare form while `can&#39;t`/`won&#39;t` appear
separately in escaped form -- the list was accumulated by hand over time rather
than generated.

#### 5.9 `CLOCK_STYLE` -- three formats in one value

Separated by `|&|`, in the order **joined**, **short**, **long**:

```
MONTH_NAME YEAR|&|DATE_NUMBER-MONTH_NUMBER-YEAR|&|MONTH_NAME DATE_NUMBER YEAR,HOUR:MIN
```

`Sources/Lib/FUNC.pm:616-625` splits and applies them:

```perl
	my ($joined, $short, $long) = split /\|&\|/, $iB::INFO->{'CLOCK_STYLE'};

	$r_time = $joined if $IN{'METHOD'} eq 'JOINED';
	$r_time = $short  if $IN{'METHOD'} eq 'SHORT';
	$r_time = $long   if $IN{'METHOD'} eq 'LONG';

	for (keys %Return) {
		$r_time =~ s!$_!$Return{$_}!;
	}
```

The eight substitutable tokens, built at `FUNC.pm:586-598`, are:

| Token | Value |
|-------|-------|
| `YEAR` | Four-digit year. |
| `MONTH_NUMBER` | 1-12, not zero-padded. |
| `MONTH_NAME` | Looked up from `UniversalWords` key `M_<n>`. |
| `DATE_NUMBER` | Day of month, zero-padded to 2. |
| `DAY_NAME` | Looked up from `UniversalWords` key `D_<wday>`. |
| `HOUR` | Zero-padded to 2 **only when `CLOCK_TYPE` is `24h`**. |
| `MIN` | Zero-padded to 2. |
| `SUFFIX` | `am`/`pm`, set **only when `CLOCK_TYPE` is `12h`**. |

Two hazards. The substitution loop iterates `keys %Return` in Perl's hash order
and uses `s!!!` without `/g`, so a token appearing twice in one format string is
replaced only once, and the replacement order is not deterministic. More visibly:
**none of the three shipped formats contains `SUFFIX`.** An administrator who
switches `CLOCK_TYPE` to `12h` without also editing `CLOCK_STYLE` gets hours
rendered 1-12 with no am/pm indication at all -- 3 in the morning and 3 in the
afternoon become indistinguishable across the entire board.

---

### 5.6 The skin format

#### 6.1 Two files per view

A skin is a directory under `Skin/`. The shipped one is `Skin/Default/`, and it
contains 30 `.cfg` files and 29 `.pm` files. Each view exists twice:

* `<View>.cfg` -- the **editable template**, a delimited text format the admin
  control panel parses into form fields.
* `<View>.pm` -- the **compiled module**, a real Perl package of subroutines that
  return interpolated strings. This is what the board actually loads.

The `.cfg` is never read at render time. Changing it by hand has no effect until
it is compiled.

Three files break the pairing: `Menu.cfg` and `gfx_data.cfg` have no `.pm`, and
`Styles.pm` has no `.cfg`. `Menu.cfg` is a template for a module that lives
elsewhere; `gfx_data.cfg` and `Styles.pm` are not templates at all but ordinary
Perl packages (section 5.6.5).

#### 6.2 A matched pair

`Skin/Default/PollView.cfg` is the smallest complete example. Its first line is
the package name; `[=HEADER]` introduces module-level code; each `[=SUB-<name>]`
block introduces one template, subdivided by three `#=` markers. Here is the
`ShowPoll_footer` template as it appears in the `.cfg`:

```
[=SUB-ShowPoll_footer]
#=DESC

#=TOP_LINE
  my $vote_button = shift;


#=BODY
 
                <tr>
                <td bgcolor='$iB::SKIN->{'MISCBACK_TWO'}' align='center' colspan='3'>
                $vote_button
                </td></tr></table>
                </td></tr></table>
                </form>
```

and here is the same template in `Skin/Default/PollView.pm`:

```perl
sub ShowPoll_footer {
  my $vote_button = shift;

return qq~ 
                <tr>
                <td bgcolor='$iB::SKIN->{'MISCBACK_TWO'}' align='center' colspan='3'>
                $vote_button
                </td></tr></table>
                </td></tr></table>
                </form>
~;
}
```

The mapping is mechanical: `#=TOP_LINE` becomes the subroutine prologue,
`#=BODY` becomes the body of a `return qq~...~;`, and `#=DESC` becomes a
one-line description shown above the textarea in the admin panel -- never emitted
into the module. In `Skin/Default` every `#=DESC` is empty, for the reason given
in section 5.6.4.

The four subs in `PollView` do not appear in the same order in the two files.
`.cfg` order is `Render_row_form`, `ShowPoll_header`, `ShowPoll_footer`,
`Render_row_results`; `.pm` order is `ShowPoll_header`, `Render_row_results`,
`Render_row_form`, `ShowPoll_footer`. Both writers iterate an unordered Perl hash
(`SkinControl.pm:1140`, `Tools.pm:346`), so **sub order in either file is
arbitrary and changes on every save**. Do not diff two skins by line order.

Across the skin there are 275 template subs in 31 views.

#### 6.3 The compile step and what `qq~...~` forbids

`Sources/Admin/SkinControl.pm::do_HTML` (lines 1096-1245) writes both files from
one form submission. The core loop:

```perl
		# Sort out the tags...
		$this_sub =~ s!&#60;!<!g;
		$this_sub =~ s!&#62;!>!g;
		# Make tidle's safe
		$this_sub =~ s!~!&#152;!g;
		# Convert $SKIN tags back..
		$this_sub =~ s!<%SKIN:(\w+)%>!\$iB::SKIN->\{'$1'\}!ig;
		# Convert language tags back..
		$this_sub =~ s!<%LANG:(\w+):(\w+)%>!\$$1::lang->\{'$2'\}!ig;
		# Convert $iB::INFO tags back..
		$this_sub =~ s!<%VAR:(\w+)%>!\$iB::INFO->\{'$1'\}!ig;
		# Convert $iB::IN tags back..
		$this_sub =~ s!<%IN:(\w+)%>!\$iB::IN\{'$1'\}!ig;
		# Convert $iB::MEMBER tags back..
		$this_sub =~ s!<%MEMBER:(\w+)%>!\$iB::MEMBER->\{'$1'\}!ig;

		# Remove carriage returns..
		$this_sub =~ s!\r!!g;
		$this_sub =~ s!\f!!g;
		$this_sub =~ s!\b!!g;
		$this_sub =~ s!\e!!g;
		# Remove double spaces..
		$this_sub =~ s!\n\n!\n!g;

		# Append to the config file.
		$config_info .= qq~[=SUB-$name]\n#=DESC\n$this_desc\n#=TOP_LINE\n$this_top\n#=BODY\n$this_sub\n~;
		# Append to our module.
		$module_info .= qq~sub $name {\n\t$this_top\nreturn qq\~\n$this_sub\n\~;\n}\n\n~;
```

**Every one of the 274 template bodies in the skin is quoted `qq~ ... ~`.** That
choice has two consequences.

First, **the tilde is forbidden, and it is destroyed rather than escaped.**
`s!~!&#152;!g` rewrites every tilde in an edited template to `&#152;`. There is
no reverse substitution anywhere in the product -- `152` appears exactly once in
`SkinControl.pm` and nowhere else -- so the original character is not recoverable
through the UI, and `&#152;` is not even a valid representation of a tilde (U+0098
is a C1 control character). A tilde typed into a skin template is silently and
permanently lost.

Second, because `qq` interpolates, **any `$` or `@` in a template body is
evaluated as a Perl variable rather than printed.** That is the entire point --
it is how `$iB::SKIN->{'TITLEBACK'}` works -- but it also means admin-entered
content containing a stray `$` becomes an empty string or a syntax error. This is
the direct reason `ikonboard.cgi::_clean_value` escapes `$` to `&#036;` on every
incoming request (section 5.9.2): stored content is rendered *inside* these
templates, and an unescaped `$` in a post would be interpolated.

Two further behaviors are worth recording. `s!\n\n!\n!g` collapses blank lines
on every save, so templates lose their paragraph spacing progressively the more
often they are edited. And `s!\b!!g` is almost certainly a mistake: in a
substitution pattern `\b` is a zero-width word boundary, not a backspace, so this
line matches at every word boundary and substitutes nothing -- it is a no-op that
was meant to strip `\x08`.

Before writing either file, the compiler validates its own output by writing the
module to `<Module>.pm.txt` and `require`-ing it inside an `eval`
(`SkinControl.pm:1194-1208`). If the `require` dies, both files are left
untouched and the administrator is told the edit was rejected. This is why a
broken skin edit cannot take a board down -- and why `Skin/Default/*.pm` is
guaranteed to be syntactically valid Perl.

#### 6.4 Forensics: which skin files were last touched by which tool

There is a **second** writer that produces `.cfg` files, running in the opposite
direction. `Sources/Admin/Tools.pm:305-362` reconstructs a `.cfg` *from* a
compiled `.pm` by regex:

```perl
        for my $s (keys %subs) {
            $subs{$s} =~ /return qq~(.+?)~;/s;
            # $subr = the HTML
            my $subr = $1;
            $subs{$s} =~ s/return qq~(.+?)~;.+?\}//s;
            # $top = top INFO
            my $top = $subs{$s};
            
            $config .= qq~[=SUB-$s]\n#=DESC\n\n#=TOP_LINE\n$top\n#=BODY\n$subr\n~;
        }
```

The two writers differ in exactly one observable way. `SkinControl.pm:1129`
opens its config with an explicit marker:

```perl
	my $config_info = qq~[=NAME]\n$iB::IN{'PKG'}\n[=HEADER]\n$header\n~;
```

while `Tools.pm:341` writes the bare name with no marker:

```perl
        my $config = qq~$name\n[=HEADER]\n$header\n~;
```

`Tools.pm` also hardcodes `#=DESC\n\n` -- an empty description -- because it has no
way to recover descriptions from a compiled module.

Checking the shipped skin, exactly **three** files begin with `[=NAME]`:
`PrintPageView.cfg`, `RegisterView.cfg` and `TopicView.cfg`. The other 27 begin
with a bare name.

This corroborates the timestamp table in `out_skin.txt` precisely. Those same
three views -- and only those three -- have a `.cfg` and `.pm` with identical
modification times (PrintPageView 07/12/2002 02:08, TopicView 07/12/2002 02:09,
RegisterView 07/13/2002 23:35), which is the signature of `do_HTML` writing both
files in one operation. Every other view has a `.cfg` stamped 07/12/2002 02:06
and a `.pm` stamped 06/27/2002 -- a fifteen-day gap in which the templates were
regenerated from the modules and the modules were not touched.

So the shipped `Skin/Default` was assembled as follows: the modules were built on
06/27/2002; three views were subsequently hand-edited through the live skin
editor; and on 07/12/2002 someone ran the `Tools.pm` rebuild across the whole
skin to regenerate the `.cfg` files from the `.pm` files. **In the shipped
tarball the templates are derived from the modules, which is the reverse of the
runtime relationship.** Two independent signals -- file format and mtime -- agree,
which is about as much certainty as this kind of archaeology allows.

#### 6.5 `Styles.pm` and `gfx_data.cfg`

These two are not templates. Both are plain Perl packages returning a hash from
`new`, and both are written by `$ADMIN->make_module` (`SkinControl.pm:1029` and
`:1036`) rather than by the template compiler.

`gfx_data.cfg` is the **declaration** of every skin graphic: key, description,
filename, and six further slots.

```perl
package gfx_data;

  sub new {
    my $pkg = shift;
    my $obj = {
        'A_FORWARD'             => [ "Forward Topic Button", "t_forward.gif", "1", "0", "", "", "", "",],
        'A_LOCKED_B'            => [ "Locked Topic Button", "t_locked.gif", "1", "0", "", "", "", "",],
```

`Styles.pm` is the **realization** of the same keys as ready-to-interpolate HTML:

```perl
package Styles;

  sub new {
    my $pkg = shift;
    my $obj = {
        'A_FORWARD'             => qq!<img src="$iB::INFO->{'IMAGES_URL'}/Skin/Default/images/t_forward.gif" ...
        'BOARD_LOGO'            => qq!logo.gif!,
```

Note the different quote delimiter: `Styles.pm` uses `qq!...!`, not `qq~...~`,
so in this one file it is the exclamation mark that is forbidden and the tilde
that is safe. `Styles.pm` is also the only `.pm` in the skin with no `.cfg`, and
at 141 lines it is the source of the `$iB::SKIN` hash.

`$iB::SKIN` has a surface of **72 keys** across the skin. The most heavily used
are structural rather than decorative:

| Key | Views using it |
|-----|---------------:|
| `TABLE_BORDER_COL` | 25 |
| `TITLEBACK` | 25 |
| `TABLE_WIDTH` | 24 |
| `MISCBACK_ONE` | 23 |
| `MISCBACK_TWO` | 20 |
| `MISCBACK_TITLE` | 8 |

The long tail -- `B_HOT`, `B_POLL_NN_IN`, `PIN_COL_FOUR`, `M_UNREAD` and the rest
-- appears in one or two views each and corresponds one-to-one with the
`gfx_data.cfg` entries.

Alongside `$iB::SKIN`, templates interpolate 27 `$iB::INFO` keys directly into
markup. `BOARD_URL` and `CGI_EXT` appear in 23 views each, which is why those two
settings cannot be changed after installation without recompiling every skin.

#### 6.6 `Data/SkinList.cfg`

The registry of which views the admin panel will offer for editing. Also a plain
Perl package:

```perl
package SkinList;

sub new {
  my $pkg = shift;
  my $obj = {

  "PostView"    => [ 'Post Screens', "Post screen Elements, Reply Screen Elements. ..."],
  "Universal"   => ['Standard Board Elements', "Guest Bar, Member Bar, ..."],
```

Each value is `[ short title, long description ]`. It contains 28 entries of
which **27 are unique** -- `"Universal"` is declared twice, once near the top and
once near the bottom, with different descriptions. Perl hash semantics apply and
the second wins, so the admin panel shows "Global Board Elements", not "Standard
Board Elements".

More consequentially, `NotePadView` ships with both a `.cfg` and a `.pm` but is
**not listed**. It is a fully functional view -- three subs, 109 lines -- that no
administrator can edit through the control panel. `Menu` and `gfx_data` are also
absent, correctly, since neither is a template.

---

### 5.7 The language format

#### 7.1 Layout

`Languages/<code>/<Area>Words.pm`. Only `en` ships. It contains 29 `Words.pm`
modules plus the standard `.htaccess` and `index.html` blocking stubs.

Each file is a Perl package whose `new` returns a hash reference of
key-to-string mappings. `Languages/en/HelpWords.pm` in full:

```perl
package HelpWords;


sub new {
  my $pkg = shift;
  my $obj = {
   
#+----------------------------------------------------------------------
#| Do Not remove or edit anything above this line!
#| Only Edit the words on the right of the => arrow
#+----------------------------------------------------------------------

page_title      => "Ikonboard Help Files",
help_txt        => "Welcome to the Ikonboard help database.<br><br>Simply choose from one of the titles ...",

submit          => "Search!",
search_txt      => "Enter keywords to search for",
...

#+----------------------------------------------------------------------
#| Do Not remove or edit anything below this line!
#+----------------------------------------------------------------------
  };

  bless $obj, $pkg;
  return $obj;
}




1;

__END__
```

#### 7.2 Namespacing

Keys are namespaced by **file**, not by prefix. `page_title` in `HelpWords.pm`
and `page_title` in `SearchWords.pm` are unrelated. Each module is loaded into a
package-scoped global named for the *consuming* module rather than the language
file, which is assigned at the top of each source file -- for example
`Sources/Help.pm:22`:

```perl
$Help::lang	= $std->LoadLanguage("HelpWords");
```

Templates then reference `$Help::lang->{'page_title'}`. The two names need not
match, and often do not: `Sources/iPoll.pm:40` loads `PostWords` into
`$iPoll::lang`, and `Sources/Massmsend.pm:29` loads `MessengerWords` into
`$Messenger::lang`. A skin template that says `$iPoll::lang->{'poll_s_q'}` is
reading a key out of `PostWords.pm`.

Templates across the skin reference **29 language namespaces**, led by `UserCP`
(125 distinct keys), `ModCP` (115), `Messenger` (78) and `Post` (68).

#### 7.3 `LoadLanguage`

`Sources/Lib/FUNC.pm:184-208`:

```perl
sub	LoadLanguage {
	my ($obj, $area) = @_;
	my ($lang, $default);
	local $@;

	# Make sure the cookie data is legal
	if ($iB::COOKIES->{$iB::INFO->{'COOKIE_ID'}.'lang'}) {
		$iB::COOKIES->{$iB::INFO->{'COOKIE_ID'}.'lang'} =~ s/^([\d\w]+)$/$1/;
	}

	$default = $iB::COOKIES->{$iB::INFO->{'COOKIE_ID'}.'lang'}
			|| $iB::INFO->{'DEFAULT_LANGUAGE'}
			|| 'en';

	# Quick check to make sure the directory exists

	unless (-d $iB::INFO->{IKON_DIR}."Languages/$default") {
		$default = 'en';
	}

	my $code = 'require '. "\"$default/" .$area. '.pm"; $lang ='. $area. '->new();';
	eval $code;

	$obj->cgi_error("Could not access the language file: $@") if $@;
	return $lang;
}
```

Resolution order is cookie, then `DEFAULT_LANGUAGE`, then `'en'`, with a
directory-existence check before use. As noted in section 5.5.4,
`DEFAULT_LANGUAGE` is absent from `ikonboard.conf`, so on a freshly installed
board the middle term is always undefined and the effective default is `en`
until an administrator saves the Options panel.

Two details are worth flagging. The "make sure the cookie data is legal" line is
a substitution that replaces a fully-matching string with itself -- it validates
nothing and modifies nothing; only the subsequent `-d` check constrains the
value. And the module is loaded by building a string and `eval`-ing it, with a
**relative** path (`require "en/HelpWords.pm"`), so `Languages/` must be in
`@INC` for any of it to work.

#### 7.4 Scale and quoting

There are **1,313 strings** across the 29 files.

| File | Strings | File | Strings | File | Strings |
|------|--------:|------|--------:|------|--------:|
| `ErrorWords` | 172 | `UniversalWords` | 47 | `WarnWords` | 12 |
| `UserCPWords` | 157 | `ModerateWords` | 46 | `MailMemberWords` | 11 |
| `ModCPWords` | 154 | `RegisterWords` | 33 | `NotePadWords` | 11 |
| `PostWords` | 129 | `LoginWords` | 28 | `ReportWords` | 11 |
| `MessengerWords` | 64 | `LegendsWords` | 23 | `PrintpageWords` | 10 |
| `ProfileWords` | 62 | `MemberlistWords` | 23 | `ForwardWords` | 9 |
| `ForumWords` | 59 | `LostpassWords` | 21 | `PagerWords` | 8 |
| `SearchWords` | 58 | `OnlineWords` | 21 | `MailFunctionsWords` | 4 |
| `BoardWords` | 57 | `CalendarWords` | 12 | `PostersWords` | 4 |
| `TopicWords` | 55 | `HelpWords` | 12 | | |

Four different quoting styles are in use, which is a reliable signal of how each
file was last edited:

| Style | Count | Written by |
|-------|------:|------------|
| `q!...!` | 801 | The admin panel, via `make_module` |
| `"..."` | 459 | Hand-edited |
| `'...'` | 51 | Hand-edited |
| `qq\|...\|` | 2 | Hand-edited (`MailFunctionsWords.pm` only) |

Files written by the admin panel use `q!...!` throughout and escape `!` to
`&#33;`, exactly as `Boardinfo.cgi` does -- `PostWords.pm` contains
`q!You must enter a parameter for all the values&#33;!`. Files still in their
hand-written form retain the "Do Not remove or edit anything above this line"
banner and use double quotes. `PostWords.pm` (07/13/2002) has been through the
panel; `HelpWords.pm` (06/25/2002) has not.

Substitution tokens inside strings follow the mail-template convention,
`<#TOKEN#>` -- for example `MailFunctionsWords.pm`:

```perl
invite_subject      => "<#MEMBER_NAME#> thought you would like to see this",
```

---

### 5.8 Miscellaneous formats

#### 8.1 `Data/MimeTypes.cfg`

A Perl package mapping MIME type to a three-element array
`[ allowed, icon filename, human label ]`:

```perl
package MimeTypes;
  
  sub new {
    my $pkg = shift;
    my $obj = {
        'application/mac-binhex40'  => [ "1", "stuffit.gif", "Mac Binary",],
        'application/msword'    => [ "1", "word.gif", "MS Word doc",],
        'application/pdf'       => [ "1", "pdf.gif", "PDF Document",],
        'application/x-zip-compressed'  => [ "1", "zip.gif", "ZIP File",],
        'image/gif'             => [ "1", "gif.gif", "GIF Image",],
```

41 lines. `Sources/Post.pm:1087` uses the first element as the upload gate:

```perl
        unless ($mime->{ $mime_type }[0]) {
```

so an upload is accepted only if its browser-declared `Content-Type` is a key in
this file with a true first element. The list is a period piece --
`application/x-compress`, `audio/x-pn-realaudio`, `image/x-MS-bmp` -- and it maps
several distinct types onto `quicktime.gif` as a catch-all icon, including
`image/x-png`, which in 2002 was still the pre-standard PNG type.

#### 8.2 `Data/MemberTitles.pm`

97 bytes, and not a data file at all:

```perl
# Pointless module to keep iB quiet until the last MemberTitle calls have
# been weeded out.

1;
```

A stub whose only job is to return true so that a leftover `require` somewhere
does not fail. Dated 07/19/2001 -- a year before the release. Member titles
themselves live in the `member_titles` table.

#### 8.3 `Data/ib_data_file.dat` -- the obfuscated credits page

20,377 bytes, dated 08/13/2001 -- older than every other file in the tree, and
carrying no hint of its purpose in its name. It looks like this:

```
60.:;;.:;104.:;;.:;116.:;;.:;109.:;;.:;108.:;;.:;62.:;;.:;10
.:;;.:;60.:;;.:;104.:;;.:;101.:;;.:;97.:;;.:;100.:;;.:;62.:;;
```

It is a list of **decimal character codes** separated by the literal delimiter
`.:;;.:;`, wrapped at 60 columns. 2,109 codes, decoding to 2,109 characters of
HTML. The decoder is `Sources/Forum.pm:571-587`, in a subroutine titled
"Security check" and named `do_config_check`:

```perl
sub	do_config_check {
	shift;
	my ($l, $d);
	{
	  local $/ = undef;
	  open FH, $iB::INFO->{'IKON_DIR'}.'Data/ib_data_file.dat';
	  $d = <FH>;
	  close FH;
	}
	print $iB::CGI->header();
	$d =~ s:\s+::g;
	for (split ".:;;.:;",$d) {
		$l .= chr($_);
	}
	print $l;
	iB::exit();
}
```

Neither the filename, the subroutine name, nor the comment describes what it
does. Decoded, the payload is the **Ikonboard 3 credits page**:

```html
<html>
<head>
  <title>Ikonboard 3 Credits</title>
...
 <td class='t'><span class='s'>Ikonboard 3 Development Team</span><br>
			   <br>Lead Programmer & Project Leader: Matthew "Needs to Learn Perl" Mecham
			   <br>iB3 Programmer: Andrew "Woody" Woodward
			   <br>iB3 Programmer: Andy "Yo!" Tomaka
			   <br>
			   <br>Graphics by: Nightwolf
			   <br>Smilies by: Leihu
			   <br>HTML design by: Matt "Needs to Learn Perl" Mecham
 </td>
```

and closes with:

```html
<br><br>And finally thanks to Debbie, for her patience, love and understanding for the last year - I really couldn't have done it
without you.
<br><br><i>Matt Mecham [August 2001]</i>
```

The encoding exists to make the credits inconvenient to remove -- the file is
unreadable in an editor, unsearchable for any of the names it contains, and the
subroutine that renders it is disguised as a security routine. It is not
encryption; it is character-code obfuscation with a distinctive delimiter, and
one line of Python recovers it.

The August 2001 signature also dates the file precisely, and confirms it was
carried forward unchanged from the 3.0 line into the July 2002 release. Note
finally that `split ".:;;.:;", $d` passes the delimiter as a **regular
expression**, in which `.` matches any character. It works only because the
literal delimiter also happens to match its own pattern.

#### 8.4 `INSTALL_DATA/` seed files

Eight data files and five HTML fragments, consumed once by
`install_modules/populate.pl`.

| File | Seeds | Format |
|------|-------|--------|
| `mem_groups.dat` | `mem_groups` | 4 rows, `\|^\|`-delimited, column-shifted (section 5.2.2) |
| `email_template.dat` | `email_templates` | 11 rows, `ID\|^\|TYPE\|^\|TEMPLATE` |
| `ssi_templates.dat` | `ssi_templates` | 8 rows, `ID\|^\|EXPORT_FILENAME\|^\|TEMPLATE` |
| `board_rules.dat` | `forum_rules` | 1 row, `ID\|^\|TITLE\|^\|TEXT` |
| `help.txt` | `help` | `[Title]` headers, body lines beneath |
| `global_template.html` | `templates` (`global`) | raw HTML |
| `register.html` | `templates` (`register`) | raw HTML |
| `news.html` | `ssi_templates` (`news`) | raw HTML |
| `tiker.html` | -- | raw HTML, 132 bytes |
| `mysql_schema.txt` etc. | SQL DDL | `CREATE TABLE` statements |
| `cgi_path.html`, `cgi_url.html`, `non-cgi_path.html`, `non-cgi_url.html` | -- | installer help text |

**These `.dat` files are the only genuine `|^|`-delimited records in the
distribution**, and they are the closest thing to sample data anywhere in the
tree. They demonstrate the delimiter and, importantly, the newline encoding -- a
literal backslash-`n`, never a real line break, since one record is one line:

```
LOST_PASS_TWO|^|t|^|Hi,\n\nThe validation process was successful.\n\nThis email contains your new password. ...
```

They are **not** table dumps. Field order follows the installer's mapping code,
not the declared ordinals -- `ssi_templates.dat` stores `EXPORT_FILENAME` second
while the declaration puts it third, and `mem_groups.dat` is offset by a whole
column. Anyone who finds a `|^|` file must confirm its field order against the
code that writes it rather than assuming the `.cfg`.

#### 8.5 `non-cgi/email/*_plain.txt`

Six plain-text mail templates under `iB_html/non-cgi/email/`, using the same
`<#TOKEN#>` substitution syntax:

```
Hi,

You have been sent this email from <#SENDER_NAME#>.

<#SENDER_NAME#> thought that you might want to take a look at the bulletin board, <#BOARD_NAME#> (<#BOARD_ADDRESS#>).

This email was sent via Ikonboard from <#SENDER_EMAIL#>.

Thanks!

<#SIGNATURE#>
```

**Nothing reads them.** A search of every `.pm`, `.pl` and `.cgi` in the tree
finds no reference to `_plain`. The live templates are the `email_templates`
table rows seeded from `INSTALL_DATA/email_template.dat`, and comparing the two
shows the on-disk files are the older generation: `lostpass_plain.txt` says
"Clicking on the link below WILL reset your password" and offers only
`<#THE_LINK#>`, while the seeded `LOST_PASS_ONE` says "Carrying out the action
below WILL reset your password" and adds the `<#CODE#>` unlock-box flow. These
are 3.0-era artifacts, preserved but dead -- the same situation as `CSV.pm`.

#### 8.6 `.htaccess` and `index.html`

Six `.htaccess` files ship, at `Data/`, `Database/`, `Database/config/`,
`Database/Temp/`, `Languages/` and `Languages/en/`. All six are byte-identical
(39 bytes, one MD5):

```apache
<files "*.*"> 
deny from all 
</files> 
```

This is the **only** protection on the data directories, and it has three
weaknesses worth recording for anyone assessing a recovered board. It requires
`AllowOverride` to be enabled, or it is ignored entirely. The pattern `*.*`
matches only names containing a dot, so a file without an extension is not
covered -- which is exactly the shape of the `Database/Temp/Searches` directory
and the `Notice-Log` / `Error-Log` files that `DBM.pm:626` creates. And it is
Apache-specific; the same tree served by any other web server is wide open.

Alongside each is an `index.html` that returns a decoy 403 page:

```html
<h1>403: Ikonboard -> Forbidden</h1>
...
Ikonboard &copy 2001 Jarvis Entertainment Group, Inc.
```

carrying the same stale 2001 copyright discussed in section 5.5.5, and an
unterminated `&copy` entity.

---

### 5.9 Reading the data without Ikonboard

This section is for someone holding the `Database/` directory of a board that
died fifteen years ago and no longer has a Perl that can run the software.

#### 9.1 Procedure

**Step 1 -- establish the backend.** If `Database/<table>/` contains `.db` files
(possibly with `.dir`/`.pag` companions), it is DBM. If it contains
`file.cgi` / `file-N.cgi`, someone was running Ikonboard 3.0 or a patched
`CSV.pm`, not stock 3.1.1 (section 5.3.1). If neither, the board was on SQL and
`Database/` holds only the `.cfg` files -- the data is in a database dump
elsewhere.

**Step 2 -- read the declaration.** Parse `Database/config/<table>.cfg` for the
`%{$COLS}` block. Accept quoted **and** bare column names, quoted **and** bare
widths, negative widths, and entries with a trailing comma before the `]`. On
duplicate names, last wins.

**Step 3 -- order the columns.** Sort by the declared ordinal and pack densely
into positions `0 .. n-1`. Do **not** index by the ordinal itself
(section 5.1.4).

**Step 4 -- enumerate the files.** For a table declaring `ID`, expect
`<table>-<idvalue>.db`; with `DBID`, expect `<dbid>/<table>-<idvalue>.db`. The
`DBID` for `forum_posts` is the string `f` followed by the forum ID. Remember
that files are deleted when their last record is removed, so gaps are normal.

**Step 5 -- open the DBM file** with a library matching whichever of DB_File,
GDBM, NDBM or SDBM the original host used. Keys are primary key values; values
are whole records.

**Step 6 -- split each value on the literal string `|^|`** and zip it against the
ordered column list.

**Step 7 -- reverse the escaping** (section 5.9.2).

A minimal Python decoder for step 6, once you have `cols` in wire order:

```python
def decode(record, cols):
    fields = record.split('|^|')
    fields += [''] * (len(cols) - len(fields))   # tolerate short records
    return dict(zip(cols, (f.strip() for f in fields)))
```

#### 9.2 Reversing `_clean_value`

Every value that entered the board through an HTTP request was passed through
`ikonboard.cgi::_clean_value` (line 175) **before** it reached the database, so
stored data is pre-escaped. There is no second escaping pass at render time; the
board writes these strings into HTML as-is.

```perl
sub _clean_value {
    my $Tmp = shift;
    return '' unless defined $Tmp;
    $Tmp =~ s|&|&amp;|g;
    $Tmp =~ s|<!--|&#60;&#33;--|g; $Tmp =~ s|-->|--&#62;|g;
    $Tmp =~ s|<script|&#60;script|ig;
    $Tmp =~ s|>|&gt;|g;
    $Tmp =~ s|<|&lt;|g;
    $Tmp =~ s|"|&quot;|g;
    $Tmp =~ s!^\s+!!;
    $Tmp =~ s!\s+$!!;
    $Tmp =~ s|  | &nbsp;|g;
    $Tmp =~ s!\|!&#124;!g;
    $Tmp =~ s|\n|<br>|g;
    $Tmp =~ s|\$|&#036;|g;
    $Tmp =~ s|\r||g;
    $Tmp =~ s|\_\_(.+?)\_\_||g;
    $Tmp =~ s|\\|&#92;|g;
    $Tmp =~ s|!|&#33;|g;
    $Tmp =~ s|\'|&#39;|g;
    return $Tmp;
}
```

To undo it, apply the inverse **in reverse order**, leaving `&amp;` until last --
otherwise a post containing the literal text `&amp;quot;` will be corrupted:

| Order | On disk | Restore to | Notes |
|------:|---------|------------|-------|
| 1 | `&#39;` | `'` | |
| 2 | `&#33;` | `!` | Also used by `q!...!` quoting in generated modules. |
| 3 | `&#92;` | `\` | |
| 4 | `&#036;` | `$` | Three digits, zero-padded. |
| 5 | `<br>` | newline | Ambiguous -- see below. |
| 6 | `&#124;` | `\|` | **This is what makes the delimiter safe.** |
| 7 | ` &nbsp;` | two spaces | Note the leading space in both. |
| 8 | `&quot;` | `"` | |
| 9 | `&lt;` | `<` | |
| 10 | `&gt;` | `>` | |
| 11 | `&#60;&#33;--` / `--&#62;` | `<!--` / `-->` | Handle before the generic `&#33;` rule if you want exact fidelity. |
| 12 | `&amp;` | `&` | **Last.** |

Five warnings.

**`|` is why the format works.** `_clean_value` converts every pipe in user input
to `&#124;` before storage, which is the only reason a `|^|`-delimited format can
safely hold arbitrary post text. Any pipe you find in a stored field arrived by
some path other than a web request -- an import, a converter, or a hand edit --
and may indicate a corrupted record.

**`$` is why templates work.** The escape to `&#036;` exists because post bodies
are interpolated inside `qq~...~` skin templates (section 5.6.3). Leave it escaped
and the text renders correctly in a browser; unescape it and you have the
author's literal text.

**`<br>` is lossy.** A newline typed by the author and the literal text `<br>`
typed by the author are indistinguishable after storage. There is no way to
recover which it was.

**`__text__` is destroyed.** `s|\_\_(.+?)\_\_||g` deletes everything between
double underscores, non-greedily, including the underscores. A post reading
`use __init__ carefully` was stored as `use  carefully` -- the content is gone,
not escaped, and cannot be recovered. The same deletion is applied to parameter
*names* by `_clean_key`.

**Trimming is silent.** Leading and trailing whitespace is stripped on input by
`_clean_value`, again on write by `encode_record`, and a third time on read by
`decode_record`. Deliberate indentation at the start of a post never survived.

#### 9.3 Reversing `encode_record`

`Base.pm:248-260` is the writer used by the DBM backend:

```perl
sub encode_record {
    my ($obj, $values) = @_;
    my ($return, $cnt);
    for my $i (0 .. $obj->{'total_cols'}) {
        $values->{$obj->{'col_name'}->[$i]}  =~ s!^\s+!!g;
        $values->{$obj->{'col_name'}->[$i]}  =~ s!\s+$!!g;
        $values->{$obj->{'col_name'}->[$i]}  =~ s!\r!!g;
        $values->{$obj->{'col_name'}->[$i]}  =~ s!\n!\\n!g;
        $return .= $values->{$obj->{'col_name'}->[$i]}."|^|";
    }
  $return =~ s!\Q|^|\E\Z!!g;
  return $return;
}
```

So on disk: fields joined by `|^|`, carriage returns removed, **real newlines
stored as the two characters `\` `n`**, and -- because of the final substitution --
**no trailing delimiter**. That last line is the one difference between this copy
and `CSV.pm:834-845`, which is otherwise identical and would have left the
trailing `|^|` in place. If you are looking at data that might come from either,
a trailing delimiter tells you which.

The reader, `Base.pm:228-242`, is where the surprises are:

```perl
sub decode_record {
    my ($obj, $record) = @_;
    my $return = {};
    chomp $record;
    my @Tmp = split (/\Q|^|\E/, $record);
    for my $i (0 .. $obj->{'total_cols'}) {
        $Tmp[$i] =~ s!^\s+!!g;
        $Tmp[$i] =~ s!\s+$!!g;
        $Tmp[$i] =~ s!\$!&#36!g;
        $Tmp[$i] =~ s!\\n!\n!g;
        $return->{ $obj->{'col_name'}->[$i] } = $Tmp[$i];
        
    }
    return $return;
}
```

Three things to know.

**`\n` becomes a real newline on read.** So the round trip is
newline -> `\n` -> newline, and a field's stored form uses the two-character
sequence. When decoding by hand, replace `\n` with a newline; a field containing
a literal backslash followed by `n` is indistinguishable from one containing a
newline, but since `_clean_value` converts real newlines to `<br>` before storage
anyway, in practice `\n` appears mainly in data that bypassed the web layer --
the `INSTALL_DATA/*.dat` seed files being the visible example.

**`s!\$!&#36!g` is a bug -- do not replicate it.** It rewrites any literal `$` in
stored data to `&#36`, **without the closing semicolon**, producing a malformed
entity that renders as the literal text `&#36` in a browser. Because
`_clean_value` already turned web-submitted `$` into `&#036;`, this only fires on
data that arrived by another route -- imports, converters, hand edits -- but when
it fires it corrupts. A recovery tool should leave `$` alone.

**Perl's `split` drops trailing empty fields; most other languages do not.** With
no `LIMIT` argument, `split` discards trailing empty strings, so a Perl record
whose last three fields are empty yields a short list and the loop assigns
`undef`. Python's `str.split` keeps them. Since `encode_record` emits every field
and strips exactly one trailing delimiter, the counts work out to the same
number of fields either way -- but always pad a short list rather than assuming a
fixed length, because a truncated or hand-edited record will otherwise
mis-align every field after the break.

#### 9.4 Cross-referencing a recovered board

* `forum_posts.AUTHOR`, `forum_topics.TOPIC_STARTER` and
  `active_sessions.MEMBER_ID` hold member **IDs**, joining to
  `member_profiles.MEMBER_ID`.
* The `_N` columns (`TOPIC_STARTER_N`, `TOPIC_LASTP_N`,
  `FORUM_LAST_POSTER_N`) hold display **names** frozen at write time. Use them
  to recover the names of members whose profile rows are missing.
* `forum_info.CATEGORY` joins to `categories.CAT_ID`;
  `member_profiles.MEMBER_GROUP` to `mem_groups.ID`;
  `forum_posts.ATTACH_ID` to `attachments.ID`.
* **`TOPIC_ID` and `POST_ID` are unique only within their partition** on a DBM
  board. Key topics on `(FORUM_ID, TOPIC_ID)` and posts on
  `(FORUM_ID, TOPIC_ID, POST_ID)`.
* Counter files (`*.cnt.db`) give the high-water mark for each partition, which
  reveals how many records once existed even where the records are gone.

#### 9.5 Where to look for data that is not in the obvious place

A board's `forum_posts` files are not the only copies of its content:

| Source | Contains |
|--------|----------|
| `search_log` | Full text of posts that matched a search, including posts since deleted. |
| `member_notepads` | Unsent private messages (`SAVED_M`) and unsubmitted post drafts (`SAVED_P`). |
| `mod_posts` | Posts that were queued for moderation and never approved. |
| `authorisation` | Name, email and IP of people who began registering and never finished. |
| `Database/Notice-Log` | Timestamped record of every key deletion, table by table. |
| `Database/Error-Log` | Failed updates, including missing-primary-key errors. |
| `<table>.txt` files | A `back_up` export: every record, prefixed `DBID-ID\|*\|`. |
| `*.bak.cgi` | Under `CSV.pm` only, the previous generation of an entire table. |
| `PUBLIC_UPLOAD` directory | Attachments named `post-<FORUM_ID>-<NNNNN>-<original>`, carrying the forum ID and a timestamp fragment even if `attachments` is lost. |

#### 9.6 What cannot be recovered

Stated plainly, so that nobody spends time on it:

* Text between double underscores, deleted on input by `_clean_value`.
* Whether a `<br>` in a post was a typed newline or typed markup.
* Whether whitespace was stripped from the start of a post, and how much.
* Tildes in skin templates, replaced by `&#152;` with no reverse mapping
  (section 5.6.3).
* Poll answers and vote tallies, without also decoding the
  `forum_polls.POLL_ANSWERS` sub-format imposed by `Sources/iPoll.pm` -- it is not
  described in `Database/config/` and this chapter does not document it.
* Which member cast which vote in a poll. `forum_poll_voters` records that a
  person voted, never what for.
* Column names, from a DBM file alone. Without the matching `.cfg` a record is an
  unlabeled list of fields, and this is the single most important reason to
  archive `Database/config/` alongside any board data.

#### 9.7 A note on `_clean_key`

For completeness, request parameter *names* go through a separate filter
(`ikonboard.cgi:506-514`):

```perl
sub _clean_key {
    my $key = shift;
    return '' unless defined $key;
    $key =~ s!\.\.!!g;
    $key =~ s!\_\_(.+?)\_\_!!g;
    &iB::_trim($key);
    $key =~ m!^([\w\.-\_]+)$!;
    return $1;
}
```

The character class `[\w\.-\_]` does not mean what it appears to. `\.-\_` is a
**range** from `.` (0x2E) to `_` (0x5F), so the class admits every character in
that span -- including `:`, `;`, `<`, `=`, `>`, `?`, `@`, `[`, `\`, `]` and `^` --
while a literal hyphen, which was plainly the intent, is **not** matched.
Verified against Perl 5.26.2: `a<b`, `a\b`, `a@b` and `a^b` are all accepted as
parameter names; `a-b` is rejected.

This does not affect stored values, and the `..` strip still blocks the obvious
directory traversal, but it is worth knowing when reading old request logs: a
parameter name containing a backslash or an angle bracket is not evidence of
tampering with the filter, because the filter allowed it.

---

## 6. Security

This chapter is a findings-based security review of Ikonboard 3.1.1, the Perl
CGI bulletin-board package released by Jarvis Entertainment Group, Inc., with
development ending around 07/15/2002. Every claim below was checked against the
reconstructed source tree at `board/cgi-bin/`; line citations are of the form
`file:line`. Where a runtime fact is inferred from code rather than observed on
a live install, that is stated -- the reconstructed tree was **never installed**,
so there is no generated `Boardinfo.cgi`, no `.pwd` key file, and no data.

The goal is to be fair. Ikonboard 3 was a near-total rewrite of the 2.x line and
it fixed real problems. It is judged here against both what was known and
available in 2002 and what is expected today, and a number of things that *look*
like remote code execution turn out, on inspection, to be defended. Those
negative results are reported as carefully as the positive ones -- one of them is
the finding the reader most expects to be a hole.

The severity table in section 6.14 collects everything with a 2002 rating and a
2026 rating side by side.

---

### 6.1 What got better since 2.1.9

Lead with this, because it is the real story of the 3.x rewrite. Ikonboard 2.1.9
(the predecessor, CVE-2001-0841) is remembered for weak password handling and
identity carried in clear in the cookie. Version 3.1.1 changed the architecture
in four concrete, verifiable ways.

#### 1.1 Passwords are hashed, and the scheme is username-keyed

The password hash is computed by `FUNC::Member::MD5` in `Sources/Lib/FUNC.pm`:

```perl
sub	MD5 {
	my $obj = shift;
	my ($Name, $Pass) = @_;
	return unless ($Name or $Pass);
	$Name = lc ($Name);
	my $ctx = Crypt::MD5->new;
	$ctx->add($Pass,$Name);
	return $ctx->hexdigest;
}
```
`Sources/Lib/FUNC.pm:1390`

The stored value is `md5_hex( password . lc(username) )` -- a single MD5 pass over
the password concatenated with the lowercased username. Read exactly, this means:

- **It is hashed, not stored in clear.** That alone is the headline improvement
  over the 2.x reputation.
- **It is *keyed* by the username, but not *salted* in the cryptographic sense.**
  The username is a per-user value mixed into the input, so two users with the
  same password get different hashes and a generic precomputed MD5 rainbow table
  does not apply. But the "salt" is the username: public, low-entropy, and
  attacker-known. There is no random salt and no per-record salt column.
- **It is a single iteration of a deliberately fast hash.** There is no work
  factor, no stretching. MD5 was already showing collision weakness by 2002
  (Dobbertin's work was from the mid-1990s), though preimage resistance -- the
  property that matters for password storage -- was and is intact. The real
  modern objection is speed: MD5 is millions of guesses per second per GPU,
  so offline cracking of a leaked hash set is cheap.

The hash is used consistently: the same `MD5(name, pass)` function is used at
login (`Sources/Sessions.pm:169`), at registration (`Sources/Lib/FUNC.pm:1219`),
on profile password change (`Sources/Profile.pm:608`), on admin-forced reset
(`Sources/Admin/Authorise.pm:322`) and on lost-password reset
(`Sources/UserCP/Lostpass.pm:165`). There is no code path that writes a
plaintext password to the member store.

**Legacy-hash migration.** `Sources/Sessions.pm:193-206` contains a thoughtful
compatibility branch. If the stored hash is shorter than 32 characters -- i.e.
not an MD5 hex digest -- it is treated as a legacy DES `crypt()` hash and verified
that way, then, on success, transparently rewritten to the MD5 form:

```perl
if (length($this_member->{'MEMBER_PASSWORD'}) < 32) {
    use Lib::Crypt;
    $pass2 = crypt ($iB::IN{'PassWord'}, lc (substr($iB::IN{'UserName'}, 0, 2 )));
}
if ($pass2 eq $this_member->{'MEMBER_PASSWORD'}) {
    $db->update( TABLE => 'member_profiles', ... VALUES => { MEMBER_PASSWORD => $in_password }, );
    $this_member->{'MEMBER_PASSWORD'} = $in_password;
}
```
`Sources/Sessions.pm:195`

The DES salt is the first two lowercased characters of the username. `Crypt.pm`
(`Sources/Lib/Crypt.pm`) is a pure-Perl DES `crypt()` implementation bundled so
this verification works even where libc `crypt` is unavailable. The design intent
is clear: on-login rehash-on-access, so an upgraded board sheds its old weak
hashes as members return. That is a mature pattern that many contemporaries did
not bother with.

**Verdict.** Against 2002 practice this is squarely reasonable -- raw or
lightly-keyed MD5 was the norm for PHP/Perl forums of the day (phpBB used
unsalted MD5 well into the 2000s). Against 2026 practice it is inadequate: no
random salt, a fast unstretched hash, MD5. But it is not the plaintext of the
predecessor, and the migration path is a genuine piece of care.

#### 1.2 Server-side sessions replace cookie-carried identity

`Sources/Sessions.pm` implements a server-side session store in the
`active_sessions` table. On authentication a row is written keyed by a session id
(`create_session`, `Sources/Sessions.pm:395`), and later requests are resolved by
looking up that id (`get_session`, `Sources/Sessions.pm:365`). The member's
identity, group and password hash live in the server-side row, not in a
client-editable cookie of privileges. The cookie carries only the opaque session
id (plus optional "remember me" material). This is the correct shape and a real
step up from identity-in-cookie designs.

The session lookup also binds the session to the client (see section 6.8): IP is
checked for logged-in sessions (`Sources/Sessions.pm:381`) and User-Agent is
checked when `CHECK_USER_AGENT` is on -- and it is on by default
(`ikonboard.conf:31` = `1`).

#### 1.3 `use strict` in most of the tree

Across 179 Perl files, 133 (74%) carry `use strict`
(`out_subs.txt` section 6.2). The 46 that do not are almost entirely non-logic
files: 30 are `Languages/en/*.pm` word-list data modules, 14 are
`Skin/Default/*View.pm` HTML templates, and the remaining two are vendored
crypto (`Sources/Lib/Crypt.pm`, `Sources/MIME/Base64.pm`) plus a tiny
`Sources/Makelog.pm`. The core request-handling modules -- the dispatcher, the
session code, the database drivers, the post/search/admin handlers -- are all
under `use strict`. Ikonboard 2.x used none. Under `strict`, a mistyped variable
is a compile error instead of a silent `undef` that surfaces later as a blank
page or, worse, a security check that quietly evaluates false. This is a
meaningful baseline-quality improvement.

#### 1.4 Input escaping at the front door, and permission masks

Every request parameter is HTML-escaped before any handler runs (section 6.2), and
authorization is expressed as per-group permission masks (`mem_groups` table,
consulted through `$iB::MEMBER_GROUP->{...}` across 31 files -- `out_taint.txt`
section 6.6) rather than a single is-admin boolean. Both are real structural
improvements. Both also have limits, covered next.

---

### 6.2 The front-door filter

`ikonboard.cgi:175` builds the entire request hash by mapping every incoming
parameter through two sanitizers:

```perl
%iB::IN = map { &iB::_clean_key($_) => &iB::_clean_value($iB::CGI->param($_)) } $iB::CGI->param;
```

`_clean_value` is the board's single, universal input defense
(`ikonboard.cgi:516-537`):

```perl
sub _clean_value {
    my $Tmp = shift;
    return '' unless defined $Tmp;
    $Tmp =~ s|&|&amp;|g;
    $Tmp =~ s|<!--|&#60;&#33;--|g; $Tmp =~ s|-->|--&#62;|g;
    $Tmp =~ s|<script|&#60;script|ig;
    $Tmp =~ s|>|&gt;|g;
    $Tmp =~ s|<|&lt;|g;
    $Tmp =~ s|"|&quot;|g;
    $Tmp =~ s!^\s+!!;
    $Tmp =~ s!\s+$!!;
    $Tmp =~ s|  | &nbsp;|g;
    $Tmp =~ s!\|!&#124;!g;
    $Tmp =~ s|\n|<br>|g;
    $Tmp =~ s|\$|&#036;|g;
    $Tmp =~ s|\r||g;
    $Tmp =~ s|\_\_(.+?)\_\_||g;
    $Tmp =~ s|\\|&#92;|g;
    $Tmp =~ s|!|&#33;|g;
    $Tmp =~ s|\'|&#39;|g;
    return $Tmp;
}
```

**Assessed honestly, this is a good anti-XSS measure for its era.** Applying it
uniformly to *all* parameters at a single choke point -- before any module can
misuse them -- is exactly the right architecture, and it is more disciplined than
the sprinkle-`HTML::Entities`-where-you-remember approach that a lot of 2002 Perl
took. It neutralizes `<`, `>`, `"`, `'`, `&`, backslash, the `<script` token, and
even HTML comment delimiters. For its stated job -- stopping stored/reflected
cross-site scripting in a board where user text is echoed into HTML -- it is
largely effective. Reflected-XSS-by-tag-injection through a normal parameter is
genuinely hard here.

But it has three structural problems.

#### 2.1 It escapes for HTML only

The filter has no notion of any other sink. There is no SQL quoting, no shell
quoting, no filesystem path normalization beyond section 6.3's key handling, and no
escaping for the Perl `eval`/`require`/`do` constructs the codebase is full of
(148 severity-3 sink sites, `out_taint.txt` section 6.2). It converts `'` to
`&#39;` -- which happens to defang a SQL string-literal break *as a side effect* --
but it does not touch `/`, `(`, `)`, `;`, `{`, `}`, or the period, and it does
not strip `..`. Any handler that feeds `%iB::IN` to something other than an HTML
page is on its own. The board's actual SQL safety (section 6.7) and search safety
(section 6.4) come from *other* code, not from this filter.

#### 2.2 It is lossy -- it corrupts the data it protects

Because the filter mutates values in place and stores the mutated form, it
silently damages legitimate content:

- A password containing an apostrophe arrives at the hasher as `&#39;`, so the
  user's real password can never be typed back in cleanly. (The login path
  sidesteps this only because the raw CGI value is re-read in places -- see 2.3.)
- A newline in a post becomes the literal string `<br>` at input time, so the
  stored data is pre-rendered HTML, not text.
- Two consecutive spaces become ` &nbsp;`.
- `$`, `!`, `|`, `\` all become numeric entities in stored data.

This is why the codebase needs the un-escaping layer in the next point: the data
was damaged on the way in and has to be repaired on the way out or on the way to
a non-HTML sink.

#### 2.3 Every un-escape re-opens the hole

Because the stored form is HTML-entity-encoded, any handler that needs the real
bytes must reverse the filter -- and `out_taint.txt` section 6.4 counts **115
un-escaping sites**. Representative examples:

```
Sources/Post.pm         1509  $obj->{'SAVED'}->{'POST'} =~ s!&#39;!'!g;
Sources/iDatabase/Driver/mySQL.pm  966  $_[0] =~ s|&#039;|\'|g;
Sources/Lib/FUNC.pm     442  $Tmp =~ s!&#36!\$!g; $Tmp =~ s/&#124;/\|/g; ...
```

Each reversal turns the entity back into the dangerous literal. Whether that is a
vulnerability depends entirely on what the un-escaped value then touches. The
database drivers un-escape right before handing values to `$DB->quote()`
(`Sources/iDatabase/Driver/mySQL.pm:957-966`), which re-quotes them -- so that
particular reversal is safe. Other reversals feed the text parser
(`Sources/iTextparser.pm`) that emits HTML, which is where BBCode/HTML handling
lives and where any XSS residue would surface. The point for this section is
architectural: **the single front-door filter is only as good as the discipline
of 115 scattered reversals**, and that is a fragile way to hold a security
boundary. A modern design escapes at the sink (context-aware output encoding),
not once at the entrance in a form it then has to keep undoing.

---

### 6.3 The `_clean_key` character-class bug

The companion sanitizer for parameter *names* is `ikonboard.cgi:506-514`:

```perl
sub _clean_key {
    my $key = shift;
    return '' unless defined $key;
    $key =~ s!\.\.!!g;
    $key =~ s!\_\_(.+?)\_\_!!g;
    &iB::_trim($key);
    $key =~ m!^([\w\.-\_]+)$!;
    return $1;
}
```

The intent of `m!^([\w\.-\_]+)$!` is plainly "word characters plus dot,
hyphen and underscore." But inside a character class, `\.-\_` is a **range**,
not three literals: from `.` (0x2E) to `_` (0x5F). I verified the actual admitted
set with Perl:

```
ADMITTED: ./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_abcdefghijklmnopqrstuvwxyz
REJECTED:  !"#$%&'()*+,-`{|}~
```

So the class admits, in addition to what the author wanted, the characters that
sit between `.` and `_` in ASCII: the digits (already covered by `\w`), and then
`:`, `;`, `<`, `=`, `>`, `?`, `@`, `[`, `\`, `]`, `^`. Note two ironies: the
literal hyphen the author was trying to include is **not** matched (it is the
range operator and the range's endpoints are `.` and `_`), and the class now
accepts a backslash and the angle brackets it presumably wanted to keep out of
key names.

**Impact -- assessed precisely, and it is small.** The matched value becomes a
*hash key* in `%iB::IN`, not a path, a query, or a command. The prior lines have
already stripped `..` and `__..__` sequences from the key
(`ikonboard.cgi:509-510`), and the whole key must still match end-to-end, so a
key like `../../etc` is rejected outright (the `/` is outside the range, and the
`..` is gone anyway). The extra admitted characters let an attacker register hash
keys with slightly odd names (`a:b`, `a<b`) -- but handlers read *specific*,
fixed key names (`act`, `f`, `t`, `SKIN`, ...), so a weird extra key simply sits
unread in the hash. There is no place where an arbitrary attacker-named key is
used as a path or code fragment. This is a real bug -- the class does not do what
it looks like -- but it is not, by itself, exploitable.

One second-order note worth stating because it is a latent correctness trap:
`m!...!` without a successful match leaves `$1` **holding its previous value**.
The `return $1` therefore returns a *stale capture from a prior key* when the
current key fails to match (e.g. a key with a space). I confirmed this: feeding
`_clean_key` the sequence `('act', 'has space', 'f')` returns `act, <undef-or-
prior>, f` with the middle key silently mapping to leftover state. It is a
data-integrity hazard rather than a security one, but it is the kind of thing
`use strict` cannot catch and a regex `/x` review would.

---

### 6.4 The DBM search `eval` -- the finding everyone expects to be RCE

This is the finding that looks worst on paper and is the one to get right.
`Sources/Search/API/api_DBM.pm` builds a Perl boolean expression **as a string**
from user search keywords and `eval`s it once per candidate row:

```perl
my $eval = "(";
while ($IN->{KEYWORDS} =~ m!(^|and|or)\s{1,}(\S+?)\s{1,}!isg) {
    if ($2 ne '') {
        my $keyword = $2;
        my $any_op  = $1;
        $keyword = '(^|\s{1,})'.$keyword unless ($keyword =~ /^%/);
        $keyword = $keyword.'($|\s{1,})' unless ($keyword =~ /%$/);
        $keyword =~ s:%:\.\*:g;
        $keyword =~ s:/:\\/:g;               # <-- escapes the regex delimiter
        ...
        $eval .= $any_op."\$row[5] =~ /$keyword/ig";
        ...
    }
}
$eval .= ")";
...
my $code = qq~
        if ($eval) {
            print SEARCH "\$row[7]+\$row[6],";
            ++\$got_results; ++\$got_topics->{ \$row[7] };
        }
     ~;
eval $code;
```
`Sources/Search/API/api_DBM.pm:121-198`

The keyword is interpolated into a regex `/$keyword/ig` inside a string that is
then `eval`ed as Perl. If an attacker could get a bare `/` or a regex embedded-
code construct into `$keyword`, this is arbitrary code execution. So I traced the
full path from `%iB::IN` to the `eval`, recording every transformation.

**The path.** `act=Search` dispatches to `Search::api::Process`
(`ikonboard.cgi:428`), which requires `USE_SEARCH` permission
(`Sources/Search/api.pm:325`) and a defined `CODE` (`:322`). Only `CODE=01`
reaches `do_search` (`:335`), and `do_search` is the *only* caller of
`run_query` in the tree (confirmed by grep). Before calling it, `do_search` runs:

```perl
my $words = Search::API::api_global::keyword_filter($iB::IN{'keywords'});
```
`Sources/Search/api.pm:214`

and `keyword_filter` (`Sources/Search/API/api_global.pm:99-116`) contains:

```perl
$words =~ s![\[\]\(\)\"/':;\|\!\#\{\}\-\+\\\\]!!gs;
```
`Sources/Search/API/api_global.pm:110`

That character class **deletes** `[ ] ( ) " / ' : ; | ! # { } - + \` from the
keywords. So by the time a keyword reaches the `eval`, the parentheses, braces,
slashes, semicolons and backslashes an attacker would need are already gone.

**Three independent defenses, verified.** I ran the real transformation chain
(`_clean_value` -> `keyword_filter` -> the `api_DBM` per-token rewrite) against
several payloads and also tested Perl's runtime behavior:

1. `_clean_value` at the front door escapes `$`, `\`, `'`, `"` (section 6.2).
2. `keyword_filter` strips the regex/eval metacharacters. `hello (?{die})`
   becomes `hello ?die`; `a/x/;system(...)` loses every `/`, `(`, `)`, `;`.
3. Even if a `(?{...})` embedded-code group survived, Perl refuses to run it from
   an **interpolated** pattern without `use re 'eval'`, which this module does
   not enable. My test produced `Eval-group not allowed at runtime`. And
   `api_DBM.pm:134` escapes `/` a second time for good measure.

**Conclusion: not exploitable through the `keywords` parameter.** The construction
is textbook-dangerous -- building Perl from request data and `eval`ing it is
something no one should do -- but in the shipped 3.1.1 code the keyword is scrubbed
of exactly the characters the attack needs, on the only path that reaches the
`eval`, and Perl's own interpolated-pattern protection backs that up. Reported as
a **negative finding**: dangerous by construction, defended in practice. The same
holds for the mySQL search backend (section 6.7).

**On the public 3.1.1 RCE.** This search `eval` is *not* the publicly-reported
remote-code-execution issue. That issue is **CVE-2003-0770** and it lives in a
different `eval` -- the language-file loader -- reached through a cookie that never
passes the front-door filter. That is section 6.5, and it is the critical finding of
this chapter. (The dispatcher's `eval` at `ikonboard.cgi:485-489` is a third
`eval` that also looks like RCE; it is guarded, covered in section 6.9.)

---

### 6.5 CVE-2003-0770 -- the `lang` cookie into `eval` (critical)

`FUNC::STD::LoadLanguage` loads a language word-list module chosen by the user's
`lang` cookie, and it does so by building Perl source and `eval`ing it
(`Sources/Lib/FUNC.pm:183-208`):

```perl
sub	LoadLanguage {
	my ($obj, $area) = @_;
	my ($lang, $default);
	local $@;

	# Make sure the cookie data is legal
	if ($iB::COOKIES->{$iB::INFO->{'COOKIE_ID'}.'lang'}) {
		$iB::COOKIES->{$iB::INFO->{'COOKIE_ID'}.'lang'} =~ s/^([\d\w]+)$/$1/;
	}

	$default = $iB::COOKIES->{$iB::INFO->{'COOKIE_ID'}.'lang'}
			|| $iB::INFO->{'DEFAULT_LANGUAGE'}
			|| 'en';

	# Quick check to make sure the directory exists
	unless (-d $iB::INFO->{IKON_DIR}."Languages/$default") {
		$default = 'en';
	}

	my $code = 'require '. "\"$default/" .$area. '.pm"; $lang ='. $area. '->new();';
	eval $code;

	$obj->cgi_error("Could not access the language file: $@") if $@;
	return $lang;
}
```

`$default` comes straight from the `lang` cookie, `$area` is a fixed string
supplied by the caller (`'UniversalWords'`, `'BoardWords'`, ...). The cookie value
is interpolated into `$code` and `eval`ed. There are three things that must all
fail for this to be exploitable, and all three fail.

**(a) Cookies bypass the front-door filter entirely.** `_clean_value` is applied
only to `%iB::IN` (`ikonboard.cgi:175`). Cookies are read separately and stored
**raw** (`ikonboard.cgi:186-192`):

```perl
@iB::TEMP_COOKIE = $iB::CGI->cookie();
for my $c (@iB::TEMP_COOKIE) {
   next unless $c =~ /^$iB::INFO->{'COOKIE_ID'}/;
   $iB::COOKIES->{$c} = $iB::CGI->cookie($c);
}
```

No escaping, no character filtering. The one XSS/HTML defense the board has does
not touch cookie data at all. (grep confirms `_clean_value` is never applied to
`$iB::COOKIES`.)

**(b) The "make sure the cookie data is legal" line is a no-op.** The intent of
`s/^([\d\w]+)$/$1/` is to strip illegal characters, but it does not: it matches
the whole string against "word characters only" and, on a match, replaces the
string with itself. If the cookie contains *any* character outside `[\w]`, the
anchored pattern simply fails and the substitution does nothing -- the payload
passes through unchanged. I verified this directly:

```
in='en";system("id");"'  ->  out='en";system("id");"'  (changed: NO)
in='en\0;evil'           ->  out='en\0;evil'            (changed: NO)
```

This is precisely the "does not properly cleanse the lang cookie" described in
the advisory. The correct code would have been a *deletion* of illegal characters
(`s/[^\w]//g`), not a match-and-replace-with-self.

**(c) The `-d` directory guard is bypassable with a poison null byte.** The one
remaining obstacle is `unless (-d "Languages/$default") { $default = 'en' }`. A
naive payload like `en";system(...)` fails this check -- `Languages/en";system(...)`
is not a directory, so `$default` resets to `en`. I confirmed the naive case is
blocked. But this is 2002-era Perl (5.005/5.6), and a filename passed to a syscall
is truncated at an embedded NUL -- the classic "poison null byte." A cookie value
of `en\0";system(...)` makes the `-d` test stat `Languages/en` (which exists,
passing the guard) while `$default` retains the full payload for the `eval`.
Modern Perl closed this by making a NUL in a pathname fatal; period-accurate Perl
did not. The NUL survives (b) because a string containing `\0` is not `[\w]+`, so
the no-op cleanse leaves it intact.

**Reachability, and why it is pre-authentication.** `LoadLanguage` is not an
obscure corner. `Sources/Lib/FUNC.pm:34` calls it at module load:

```perl
$Universal::lang = $obj->LoadLanguage('UniversalWords');
```

`Lib/FUNC.pm` is `require`d very early (`ikonboard.cgi:202`), before the request
is dispatched and independent of whether the visitor has authenticated. The
attacker controls the cookie, the `$area` argument is fixed, and the `eval` runs
on essentially every request. This is unauthenticated remote code execution in
the security context of the web-server user.

**Identifier.** The public record for this is **CVE-2003-0770**: "FUNC.pm in
IkonBoard 3.1.2a and previous versions does not properly cleanse the 'lang' cookie
when it contains illegal characters, which allows remote attackers to execute
arbitrary code when the cookie is inserted into a Perl 'eval' statement." That is
exactly the code above. The published proofs of concept are **Exploit-DB 22499**
and **22500**. I am not reproducing a working payload here -- the mechanism above
(raw cookie -> no-op cleanse -> null-byte past the `-d` guard -> `eval`) is the
whole of it.

Note this is **not** CVE-2002-0328, which is a different Ikonboard 3.1.1 bug (an
`[img]`-tag XSS, covered in section 6.5.2).

#### 5.1 The disclosure timeline -- worse than a single CVE

The dates are the real story, and they are worth stating plainly:

| Date | Event |
|------|-------|
| 01/26/2003 | Jarvis Entertainment Group notified privately by the reporter |
| 04/02/2003 | Public disclosure by **Nick Cleaton** (vuln-dev); fix status listed as "None available" |
| 04/03/2003 | Follow-up post by Adam Gilmore |
| 09/2003 | Re-reported on Bugtraq -- "IkonBoard 3.1.2a arbitrary command execution" -- still exploitable in the successor release |

Sources: `seclists.org/vuln-dev/2003/Apr/10` (original disclosure) and
`seclists.org/bugtraq/2003/Sep/95` (the September re-report).

The vendor had **over two months** of private notice before the bug went public,
and the bug was still present and exploitable in **3.1.2a** -- the successor
release -- when it was reported again five months after that. A common assumption
is that this vulnerability is what prompted the 3.1.2 release; it did not. 3.1.2a
shipped still carrying it. For an archival reader, that is the more useful fact
than the CVE number: this was not a bug that was quietly fixed in the next
version, it was a bug that survived the next version.

The original advisory cites `Sources/Lib/FUNC.pm` **line 191** for the
`LoadLanguage` cleanse -- in this 3.1.1 tree the same statement sits at
`Sources/Lib/FUNC.pm:190`, a one-line drift between the 3.1.1 and 3.1.2a sources.
The advisory separately flags **line 104** as similar code; that is the subject of
section 6.5.3.

**The fix.** Do not build code from the cookie. Whitelist the language against
the known-installed set (`LANGUAGES` in config already enumerates them), and load
by a fixed dispatch table rather than string-interpolating a `require`/`eval`.
Failing that, actually delete illegal characters (`$default =~ s/[^A-Za-z0-9]//g`)
before use.

#### 5.2 CVE-2002-0328 and CVE-2002-2230 -- `[img]` tag XSS

Two further vulnerabilities are on public record against 3.1.1, both cross-site
scripting through the IB-code `[img]` tag:

- **CVE-2002-0328** -- XSS via JavaScript in an `[img]` tag, originally reported
  against Ikonboard 3.0.1 and also reported against 3.1.1.
- **CVE-2002-2230** -- a variant: XSS via a private message containing a
  `javascript:` URL in an IMG tag where the URL **ends in `.gif` or `.jpg`**.

The relevant code is the `[img]` handler in `Sources/iTextparser.pm:178-204`
(duplicated verbatim in `Sources/iTextparser2.pm:178-204`):

```perl
if ($iB::INFO->{'ALLOW_IMAGES'}) {
    my $url;
      while ($Txt =~ s{\[img\](.+?)\[\/img\]}
        {
     $url = $1;
             ++$obj->{'IMG_CNT'};
             ...
             unless ($iB::INFO->{'ALLOW_DYNAMIC_IMG'}) {
                 $obj->{'ERROR'} = 'no_dynamic' if $url =~ m#[?&;]#;
                 $obj->{'ERROR'} = 'no_dynamic' if $url =~ /javascript(\:|\s)/i;
             }

             if ($iB::INFO->{'IMG_EXT'}) {
                 # We use the "greedy" match .* to match everything up until the furthermost right
                 # period. We hope that this will be the image extension.
                 $url =~ m!^.*\.(\S+)$!ig;
                 my $ext = $1;
                 unless ( grep { lc($ext) eq lc($_) } (split/\|/, $iB::INFO->{'IMG_EXT'}) ) {
                     $obj->{'ERROR'} = 'invalid_ext';
                 }
             }

             qq#<img src="$url" border="0">#;
        }eisgx) {}
```

Reading the shipped code against the two CVE descriptions, the design has four
distinct weaknesses, and they explain the `.gif`/`.jpg` detail in CVE-2002-2230:

1. **The extension check is a suffix test on the whole URL, not a scheme test.**
   It matches to "the furthermost right period" and compares that tail against
   `IMG_EXT` (`gif|jpeg|jpg|swf`). A `javascript:` URL whose text happens to end
   in `.gif` satisfies it. The comment ("We hope that this will be the image
   extension") is candid about the guesswork. Nothing anywhere validates the URL
   *scheme*.
2. **The `javascript:` check is conditional on a config flag.** Both dynamic-URL
   tests run only `unless ($iB::INFO->{'ALLOW_DYNAMIC_IMG'})`. Any board that
   enables dynamic images -- a plausible thing for an admin to want -- turns the
   only `javascript:` filter **off entirely**. The shipped default is
   `ALLOW_DYNAMIC_IMG = 0` (`ikonboard.conf:6`), so the check is on out of the
   box; that is the saving grace.
3. **The scheme blocklist is `javascript` only.** `vbscript:` -- live in Internet
   Explorer of that era -- and `data:` are not tested. The pattern also requires
   `javascript` to be followed immediately by a colon or whitespace, so the usual
   period-typical evasions (embedded encoded characters splitting the token)
   are not accounted for.
4. **Setting `ERROR` does not suppress the emitted tag.** The substitution block
   returns `qq#<img src="$url" border="0">#` unconditionally; the flag is
   advisory. The post-handling code does consult it -- `Sources/Post.pm:79`,
   `:119`, `:342`, `:571`, `:623`, `:672` map `$txt->{'ERROR'}` into `_ERROR` and
   the display path refuses to save when `_ERROR` is set (`Post.pm:705-706`,
   `:739-740`, `:771-772`). So on the *posting* path the flag does gate the save.
   The structural fragility is that the sanitizer and the enforcement live in
   different modules: any consumer of the parser that renders its output without
   checking `ERROR` gets the unsanitized tag. The private-message path named in
   CVE-2002-2230 is precisely a different consumer.

There is also a direct interaction with section 6.2.3's un-escaping problem. `$url`
originates in post text that `_clean_value` escaped (`"` became `&quot;`), but
`Sources/Post.pm:1507-1515` reverses that escaping before storage --
`s!&quot;!"!g` among others. A `"` restored inside `$url` closes the `src`
attribute in `qq#<img src="$url" border="0">#` and permits arbitrary attribute
injection into the tag. This is the concrete case where "the front-door filter is
undone at 115 sites" stops being an abstract architectural complaint and becomes
the enabling condition for a real XSS.

**The fix** for this class is to validate the URL *scheme* against an allowlist
(`http`/`https` only), reject anything else outright rather than flagging it, and
HTML-attribute-encode `$url` at the point of emission instead of relying on
input-time escaping that a later stage undoes.

#### 5.3 FUNC.pm:104 -- the same no-op cleanse, second instance

The original advisory flags a second line of "similar code." In this 3.1.1 tree,
`Sources/Lib/FUNC.pm:102-104`, at the top of `LoadSkin`, is:

```perl
	my $sid = $iB::IN{'sid'} || $iB::COOKIES->{$iB::INFO->{'COOKIE_ID'}.'skin'};
	# Make sure it only contains a number
	$sid =~ s/^(\d+)$/$1/;
```

This is the **identical defective idiom** as the `lang` cleanse: an anchored
match-and-replace-with-itself that removes nothing. If `$sid` contains any
non-digit the pattern simply fails and the value passes through untouched, so the
comment "Make sure it only contains a number" is not true of the code. Confirming
the advisory's observation: it is the same class of bug, written twice.

(The advisory describes line 104 as handling the session-ID cookie. In this tree
the variable is `$sid` = the *skin* id, read from the `sid` parameter or the
`skin` cookie -- the name is easy to read as "session id." The defect is the same
either way.)

**Is it exploitable? No -- and the reason is worth recording.** Unlike `$default`
in `LoadLanguage`, `$sid` never reaches an `eval`. Its only use is a numeric
comparison against skin ids parsed out of the `SKINS` config string
(`Sources/Lib/FUNC.pm:137-143`, `if ($id == $sid)`). The value that goes on to be
interpolated into the skin `eval` is `$Skin`, and `$Skin` is only ever assigned
from configuration -- `DEFAULT_SKIN`, the `$dir` field of a `SKINS` entry, or a
`FORUM_SKINS` capture -- never from the cookie. So although the identical
`-e`-guard-then-`eval` shape is present a few lines later:

```perl
	unless (-e $INFO->{IKON_DIR}."Skin/$Skin/Styles.pm") {
		$Skin = 'Default';
	}
	...
		eval 'require "$INFO->{IKON_DIR}'.'Skin/$Skin/Styles.pm";';
```
`Sources/Lib/FUNC.pm:158`, `:165`

the attacker does not control `$Skin`, and the null-byte trick that defeats the
guard in `LoadLanguage` has nothing to carry here. **Reported as a real bug of the
same class with no exploitable path** -- the ineffective sanitizer is genuine and
the advisory was right to point at it, but the dangerous sink is not reachable
from it.

One adjacent observation while in this code: `Sources/Lib/FUNC.pm:124` interpolates
a request parameter directly into a regular expression --

```perl
			$INFO->{'FORUM_SKINS'} =~ m/(^|\|\&\|)$iB::IN{f}\:(\S+?)\|\&\|/;
```

`_clean_value` does not strip `(`, `)`, `.`, `*`, `+`, `?`, `[`, `]`, `{`, `}`, so
`f` can inject regex metacharacters and perturb the capture-group numbering that
the next line depends on (`if ($2)`). The resulting `$Skin` is still a substring
of the operator's own `FORUM_SKINS` config, not attacker text, so the impact is
limited to skin confusion and potential catastrophic backtracking (ReDoS) rather
than code execution; interpolated `(?{...})` is blocked by Perl as established in
section 6.4. Noted as low severity, but it is the same "untrusted data into a
compiled construct" habit that produces the critical finding.

---

### 6.6 The ARC4 database-password scheme

On first run, `ikonboard.cgi:210-267` sets up an obfuscation layer for the
database password stored in `Boardinfo.cgi`. The DB password is ARC4-encrypted
(`Sources/ARC4.pm`, a standard RC4) and Base64-encoded, and the ARC4 key is the
*filename* of a `.pwd` file dropped in `Data/` -- a file whose contents are its
own path:

```perl
opendir (DIR, $iB::INFO->{'IKON_DIR'}.'Data');
my @list = grep { !/\A\.{1,2}\Z/ } readdir(DIR);
closedir(DIR);
my @key  = grep { /.+?(\.pwd)\Z/ } @list;
unless (scalar @key > 0) {
    my $file = &iB::my_gen_key();
    my $file_name = $iB::INFO->{'IKON_DIR'}.'/Data/' . $file . '.pwd';
    open (KEYF, ">" . $file_name ) or die "...";
    print KEYF $file_name;
    close KEYF;
    chmod ( 0644, $file_name );
    $file = $file . '.pwd';
    my $ark4 = Crypt::ARC4->new($file);
    ...
}
```
`ikonboard.cgi:213`

**What it protects against, and what it does not.** This defends against exactly
one thing: a casual read of `Boardinfo.cgi` alone -- a misconfigured server that
serves the `.cgi` as text, a backup file left in the web root, a paste of the
config into a support forum. In those narrow cases the DB password is not sitting
there in clear.

It provides no protection against an attacker who can read the filesystem,
because everything needed to decrypt is on that same filesystem:

- The ARC4 key is the `.pwd` filename, which is discoverable by listing `Data/`
  (the code itself finds it with `readdir` + a glob).
- The `.pwd` file is created **world-readable, `chmod 0644`** (`ikonboard.cgi:223`),
  so any local user or any traversal/file-read bug reveals it.
- ARC4/RC4 is symmetric and the algorithm is in `Sources/ARC4.pm`; there is no
  secret beyond the filename.

So this is *obfuscation*, not encryption in any meaningful sense -- key and
ciphertext travel together, and the key is world-readable. It raises the bar
against "someone saw the config file" and does nothing against "someone can read
the directory." That is a fair thing to ship in 2002 as long as no one mistakes
it for real key management; the risk is that it reads as protection it does not
provide.

**The key generator's entropy problem.** The 16-character key is produced by
`my_gen_key` (`ikonboard.cgi:269-284`):

```perl
srand (time ^ $$ ^ unpack "%L*", `ps axww | gzip`);
my $Password;
for (my $i = 0; $i < 16; $i++) {
    $Password .= $Chars[ int ( rand ( $#Chars + 1 ) ) ];
}
```
`ikonboard.cgi:278`

Three problems, concretely:

- **A backtick shell invocation for entropy.** ``ps axww | gzip`` shells out to
  gather "randomness" from the gzipped process table. It is fragile (depends on
  `ps`/`gzip` on PATH and the platform), it is a performance and reliability
  hazard, and the process-table snapshot is far more predictable than it looks --
  an attacker on the same host can approximate it.
- **A weak, low-entropy seed.** `time ^ $$ ^ unpack "%L*", ...` XORs the epoch
  second, the PID (~15 bits), and a checksum of that `ps` output. The epoch
  dominates and is known to within seconds; the PID space is small and often
  guessable; the checksum adds little. The effective entropy of the seed is a
  long way below the 16-character key's apparent strength.
- **`rand()` is not a cryptographic RNG.** Perl's `rand` is a linear PRNG; given
  the seed it is fully deterministic. Key strength is bounded by seed entropy, not
  by key length.

In practice the ARC4 key adds little even to its narrow threat model, because the
same seed weakness that undermines session ids (section 6.8) undermines it here.
Since the plaintext being "protected" is only recoverable by someone who can
already read `Data/` anyway, this is a low-severity issue -- but it is a good
illustration of the era's habit of treating `srand`/`rand` and shell tricks as
cryptographic primitives.

---

### 6.7 SQL injection surface

Literal SQL appears in 29 files, and 11 of those are **outside** the
`Sources/iDatabase/` abstraction that is supposed to be the only thing that knows
SQL (`out_fileio.txt` section 6.6). That sounds alarming; the reality is more
mixed, and mostly better than the file count suggests.

**Value binding uses `quote()`.** The mySQL driver routes user *values* through
DBI's `$DB->quote()` for the operations that carry request data -- select-by-key,
insert, update, delete:

```
Sources/iDatabase/Driver/mySQL.pm:185  ... WHERE '.$obj->{'cur_p_key'}.'='.$DB->quote($IN->{'KEY'});
Sources/iDatabase/Driver/mySQL.pm:334  push @values, $DB->quote($IN->{'VALUES'}->{$k});
Sources/iDatabase/Driver/mySQL.pm:437  push @fields, $k.'='.$DB->quote($IN->{'VALUES'}->{$k});
Sources/iDatabase/Driver/mySQL.pm:391  $statement = $obj->{'cur_p_key'}.'='.$DB->quote($IN->{'KEY'});
```

This is the correct, era-appropriate defense: post bodies, usernames, profile
fields -- the bulk of attacker-controlled data -- are quoted before they enter a
statement. `prepare`/`execute` is used throughout. So the primary data-entry
paths are not string-concatenation injection points.

**The gaps are identifiers and raw WHERE clauses.** Two categories are not
quoted:

1. **Identifiers.** Table and column names (`$IN->{'TABLE'}`, `$obj->{'cur_p_key'}`,
   field keys) are concatenated directly (`mySQL.pm:232`, `:352`, `:456`). DBI's
   `quote()` is for values, not identifiers, so this is expected -- but it means
   any caller that lets request data choose a table or column name would inject.
   In the shipped handlers these come from module code, not directly from
   `%iB::IN`, so the practical risk is low.

2. **The WHERE mini-language.** Callers pass WHERE clauses as strings in an
   internal DSL, and `parse_where` (`Sources/iDatabase/Driver/Base.pm:44-63`)
   rewrites the DSL operators into SQL without quoting the value literals:

   ```perl
   $where =~ s#\s{1,}(eq|ne|==|!=|=~)\s{1,}# $ops{$1} #ig;
   $where =~ s#\s{1,}(and|or|&&)\s{1,}# $bln{$1} #ig;
   $where =~ s#/#'#g;                       # convert perl / into single quotes
   return $where;
   ```

   Whatever the caller interpolated into that string reaches SQL as-is. Most
   WHERE strings are built from server-generated values -- e.g.
   `"RUNNING_TIME < $time"` where `$time` is `time()` -- and are safe. The one
   that stands out is session cleanup (`Sources/Sessions.pm:468-471`), which
   embeds the client IP into the clause:

   ```perl
   $IN->{'DELETE'} = qq! or (THIS_IP eq '$iB::IN{'IP_ADDRESS'}' and (MEMBER_ID eq '...' or MEMBER_GROUP == '2'))! if ...;
   $db->delete( TABLE => 'active_sessions', WHERE => "RUNNING_TIME < $iB::INFO->{'SESSION_EXPIRATION'}$IN->{'DELETE'}", ... );
   ```

   `$iB::IN{'IP_ADDRESS'}` is set from the `X-Forwarded-For` / `REMOTE_ADDR`
   headers (`ikonboard.cgi:317`), trimmed to the part before the first comma
   (`ikonboard.cgi:324`), and -- crucially -- it is set directly from `$ENV`, so it
   never passes `_clean_value`. An attacker who controls `X-Forwarded-For` can put
   a single quote in it. For an SQL-backed install this is a plausible injection
   into the session-cleanup `DELETE`; for the DBM/CSV backends the same string
   goes into a per-row `Check` `eval` instead (`Driver/DBM.pm:237`,
   `Driver/CSV.pm:225`). This is the most concrete SQL/eval-injection candidate
   outside the search code, and it is worth flagging -- though it is
   backend-dependent (many IB3 installs ran the flat-file DBM driver, where there
   is no SQL server to injure) and the payload lands in a maintenance `DELETE`
   rather than a data-returning `SELECT`, which limits exfiltration.

**Search.** Both search backends (`api_DBM`, `api_mySQL`) are fed keywords only
after `keyword_filter` strips quotes, slashes, parens and braces (section 6.4). The
mySQL backend additionally escapes `'` again before building `REGEXP '$keyword'`
(`Sources/Search/API/api_mySQL.pm:121`), and the forum-id list is built from
integer IDs pulled from the DB. The search SQL is not injectable through the
keyword.

**Verdict.** Better than the "SQL in 29 files" headline. The bread-and-butter
data paths are `quote()`-parameterized. The exposure is (1) the un-quoted WHERE
DSL wherever a caller interpolates untrusted data -- of which the `X-Forwarded-For`
-> session-cleanup path is the standout -- and (2) the admin SQL client
(`Sources/Admin/SQLclient.pm`), which by design runs operator-supplied SQL and is
covered by the admin gate.

---

### 6.8 Session security

Session handling lives in `Sources/Sessions.pm`.

**ID generation -- weak RNG.** New session ids come from `my_gen_id`
(`Sources/Sessions.pm:478-483`):

```perl
sub	my_gen_id {
	my $obj = shift;
	srand($$|time);
	my $session = int(rand(600000000000));
	return $mem->MD5(unpack("H*", pack("Nnn", time, $$, $session)));
}
```

The id is `md5_hex` of `(time, pid, rand)`. The MD5 wrapper gives a 32-hex-char
opaque token and hides the structure -- a good move for 2002 -- but the *input
entropy* is low. `srand($$|time)` seeds with the bitwise OR of PID and epoch; I
confirmed that value is dominated by the epoch (~2^30) with only a few low bits
from the PID (~2^15), so the seed is essentially "the current second, slightly
perturbed." Given the request time (leaked in `Date` headers and cookie
timestamps) and a guess at the PID, the `rand` output is deterministic and the
whole preimage is brute-forceable in principle. The token is not predictable at a
glance, but it is not backed by real entropy either. A modern implementation
would draw from a CSPRNG (`/dev/urandom`), not `srand`/`rand`.

**Binding to client.** `get_session` (`Sources/Sessions.pm:365-390`) binds a
logged-in session to the originating IP and, optionally, the User-Agent:

```perl
if ($session->{'MEMBER_ID'}) {
    if ($iB::IN{'IP_ADDRESS'} ne $session->{'THIS_IP'}) { return {}; }
    if ($iB::INFO->{'CHECK_USER_AGENT'}) {
        return {} unless $ENV{'HTTP_USER_AGENT'} eq $session->{'USER_AGENT'};
    }
}
```

IP binding is unconditional for authenticated sessions; UA binding is gated on
`CHECK_USER_AGENT`, which defaults to `1` (`ikonboard.conf:31`). This meaningfully
mitigates the weak-id problem: a stolen or guessed id is only usable from the
same IP (and UA). It also has the usual downside -- mobile/proxy users whose IP
rotates get logged out -- but as a security posture it is sound and better than
many contemporaries.

**Expiration.** `clean_sessions` (`Sources/Sessions.pm:460-475`) prunes
`active_sessions` older than `SESSION_EXPIRATION`, configured at 3000 seconds =
50 minutes (`ikonboard.conf:102`). Reasonable.

**Session id in the URL.** The id is propagated as `?s=$iB::SESSION` throughout
the skin templates (e.g. every nav link in `Search/api.pm:284,305`). This is the
well-known "session in URL" weakness: the id leaks via the `Referer` header to
any off-site link, and into browser history, proxy logs and bookmarks. In 2002
this was a widespread and somewhat-accepted practice (cookies were less reliable),
and the IP/UA binding blunts the impact -- a leaked id is only replayable from the
same client address. Still, by any standard it is an information-exposure issue:
the credential is written into places that outlive the session and are read by
third parties. The input side is defensively handled -- `authenticate` strips
non-word characters from `s` (`Sessions.pm:100`, `$iB::IN{'s'} =~ s!\W!!g`).

**Session fixation.** There is no fixation protection. On login, the code adopts
the pre-existing session id rather than minting a fresh one:

```perl
$iB::SESSION = $session_cookie || $iB::IN{'s'} || 0;
...
$iB::SESSION ? $db->update( TABLE => 'active_sessions', KEY => $iB::SESSION, VALUES => { MEMBER_NAME => ..., MEMBER_ID => ..., ... } )
             : $obj->create_session(...);
```
`Sources/Sessions.pm:152`, `:226`

An unauthenticated session id (which an attacker could plant via an `?s=` link)
is upgraded in place to an authenticated one, rather than rotated. The mitigating
factor is again IP binding -- the fixed id only becomes useful to the attacker if
they share the victim's IP -- but the correct behavior is to issue a new id at the
moment of privilege change, and that does not happen here.

---

### 6.9 Authentication and authorization

**The dispatcher `eval` is guarded (a disproven RCE).** `ikonboard.cgi:485-489`
builds Perl source from `%Mode{ $iB::IN{'act'} }` and `eval`s it:

```perl
my $code = 'require '.$Mode{ $iB::IN{'act'} }[0].';'.
           'my $idx = '.$Mode{ $iB::IN{'act'} }[0].'->new();'.
              '$idx->' .$Mode{ $iB::IN{'act'} }[1].'($db);';
eval $code;
```

This looks like request-driven RCE, but the module and method names never come
from the request -- they come from a fixed `%Mode` hash, and any `act` that is not
already a key of that hash is forced to `BoardIdx` two lines earlier:

```perl
$iB::IN{'act'} = 'BoardIdx' if $iB::IN{'act'} eq '';
$iB::IN{'act'} = 'BoardIdx' unless exists $Mode{ $iB::IN{'act'} };
```
`ikonboard.cgi:473-474`

So the `eval` only ever assembles a class/method pair from the hardcoded table.
It is a code-generation shortcut, not an injection point. Stated plainly because
a reader deserves the check: this one is safe.

**Admin authorization is centrally gated, and the gate is real.** Every admin
action funnels through `Admin::Functions::process` (`Sources/Admin/Functions.pm:46`),
which enforces, in order, before dispatching to any handler:

1. logged in -- `unless ($iB::MEMBER->{'MEMBER_ID'})` (`:51`);
2. holds the `ACCESS_CP` group permission -- `unless ($iB::MEMBER_GROUP->{'ACCESS_CP'})`
   (`:60`); a failure is logged, the member's `ALLOW_POST` is set to 0, and an
   error is raised;
3. holds a fresh admin-session token file `Temp/admin-<MEMBER_ID>.cgi` within an
   8-hour window (`:77-102`).

Only after all three does the `%Mode` table (`:104-138`) dispatch to
`cat`/`forum`/`member`/`sqlclient`/`file`/... . The permission is a per-group
mask, consulted centrally rather than re-checked ad hoc in each handler, which is
the right structure. One weak spot: `dologin` (`:152`) is called at `:49`
*before* the `ACCESS_CP` check and writes the admin token after verifying only
that the member is logged in and supplied a `UserName` -- it does not itself
re-verify a password or `ACCESS_CP`. In practice the downstream `ACCESS_CP` check
on every real action contains the damage (a non-admin can create a token file but
still cannot invoke a handler), so this is a low-severity hygiene issue rather
than a privilege escalation. Password re-entry to enter the CP is effectively the
member's normal session password, already validated at `ikonboard.cgi:352`.

**The permission census.** `MEMBER_GROUP` is consulted in 31 files and specific
permission fields (`ACCESS_CP`, `IS_SUPMOD`, `EDIT_OWN_POSTS`, `POST_NEW_TOPICS`,
`USE_SEARCH`, `UPLOAD_AVATARS`, ...) are checked across the handlers
(`out_taint.txt` section 6.6). Authorization is genuinely mask-based and reasonably
granular. The main structural risk with per-handler permission checks -- a handler
that forgets to check -- is mitigated for the admin surface by the central gate
above; the public handlers each guard their own feature bit (e.g.
`Sources/Search/api.pm:325` for `USE_SEARCH`, `Sources/Post.pm` for
`ALLOW_POST`/`ATTACH_MAX`).

**Password reset token -- weak RNG.** The lost-password flow
(`Sources/UserCP/Lostpass.pm`) generates its reset token with the mailer's
`my_gen_id` (`Sources/Lib/FUNC.pm:1626-1631`):

```perl
srand($$|time);
my $session = int(rand(60000000));
return unpack("H*", pack("Nnn", time, $$, $session));
```

Unlike the session id, this token is **not** wrapped in MD5 -- it is the raw hex
of `(time, pid, 16-bit-rand)`: 4 bytes of known-ish time, 2 bytes of guessable
PID, and 2 bytes from a `rand` that is itself seeded by `$$|time`. That is very
low entropy for a credential that grants a password reset. The token is delivered
out-of-band by email (`Lostpass.pm:104`) and step_c
(`Sources/UserCP/Lostpass.pm:116-201`) requires the `SID` to match a stored
`authorisation` row, so an attacker must both trigger a reset for the victim and
predict the token without seeing the email -- but given the entropy that is a
finite brute force, not a cryptographic barrier. The new password itself comes
from `RandomPassword` (`Sources/Lib/FUNC.pm:1429`), also `srand(time)` + `rand` --
eight characters, same PRNG weakness. Reset tokens and generated passwords should
use a CSPRNG.

The reset `WHERE` at `Lostpass.pm:131` (`UNIQUE_CODE eq \"$iB::IN{'SID'}\"`) is
not injectable: `SID` passes `_clean_value`, which turns `"` into `&quot;`, and
`parse_where`'s `/`->`'` rewrite only produces a single quote inside a
double-quoted SQL literal.

---

### 6.10 File upload / attachments

Attachment handling is in `Sources/Post.pm:1066-1157` (mirrored in `Post2.pm`);
`Sources/Misc/Attachments.pm` only bumps a hit counter and redirects to the
stored file. The relevant config is `$CGI::POST_MAX = 500*1024`
(`ikonboard.cgi:164`, a 500 KB cap on the whole request) and the extension lists
`AV_EXT` / `IMG_EXT` = `gif|jpeg|jpg|swf` (`ikonboard.conf:19`, `:71`).

**Validation is by client-supplied MIME type, not by a real extension whitelist.**
The upload is accepted based on the browser-declared `Content-Type` checked
against `MimeTypes.cfg` (`Post.pm:1083-1091`):

```perl
my $mime_type = $iB::CGI->uploadInfo($file_to_attach)->{'Content-Type'};
...
unless ($mime->{ $mime_type }[0]) { ...reject... }
```

`Content-Type` in a multipart upload is attacker-controlled, so this check is
trivially satisfied by declaring an allowed type regardless of the actual bytes.

**The filename is sanitized, then run through a blocklist -- not a whitelist:**

```perl
$file_to_attach =~ /([^\\\/\:]+)$/;
$file_name = $1;
$file_name =~ s/[^\w\.]/\_/g;                       # good: kills traversal, NUL, spaces
$file_name = "post-$iB::IN{f}-".substr(time, 5,10)."-".$file_name;
# Make perl/php scripts safe
if ( ($file_name =~ /\.(cgi|pl|js|asp)$/i) or ($file_name =~ /\.php\d{0,2}$/i) ) {
    $file_name =~ s!\.!-!g;
    $file_name .= '.txt';
}
```
`Post.pm:1101`

The character sanitization is genuinely good -- `s/[^\w\.]/_/g` removes path
separators, null bytes and spaces, so filename-based traversal and null-byte
tricks are closed. But the dangerous-extension handling is a **blocklist** that
rewrites only `.cgi`, `.pl`, `.js`, `.asp`, and `.php`/`.phpN`. It misses:

- **`.swf`** -- explicitly *allowed* by `AV_EXT`/`IMG_EXT`. Flash in that era
  executed ActionScript in the browser in the page's origin; a hosted SWF was a
  workable XSS/redirect vector.
- **`.html` / `.htm` / `.shtml` / `.phtml` / `.pht`** -- an uploaded `.html` file
  is served from the upload directory and rendered in the board's origin: stored
  XSS. `.shtml` invokes server-side includes on many 2002 Apache configs. None of
  these are rewritten.
- Server-executable mappings the blocklist does not know about depend entirely on
  the host's handler config.

**Where it lands, and its mode.** The file is written to `PUBLIC_UPLOAD`
(`Post.pm:1125`), which is a **web-accessible** directory by design -- that is how
attachments are downloaded (`Attachments.pm:98` redirects to
`$iB::INFO->{'UPLOAD_URL'}/...`). Both the directory and the file are
`chmod 0777` (`Post.pm:1124`, `:1135`; `out_fileio.txt` section 6.5). So an
attacker who gets a `.html` or `.swf` past the MIME check has a file at a
predictable URL in the board's origin, world-writable on disk.

**Verdict.** The size cap and the filename character-scrub are good; the
validation model is the era-typical mistake -- trust the client MIME type, blocklist
a handful of extensions -- and it lets browser-executable content (`.swf`, `.html`,
`.shtml`) into a web-accessible directory. The fix is an allowlist of extensions
*and* content-sniffing, uploads stored outside the docroot and served through a
handler with a forced `Content-Type: application/octet-stream` and
`Content-Disposition: attachment`, and non-0777 permissions.

---

### 6.11 Information disclosure

**`catch_die` prints the error to the browser.** The global `$SIG{__DIE__}`
handler (`ikonboard.cgi:546-576`) renders any fatal error into an HTML page,
including the file path where it occurred, after attempting to redact real paths:

```perl
$error =~ s!$ENV{'DOCUMENT_ROOT'}!/your/path/to!i;
my ($msg, $path) = split " at ",$error;
print "Content-type: text/html\n\n";
print qq~ ... <b>$msg</b> ... This error was reported at: ...$path... ~;
```

The redaction is weak on two counts, both concrete:

- **It only substitutes `$ENV{'DOCUMENT_ROOT'}`.** For a CGI script, the
  interesting absolute paths are `IKON_DIR` and `DB_DIR`, which are typically
  *outside* the document root (the whole point of putting `cgi-bin` and data
  dirs out of web reach). Those paths are not redacted and leak verbatim.
- **`DOCUMENT_ROOT` is frequently unset for CGI.** When it is empty, the
  substitution matches nothing (or, with an empty pattern, does nothing useful),
  so no redaction happens at all -- while the page still cheerfully tells the user
  "your 'real' paths have been removed to protect your information."

Beyond paths, `$msg` is the raw die string, and many die sites embed sensitive
detail: the database drivers die with the full query and `$DBI::errstr` (e.g.
`Sources/iDatabase/Driver/mySQL.pm:269`, "Query: $db_query"), which discloses
schema, table/column names, and sometimes data fragments to an unauthenticated
visitor who can trigger the error. The board ships with `CGI::Carp
"fatalsToBrowser"` commented out (`ikonboard.cgi:26`) precisely to avoid leaking
internals -- but this hand-rolled handler leaks a narrower but still meaningful set
of the same information. Production error handling should log server-side and show
the user a generic message with no path or query text.

**`.htaccess` and `index.html` blockers.** Sensitive directories carry two
belt-and-suspenders protections. Each data directory has an `.htaccess`:

```
<files "*.*">
deny from all
</files>
```
(`Data/.htaccess`, `Database/.htaccess`, `Database/config/.htaccess`,
`Languages/.htaccess`, ...)

and an `index.html` "403" placeholder so a directory listing shows a decoy page
rather than the real contents (`Data/index.html` and siblings).

Two honest caveats. First, `.htaccess` does nothing unless Apache is configured
to honor it: on a server with `AllowOverride None` (a common and *recommended*
hardening), the `deny from all` is silently ignored and the files under those
directories become web-reachable if they sit inside the document root. The
protection is advisory, contingent on server config the board does not control.
Second, the `<files "*.*">` pattern matches only names containing a dot; a file
without an extension would not be covered by that block (most data files here do
have extensions, so this is a latent sharp edge rather than an active hole). The
`index.html` decoys only defeat auto-indexing; they do nothing to stop a direct
request for a known filename. These measures are reasonable defense-in-depth for
2002 but should not be relied on as the sole control -- the real control is keeping
`Data/`, `Database/` and the config out of the document root entirely.

---

### 6.12 Cross-cutting: `srand`/`rand` as a security primitive

Three separate security-relevant values are generated with `srand(...) ; rand()`:
the ARC4 key (section 6.6), session ids (section 6.8), and password-reset tokens and
generated passwords (section 6.9). In every case the seed is some XOR of `time` and
`$$`, which is dominated by the current second, and `rand` is a non-cryptographic
PRNG that is fully determined by that seed. I confirmed the seed structure and the
determinism directly. This is a single systemic weakness with several faces:
anything whose unpredictability matters is only as unpredictable as "the current
second, plus a guessable PID." It was a common 2002 misunderstanding; by modern
standards every one of these should draw from a CSPRNG.

---

### 6.13 mod_perl state hazard (reliability-adjacent)

Ikonboard 3 ships mod_perl support and `ikonboard.cgi:77-83` explicitly resets
its `$iB::*` globals at the top of each request because, under mod_perl, a module
is compiled once per child and reused across requests. That reset is correct and
shows awareness of the hazard. But it cannot reach **file-scoped lexicals inside
modules**, and those persist across requests for the life of the child process
(`out_subs.txt` section 6.3 flags `$base_url` and `$ibc_flash` in the skin views,
among others). Most are benign (a base URL), but a file-scoped lexical that ever
holds per-user data would leak from one visitor to the next served by the same
child. No such leak of sensitive data was confirmed in this pass; it is noted as a
class of latent risk that the architecture invites and that only manifests under
mod_perl, not plain CGI.

---

### 6.14 The 2002 view vs the 2026 view

Severity is rated as it would plausibly have been triaged in 2002 (against then-
current norms and tooling) and as it reads in 2026. "Disproven" entries are
included deliberately.

| # | Finding | Location | 2002 | 2026 |
|---|---------|----------|------|------|
| 5 | **`lang` cookie -> `eval` (CVE-2003-0770), unauth RCE** | `Lib/FUNC.pm:183-208`; cookies raw at `ikonboard.cgi:186-192` | High | **Critical** |
| 5.2 | **`[img]` tag XSS (CVE-2002-0328, CVE-2002-2230)** -- suffix-only extension test, scheme never validated, `javascript:` check disabled by `ALLOW_DYNAMIC_IMG`, `"` restored by post un-escaping | `iTextparser.pm:178-204`; `iTextparser2.pm:178-204`; `Post.pm:1507-1515` | Medium | High |
| 5.3 | `LoadSkin` no-op cleanse `s/^(\d+)$/$1/` -- same defective idiom, second instance | `Lib/FUNC.pm:104` | Info | Low (**no exploitable path** -- `$sid` never reaches an `eval`) |
| 5.3b | Request parameter `f` interpolated into a regex | `Lib/FUNC.pm:124` | Info | Low (skin confusion / ReDoS) |
| 16 | Upload validation by client MIME + extension blocklist; `.swf`/`.html`/`.shtml` reach a 0777 web dir | `Post.pm:1066-1157`; `ikonboard.conf:19,71` | Medium | High |
| 9c | Password-reset token & generated password from `srand`/`rand` (16-bit) | `Lib/FUNC.pm:1626`, `:1429`; `UserCP/Lostpass.pm` | Low | High |
| 8a | Session id entropy = `srand($$\|time)` + `rand`, MD5-wrapped | `Sessions.pm:478-483` | Low | Medium-High |
| 7 | Un-quoted WHERE DSL; `X-Forwarded-For` -> session-cleanup `DELETE`/Check-`eval` | `Base.pm:44-63`; `Sessions.pm:468-471`; IP at `ikonboard.cgi:317,324` | Low | Medium (backend-dependent) |
| 2 | Front-door filter is HTML-only, lossy, undone at 115 sites | `ikonboard.cgi:516-537`; `out_taint.txt` section 4 | Low | Medium |
| 8c | Session id in URL (`?s=`) leaks via Referer/history | skin templates; `Sessions.pm` | Low | Medium |
| 8d | No session-fixation protection (id adopted, not rotated, at login) | `Sessions.pm:152,226` | Low | Medium |
| 11 | `catch_die` leaks paths/SQL; redaction only `DOCUMENT_ROOT`, often unset | `ikonboard.cgi:546-576`; driver die strings | Low | Medium |
| 10 | Admin `do`/`require` of request-controlled paths (`SKIN`/`LANG`); `_clean_value` keeps `..` and `/` | `SkinControl.pm:786,876-877`; `LangControl.pm:316,373` | Low | Medium (post-auth admin) |
| 6 | ARC4 DB-password obfuscation; key = world-readable `0644` `.pwd` filename | `ikonboard.cgi:210-284`; `ARC4.pm` | Low | Low-Medium |
| 11b | `.htaccess deny` is inert under `AllowOverride None`; data dirs must be out of docroot | `Data/.htaccess` et al. | Info | Low-Medium |
| 3 | `_clean_key` `[\w\.-\_]` range bug (admits `:;<=>?@[\]^`); stale `$1` | `ikonboard.cgi:512` | Info | Low (not exploitable) |
| 9b | `dologin` writes admin token before `ACCESS_CP` check | `Admin/Functions.pm:49,152` | Info | Low (contained downstream) |
| 13 | File-scoped lexicals under mod_perl | `out_subs.txt` section 3 | Info | Low (latent) |
| 4 | **DBM/mySQL search `eval`** | `api_DBM.pm:121-198`; `api_mySQL.pm` | (looks High) | **Disproven** -- defended by `keyword_filter` |
| 9a | **Dispatcher `eval`** | `ikonboard.cgi:485-489` | (looks High) | **Disproven** -- fixed `%Mode`, `act` clamped |
| 1 | Password hashing (username-keyed MD5, `crypt` legacy migration) | `Lib/FUNC.pm:1390`; `Sessions.pm:195-206` | Good | Weak-but-not-broken |
| 1 | Server-side sessions; IP+UA binding; `use strict` in 74% | `Sessions.pm`; `out_subs.txt` section 2 | Good | Good baseline |

---

### 6.15 What an operator could have done about it in 2002

Practical, period-available mitigations -- several of which would have blunted the
critical finding without touching the source:

1. **Do not assume upgrading fixes it.** The usual first advice -- "move to the
   3.1.2 line" -- does **not** remedy CVE-2003-0770: 3.1.2a still carried the bug
   and was reported vulnerable in September 2003. The operator's own remedy is to
   patch `LoadLanguage` by hand (delete non-word characters from the cookie, or
   whitelist against `LANGUAGES`), which is a two-line change, and to apply the
   same treatment to the `LoadSkin` cleanse at `Lib/FUNC.pm:104` on principle.
   This is the single most important action.
2. **Keep `cgi-bin`, `Data/`, `Database/`, `Languages/` and the config out of the
   document root.** This makes the `.htaccess`/`index.html` decoys unnecessary and
   robust against `AllowOverride None`, and it neutralizes direct-fetch disclosure
   of data files and `Boardinfo.cgi` regardless of the ARC4 obfuscation.
3. **Serve the uploads directory with executable handlers disabled** --
   `Options -ExecCGI -Includes`, `RemoveHandler`/`RemoveType` for `.cgi .pl .php
   .shtml`, and ideally `AddType application/octet-stream` for everything -- so a
   `.swf`/`.html`/`.shtml` that slips past the MIME check cannot execute in the
   board's origin. Cap upload size at the web-server layer too.
4. **Restrict the admin CP by network** -- an Apache `<Files>`/`<Location>` block
   requiring auth or an IP allowlist for `AD=1`/`CP=1` requests adds a control in
   front of the application's own (real, but code-level) `ACCESS_CP` gate.
5. **Run the CGI as an unprivileged, sandboxed user** (suEXEC / a dedicated
   account) so that a successful `eval` RCE is contained to that user rather than
   the web-server master, and so `chmod 0777` files are not readable/writable by
   every other site on a shared host.
6. **Turn on `CHECK_USER_AGENT`** (it defaults on) and keep `SESSION_EXPIRATION`
   short; prefer cookie sessions over the `?s=` URL form where the client base
   supports it, to limit Referer leakage.
7. **Front the board with mod_security or equivalent** to strip or reject `lang`
   cookies containing non-word bytes (especially NUL) -- a virtual patch for the
   critical finding pending the code update, and the only mitigation available to
   an operator between the April 2003 disclosure and a hand-patch.
8. **Disable `ALLOW_DYNAMIC_IMG`** (the shipped default is off) -- leaving it off
   keeps the only `javascript:` filter in the `[img]` path active, which is
   directly relevant to CVE-2002-0328 / CVE-2002-2230.
9. **Filesystem hygiene:** ensure the `.pwd` key file and `Boardinfo.cgi` are not
   world-readable, and that the DB account used has least privilege on only the
   board's schema, so that a disclosed/decrypted DB password is of limited value.

---

### Appendix: methodology and disproven findings

Every code quotation and line number above was read from the reconstructed tree;
the dynamic claims were checked with throwaway Perl (Perl 5.26) against the actual
regexes and transformation chains rather than by eye:

- The `_clean_key` character class admits `:;<=>?@[\]^` -- confirmed by enumerating
  chr(32..126) against `m!^([\w\.-\_]+)$!`.
- The search `eval` is not reachable with attack characters -- confirmed by running
  `_clean_value` -> `keyword_filter` -> the `api_DBM` per-token rewrite over
  candidate payloads (parens/braces/slashes/semicolons are deleted), and by
  confirming Perl raises "Eval-group not allowed at runtime" for an interpolated
  `(?{...})` without `use re 'eval'`.
- The `lang` cookie cleanse `s/^([\d\w]+)$/$1/` never removes anything -- confirmed
  it leaves `en";system("id");"` and `en\0;evil` unchanged.
- `_clean_value` leaves `../` and `/` intact in values -- confirmed, which is why
  the admin `do`/`require` paths (section 6.10) can carry traversal.
- `srand($$|time)` is dominated by the epoch and `rand` is deterministic given the
  seed -- confirmed.

**Findings raised and then disproven, reported as negatives because a reader will
suspect them:**

- **The DBM/mySQL search `eval` (section 6.4)** -- dangerous by construction,
  defended in practice by `keyword_filter`; *not* the public 3.1.1 RCE.
- **The dispatcher `eval` (section 6.9)** -- guarded by a fixed `%Mode` hash and the
  `act` clamp at `ikonboard.cgi:473-474`; not injectable.
- **The lost-password `WHERE` interpolation of `SID` (section 6.9)** -- not
  injectable; `_clean_value` neutralizes the double-quote delimiter and the DSL's
  `/`->`'` rewrite yields only a harmless quote inside a double-quoted literal.

**A negative finding added on review:** the second no-op cleanse at
`Sources/Lib/FUNC.pm:104` (`LoadSkin`) is the same defective idiom as the critical
one and was flagged as "similar code" by the original advisory -- but `$sid` is
only ever numerically compared against config-derived skin ids and never reaches
an `eval`, so there is no exploitable path from it. Recorded as a real bug with no
exploit, not as a second RCE.

The findings that are *not* defended, and that match the historical record, are
the `lang` cookie `eval` of section 6.5 -- **CVE-2003-0770**, disclosed 04/02/2003
by Nick Cleaton after a 01/26/2003 vendor notification, and still exploitable in
3.1.2a as of the September 2003 Bugtraq re-report -- and the `[img]` tag XSS pair
of section 6.5.2, **CVE-2002-0328** and **CVE-2002-2230**.

**Primary sources.** `seclists.org/vuln-dev/2003/Apr/10` (original disclosure,
Nick Cleaton, 04/02/2003; follow-up 04/03/2003 by Adam Gilmore);
`seclists.org/bugtraq/2003/Sep/95` (3.1.2a still vulnerable); Exploit-DB **22499**
and **22500** (published proofs of concept); NVD entries for CVE-2003-0770,
CVE-2002-0328 and CVE-2002-2230.

---

## 7. Installation and operation

This chapter is about the part of the software nobody archives: the hour you spent with
an FTP client, a browser window, and a text file of paths, getting the thing to come up
at all. Ikonboard 3.1.1 shipped in July 2002 with two HTML guides, a glossary, a readme,
thirty-two screenshots, a six-step web installer, and a `Tools\` folder full of scripts
that could destroy your board. All of it was shaped by one constraint: the person
installing it had FTP and nothing else.

A note on evidence. The tree under `teardown\board\` was **never installed**. There is no
`Boardinfo.cgi`, no `install.lock`, no `.pwd` key file, no row of data anywhere. Everything
in this chapter about what the installer *produced* is read out of the installer's own
code, out of `ikonboard.conf`, and out of the vendor's screenshots -- not out of a running
board. Where that distinction matters, it is called out.

---

### 7.1 What you were working with in 2002

Picture the machine on the other end. It is almost certainly a shared box: one physical
server, a few hundred virtual hosts, Apache 1.3, and a `/home/` full of customer
directories. You have an account on it. You do not have a shell. You have FTP.

The vendor's own screenshots tell you exactly this. `img\Installation1.jpg` -- a real
install captured on 06/22/2002 -- shows the paths the operator typed:

```
/home/cursed/public_html/cgi-bin/boards/BACK_UP
/home/cursed/public_html/cgi-bin/boards/Data
/home/cursed/public_html/cgi-bin/boards/Database
...
/home/cursed/public_html/iB_html/uploads
```

`cursed` is a username. `public_html` is the document root. `cgi-bin/boards` is a
subdirectory the operator made by hand in their FTP client because the install guide
suggested it. This is the whole world of 2002 shared hosting in one path string.

What you had:

| Resource | Reality in 2002 |
|---|---|
| Shell access | No, for most people. FTP only. |
| Perl | Yes -- 5.005_03 or 5.6.x. The vendor's own sample output in `Install_Guide.html` shows `5.006001`. |
| `cgi-bin` | Usually. Sometimes you had to create it and `chmod 0755` it yourself. |
| CGI.pm | Almost always -- it was core Perl then. Version 2.752 in the guide's sample. |
| DB_File | Usually, but not guaranteed. This was the make-or-break module. |
| DBI + DBD::mysql | Only if you paid for the MySQL add-on, and only if the host had bothered to build the driver. |
| A MySQL database | Extra money, often "1 database included," sometimes none at all. |
| Setting file permissions | Through your FTP client's checkbox dialog. `Install_Guide.html` ships two screenshots (`fig_a.gif`, `fig_b.gif`) of exactly that dialog because typing `chmod 755` was not an option available to you. |
| Untarring an archive | Not directly. That is the entire reason for section 3 and `install_modules/tar.pl`. |

Two consequences follow from "no shell," and between them they explain nearly every
design decision in the installer:

1. **You cannot unpack an archive on the server.** So either you unpack it on your
   Windows box and upload 550 individual files over a dialup or early-DSL link with an
   FTP client that will helpfully corrupt most of them (section 3), or the software brings its
   own untar with it and does the work server-side. Ikonboard does the second, and
   bundles `Archive::Tar` to do it.
2. **You cannot run a script from a prompt to configure the board.** So the configurator
   has to be a CGI script, which means it is world-reachable while it exists, which means
   it has to lock itself and the board has to refuse to start while it is still lying
   around. Ikonboard does both (section 5.7).

Two smaller period details worth noting. Windows hosting existed and was awkward: the
guide tells you that "*Windows servers do not accept CHMOD values*" and that "*You will
probably need to rename your \*.cgi files to have the extension \*.pl*", and the installer
carries an `$ext` variable and a `$iB::EXT` global through every generated form action to
support it (`installer.cgi:41`, `installer.cgi:88-97`). And mail was sendmail: the
installer probes four hardcoded paths for a binary (`install_modules/start.pl:270`) --

```perl
for ("/usr/sbin/sendmail", "/usr/lib/sendmail", "/usr/bin/sendmail", "/var/qmail/bin/qmail-inject") {
    $paths->{'SEND_MAIL'} = $_ if -e $_;
}
```

-- and the standalone tester probes twelve (`Tools\HELP\perl_test.cgi:22-35`), including
the bare word `sendmail` and `/var/qmail/bin/qmail-inject`.

---

### 7.2 The shipped documentation

The download has four documents and an `img\` folder. The readme is the front door:

```
-----------------
Ikonboard v3.1.1
-----------------

Their Are 4 Directories and 4 files in this Package
```

The typo is quoted as-is; it is on line 5 of `readme.txt`, dated 11/23/2002 -- five months
*after* the guides, making it the newest text in the package. It goes on to describe the
four directories (`Upgrading`, `Tools`, `Upload_Files`, `Img`) and the four files
(`Glossary.html`, `Install_Guide.html`, `Installer_guide.html`, `License.html`), then
signs off:

```
Getting Started:
----------------

View the Install_guide.html File. IT contains all the necessary information for where all the files go and then how to proceed after that.
```

That is the tone throughout: written quickly, by someone who knew the product, not
proofread. It is 1,137 bytes and it does its job.

#### `Install_Guide.html` (28 KB, written 6/22/02 by "Snow Wolf")

The long one, and the good one. It opens by pointing you at the support forums and the
live-support ticket system, then sets three prerequisites:

> **1)** A website that allows self-installed cgi scripts.
> **2)** An FTP client. A good source to find one is ZDNet.com, if you don't have one. Some of the more popular ones are Smart FTP for Windows users, and Fetch for Mac users.
> **3)** A host that has installed Perl 5 or better with the DB_file module installed.

Followed by, in italics:

> *Please note that although we offer MySQL databases for Ikonboard, that it is not a requirement to have MySQL capabilities on your site in order to use this version of iB.*

Step 1 is "run `perl_test.cgi` and find out where you are." The guide reproduces the
expected output verbatim, which is how we know what a healthy 2002 host looked like:

> Is Perl Version 5 or above installed? **Yes**
> Version of Perl running on this server: **5.006001**
> Is the CGI.pm module installed?: **Yes**
> Version of CGI running on this server: **2.752**
> Is the DB_File installed on this server to allow DBM's to be used? **Yes**
> Is the DBI package installed, allowing the use of MySQL? **Yes**
> Full path to this script: **/home/user/public_html/cgi-bin**
> Sendmail Path: **/usr/lib/sendmail**

and then tells you how to read it:

> If **No** is listed for question 5, then you will not be able to run ikonboard on your server. You will need to contact your host, if possible, and ask them to install that module.
>
> If question 6 is **No**, then you will still be able to use the DBM version of ikonboard.
>
> If you see a bunch of code on the page when you go to access the script, the the site or directory is not configured for Perl usage, and you will be unable to run ikonboard on your server.

That last one -- the `.cgi` served as text because the host has not enabled CGI in that
directory -- is the single most common 2002 failure mode and the guide names it in the
first three screens. "the the" is in the original.

The guide also does something genuinely thoughtful: it prints six ASCII sketches of
possible directory layouts (`cgi-bin/` + `httpdocs/`, `cgi-bin/` + `www/`,
`public_html/cgi-bin/`, etc.) with the CGI directory colored blue and the HTML directory
colored dark green, and asks you to "*find the one closest to your directory structure and
follow it based upon color references*." A color-keyed diagram is the correct answer to
"my host's layout is not your host's layout" when the reader has no shell to explore with.

Step 2 is the file listing and the permissions (section 4). The guide's line before it is worth
quoting in full because it is the thesis of the entire document:

> Now it's time for the good stuff. Please read the instructions below and follow them very carefully. 97% of all installation problems are avoidable by paying attention to the instructions.

The made-up statistic is doing real work: it is telling you that if this goes wrong, it is
your fault and the fix is to re-read. Given what section 3 is about, it is close enough to true.

#### `Installer_Guide.html` (14 KB, same author, same date)

A screenshot walkthrough of the wizard, one panel per step, each with a paragraph of
commentary. It leads with:

> This guide is designed to help walk you through the automatic installer offered for this program. Please pay careful attention to the notes made, as well as markings on the images that are there to help indicate what you need to do for a successful install. For file size reasons, all screenshots have been sized down.

"For file size reasons" is a 2002 sentence. The screenshots are 4-25 KB JPEGs, and the
step-one form is sliced into a 3x3 grid of nine separate JPEGs (`Image8_1x1.jpg` through
`Image8_3x3.jpg`) reassembled with a `<TABLE>` -- the ImageReady export of the era.

Its most useful paragraph is the one about paths, which is where the guide says outright
where people fail:

> This is where the majority of people make the most mistakes, so please read this information VERY carefully [...] Your CGI Path is the path found by the test file. It can also be found by installing and running the iBtest.cgi script. This will be the server path to the CGI directory. This cannot be an URL and it should not have a / at the end of it.

Note `iBtest.cgi`: **there is no `iBtest.cgi` anywhere in this distribution.** The tester
that ships is `Tools\HELP\perl_test.cgi`. This is doc drift, and it is not the only
instance -- the same guide's "Getting Started" section says the zip contains

> DOCS          iB2-iB3_Upgrading          iB3_UPLOAD          TOOLS          upgrading

which does not match the actual package (`Upgrading`, `Tools`, `Upload_Files`, `img`) and
does not match the readme's own list either. The guides were written against a
3.0-era layout and shipped, lightly edited, with 3.1.1.

#### `Glossary.html` (2 KB)

Six terms: ASCII, Binary, CGI, CHMOD, FTP, tar. Each links back to its anchor in the
install guide, and the install guide marks each first use with a small question-mark GIF.
It is a small, well-built piece of hypertext. Two definitions are worth reproducing
because they carry the chapter's central problem:

> **ASCII** - Plain Text - *A type of file transfer used on text-based files, such as scripts, html documents, or other text-based items used on the web*.
>
> **Binary** - *A type of file transfer used on images, such as gif, jpg, or png, as well as "grouped" or archived files, such as tar or exe, and compressed files such as zip & gz*.

and the one that explains the whole packaging strategy:

> **tar** - Tape Archives - *based on the tape back-up devices used by early Unix platforms. A useful way to transport many files without compression while retaining the original file names, data and permission settings. One .tar file could contain tens, hundreds or even thousands of files. These files are always uploaded in binary*.

The glossary also, correctly and slightly wearily, kills a misconception:

> **CGI** - Common Gateway Interface- *A common misconception is that CGI is a language. This is not true. A CGI is a server process, many different languages can be run as a CGI process including Perl and PHP*.

**Overall quality:** better than most of its contemporaries. The color-keyed layout
sketches, the "here is what a good result looks like" sample output, the anchored
glossary, and the annotated screenshots are all real instructional design. The typos,
the stale file listings, and the reference to a script that does not exist are all real
sloppiness. Both are period-accurate.

---

### 7.3 The ASCII / BINARY problem

This is *the* 2002 support issue, and Ikonboard's packaging exists to route around it.

FTP has two transfer modes. In BINARY (`TYPE I`) the bytes arrive as sent. In ASCII
(`TYPE A`) the client and server agree to translate line endings to the destination's
convention -- so a file written on Windows with CRLF endings arrives on a Unix server with
LF endings. Get it backwards and one of two things happens:

- **Text file uploaded in BINARY:** every line of your Perl script now ends `\r\n` on a
  Unix box. The first line is `#!/usr/bin/perl\r`. The kernel takes the interpreter path
  literally, including the carriage return, fails to find a binary called
  `/usr/bin/perl\r`, and Apache reports **500 Internal Server Error** with
  `Premature end of script headers` in a log you cannot read.
- **Binary file uploaded in ASCII:** every `0x0D 0x0A` pair in your GIF or your tar is
  rewritten, and every lone `0x0A` gains a `0x0D`. The file is now corrupt. A tar so
  treated will fail its checksum; a GIF will render as garbage or not at all.

The install guide's answer is a two-column table:

> The following files must be uploaded in ASCII mode :
> `*.html  *.dat  *.txt  *.pm  *.pl  *.cgi  *.conf`
>
> The following file types must be uploaded in binary mode :
> `*.tar`
>
> NOTE : *If your FTP client allows for auto-detect, this is usually the safe way to go, though you may wish to check for your own benefit to make sure that it does upload the files listed above in ASCII format, or you may have problems with your board.*

The 3.1.0-to-3.1.1 upgrade note states the same rule with the extension list the prompt to
this chapter quotes:

> You must transfer all the .html, .pm, .txt, .css and .cfg files in ASCII mode and all the others in BINARY mode (all the images files).

#### The evidence is still in the files

This is verifiable from the distribution as it sits on disk today. Every loose text file
in `Upload_Files\` is **CRLF**:

| File | CRLF pairs | Total LF | Verdict |
|---|---|---|---|
| `readme.txt` | 26 | 26 | all CRLF |
| `Install_Guide.html` | 455 | 455 | all CRLF |
| `Upload_Files\cgi-bin\ikonboard.cgi` | 587 | 587 | all CRLF |
| `Upload_Files\cgi-bin\ikonboard.conf` | 118 | 118 | all CRLF |
| `Upload_Files\cgi-bin\install_modules\start.pl` | 350 | 350 | all CRLF |
| `Tools\create_indexes.cgi` | 159 | 159 | all CRLF |
| `Tools\HELP\perl_test.cgi` | 103 | 103 | all CRLF |

Every file *inside* the tarballs is **LF**. `Sources/Post.pm`, extracted from
`Sources.tar`, has 1,623 newlines and zero carriage returns.

That asymmetry is the whole design. The nine loose files you upload by hand
(`ikonboard.cgi`, `installer.cgi`, `ikonboard.conf`, the six `install_modules` files, and
the seed data under `INSTALL_DATA`) are shipped DOS-style and **depend** on your FTP
client doing the ASCII translation. The 550-odd files that make up the actual application
never touch FTP's text path at all: they ride inside six binary tarballs and are written
to disk by Perl, byte for byte, already in Unix form.

So the shipped ASCII list is not a suggestion you can ignore by "just using binary for
everything." Upload `ikonboard.cgi` in binary and it will not execute. And the tar list
is not a suggestion either: upload `Sources.tar` in ASCII and `Archive::Tar` will reject
it. The installer checks for exactly this, with `-B` (`install_modules/tar.pl:259`):

```perl
unless (-B "$iB::CONFIG->{'IKON_DIR'}/$key") {
    push @invalid     , $key;
}
```

and if any archive fails the test it prints "*The following tar files are not vaild*"
(typo original) and tells you to "*go back and ensure that you have uploaded all the tar
archive files in binary before re-submitting*" (`tar.pl:281-296`). That is a support
ticket the vendor decided to answer in code rather than on the forum.

#### Untarring without a shell

The archives solve the corruption problem but create a new one: how do you unpack a
`.tar` when you cannot run `tar`? Ikonboard bundles the answer.

`install_modules/Archive/Tar.pm` is a copy of the CPAN pure-Perl `Archive::Tar`, and
`install_modules/Archive/Compress/Zlib.pm` is a copy of `Compress::Zlib` 1.13 alongside
it. The vendor then modified `Archive::Tar` to remove the Zlib dependency outright -- the
detection is commented out and compression is hard-disabled
(`install_modules/Archive/Tar.pm:27-37`):

```perl
    # Check if Compress::Zlib is available
    #$compression = 1;
    #eval {require Compress::Zlib;};
    #if ($@) {
	#warn "Compression not available.\n";


	$compression = undef;
```

That is the right call. The bundled `Compress::Zlib` is only the Perl half of an XS
module -- it ends in `bootstrap Compress::Zlib $VERSION` and there is no compiled object
shipped with it, so on a host without the real thing installed it cannot load at all
(confirmed: requiring it fails with `Can't locate loadable object for module
Compress::Zlib`). By hard-disabling compression the vendor removed a dependency it could
not actually satisfy, and shipped all six archives **uncompressed** -- every one of them
carries the `ustar` magic and none is gzipped. `tar.pl:177` reads them accordingly, with
the "compressed" flag explicitly off:

```perl
       unless ($tar->read("$from/$tar_name", 0)) {
```

The tradeoff is upload size: 3.5 MB of uncompressed tar over a 2002 connection. The
installer's own form acknowledges that some people will not be able to manage it and
offers the manual path (`tar.pl:361`):

> We now need to install the ikonboard files. The official ikonboard distribution contains these files in tar archives. This makes it easier to upload and set the permissions on. However, some webhosts have a file upload limit which the tar files exceed, or your system cannot untar these archives. If you've already extracted the files from the tar archives, then choose the approriate option. If you have uploaded the tar archives (in BINARY), the installer will then proceed to extract them for you. To save system resources, this is done in steps with a page refresh in between, please don't stop the page from loading or you may experience errors.

If you took the manual path you were back to uploading 550 files with the ASCII/BINARY
rules applied individually. Almost nobody did. The guide says so: "*Most people will have
the installer extract the tar archives.*"

---

### 7.4 The upload layout

`Upload_Files\` has two top-level directories, and they go to two completely different
places on the server.

```
Upload_Files\
  cgi-bin\        -> your cgi-bin (or a subdirectory of it)
  iB_html\        -> your web root (public_html / www / httpdocs)
```

What is actually in `cgi-bin\` as shipped:

| Item | Type | Notes |
|---|---|---|
| `ikonboard.cgi` | script | the board. 587 lines. |
| `installer.cgi` | script | the wizard. Deleted after use. |
| `ikonboard.conf` | text | 118 settings, mostly blank; becomes `Data/Boardinfo.cgi`. |
| `index.html` | text | a fake 403 page, repeated in every directory, to defeat directory listing. |
| `Sources.tar` | binary | 118 files, 2.0 MB -- the application. |
| `Skin.tar` | binary | 61 files, 680 KB -- compiled views + `.cfg` templates. |
| `Database.tar` | binary | 68 files, 80 KB -- table definitions and empty data directories. |
| `Languages.tar` | binary | 35 files, 140 KB -- the `en` language pack. |
| `Data.tar` | binary | 7 files, 40 KB. |
| `non-cgi.tar` | binary | 262 files, 460 KB -- **images; extracts into the web root, not here.** |
| `install_modules\` | dir | 6 Perl files + bundled `Archive::Tar` and `Compress::Zlib`. |
| `INSTALL_DATA\` | dir | 17 seed files including the three SQL schemas. |
| `BACK_UP\ Data\ Database\ INCOMING\ Languages\ OUTGOING\ Skin\ Sources\` | dirs | empty except for the fake-403 `index.html`. |

And `iB_html\` contains only `index.html` and an empty `uploads\`. Everything else that
belongs in the web root arrives via `non-cgi.tar`.

#### The `non-cgi` move

This is the one genuinely non-obvious part of the layout. `non-cgi.tar` sits in `cgi-bin\`
with the other five archives, but its contents all live under a single top-level directory
called `non-cgi/`, and the installer extracts it with the *destination* set to the HTML
directory rather than the CGI directory. The routing table is in `install_modules/functions.pm:155-161`:

```perl
    my $tarballs = {  'Data.tar'      => [ 1, 'c', 'c', 0, 'Data'     ],
                      'Database.tar'  => [ 2, 'c', 'c', 0, 'Database' ],
                      'Languages.tar' => [ 3, 'c', 'c', 0, 'Languages'],
                      'non-cgi.tar'   => [ 4, 'c', 'h', 0, 'non-cgi'  ],
                      'Skin.tar'      => [ 5, 'c', 'c', 0, 'Skin'     ],
                      'Sources.tar'   => [ 6, 'c', 'c', 1, 'Sources'  ],
                   };
```

Field 2 is where the archive is read *from* (`c` = CGI dir), field 3 is where it is
extracted *to* (`h` = HTML dir). Only `non-cgi.tar` crosses over. The net effect is that
262 GIFs and JPEGs -- skin graphics, post icons, emoticons, avatars, MIME-type icons -- end
up at `iB_html/non-cgi/`, web-addressable, while all the Perl stays behind the `cgi-bin`
boundary where the server will execute it rather than serve it.

`functions.pm:446-448` then hardcodes that convention into the generated config:

```perl
    $data->{'HTML_DIR'}       = $data->{'HTML_DIR'}.'non-cgi/';
    $data->{'UPLOAD_URL'}     = $data->{'IMAGES_URL'}.'/uploads';
    $data->{'IMAGES_URL'}     = $data->{'IMAGES_URL'}.'/non-cgi';
```

So the operator types `/home/cursed/public_html/iB_html` into the form, and the board
stores `/home/cursed/public_html/iB_html/non-cgi/` as `HTML_DIR` and
`/home/cursed/public_html/iB_html/uploads` as `PUBLIC_UPLOAD`. That split -- images in one
subdirectory, user uploads in another -- is why `iB_html` and both its children all need
to be writable.

#### Permissions

Assembled from `Install_Guide.html` step 2. Everything here is UNIX-only; the guide notes
that on Windows hosting "*CHMOD settings have no effect*" and the whole column can be
skipped.

| Target | Mode | Why |
|---|---|---|
| the `cgi-bin` (or the `boards`/`ib3` subdirectory you made in it) | `0755` | executable and traversable by the web server |
| `ikonboard.cgi`, `installer.cgi` | `0755` | must be executable |
| the eight uploaded directories in `cgi-bin` (`BACK_UP`, `Data`, `Database`, `INCOMING`, `INSTALL_DATA`, `Languages`, `OUTGOING`, `Skin`, `Sources`, `install_modules`) | `0777` | the CGI runs as `nobody`, not as you; it has to write here |
| the six `*.tar` files | `0777` | the guide groups them with the folders in one select-all |
| `ikonboard.conf` | `0777` | the installer rewrites it after every step |
| everything inside `INSTALL_DATA\` | `0777` | |
| everything inside `install_modules\` | `0777` | |
| `iB_html` | `0777` | `non-cgi.tar` is extracted into it |
| `iB_html/non-cgi`, `iB_html/uploads` | `0777` | attachments and avatars are written here at runtime |

`0777` everywhere is indefensible by any modern standard and was completely standard
practice in 2002 on shared hosting, for the reason implied above: your files are owned by
your FTP user, the CGI process runs as the web server's user, and on a shared box there is
no group you both belong to. World-writable was the only mode that worked, and every
other customer on the box could read and write your board's data directory. Nobody
thought about it much.

The installer verifies the two it needs first (`functions.pm:213-214`):

```perl
    my $cw_install_data = ( -w $iB::OBJ->{tmp_path}.'INSTALL_DATA' )   ? 'Yes'  : 'No, please CHMOD to 0777';
    my $cw_config_file  = ( -w $iB::OBJ->{tmp_path}.'ikonboard.conf' ) ? 'Yes'  : 'No, please CHMOD to 0777';
```

and refuses to give you the "Proceed" link if either fails (`functions.pm:231-233`).

---

### 7.5 The installer, step by step

`installer.cgi` is 250 lines and does almost nothing itself. It is a dispatcher: it works
out where it is, loads `install_modules/functions.pm`, checks for a lock file, reads
`ikonboard.conf`, and then hands off to one of six named modes.

#### 5.0 Bootstrap

The first interesting thing it does is guess its own location (`installer.cgi:85-105`):

```perl
if (-e 'installer.cgi') {
    $iB::OBJ->{can_load} = 1;
    $iB::OBJ->{tmp_path} = '';
    $iB::EXT = 'cgi';
} elsif (-e 'installer.pl') {
    ...
} elsif (-e $ENV{'DOCUMENT_ROOT'}.$ENV{'SCRIPT_NAME'}) {
    $iB::OBJ->{tmp_path} = $ENV{'DOCUMENT_ROOT'}.$ENV{'SCRIPT_NAME'};
    $iB::OBJ->{tmp_path} =~ s!installer.(cgi|pl)$!!i;
    $iB::EXT = $1;
} else {
    if ($full_path eq '/home/ikonboard/cgi-bin/forums/') {
        iB::install_error( 'FATAL_PATH_ERROR' );
    ...
```

Three probes: is the current working directory the board directory (the normal `mod_cgi`
case)? Is it there under the `.pl` name (the Windows case)? Can `DOCUMENT_ROOT` +
`SCRIPT_NAME` be joined into a path that exists? If all three fail, the script checks
whether you have edited the `$full_path` variable at the top of the file away from its
factory value, and if you have not, it bails with a specific error and a specific
instruction: download the file, open it in a text editor, find the block that begins
`# P R O G R A M  S E T - U P`, fill in your paths, re-upload. The error handler also
dumps the entire `%ENV` to the browser and appends a small glossary of Perl error messages
(`installer.cgi:215-220`):

```
"Can't locate DBD..."  means that you do not have the needed files to run mySQL/pgSQL for perl
"Can't locate DBI..."  means that you do not have the needed files to run mySQL/pgSQL for perl
"Can't locate method TIE_HASH..  means that your servers DB_File installation is botched, contact your webhost
"Can't locate 'functions.pm'...  means you you will have to edit the installer script
```

That is a support forum's FAQ compiled into the error page. It is the single most
2002-sympathetic thing in the codebase.

The dispatcher itself is an eval'd string (`installer.cgi:138-154`) -- the same "fudge our
autoloader together" pattern `ikonboard.cgi` uses for the board proper:

```perl
    my %Mode = (
                    Splash   => [ 'system_test'   ,    'iB System Profiler'         ],
                    start    => [ 'start'         ,    'Installation: Step One'     ],
                    tar      => [ 'tar'           ,    'Installing Ikonboard Files' ],
                    database => [ 'database'      ,    'iB Database Set-up'         ],
                    populate => [ 'populate'      ,    'iB Database Population'     ],
                    admin    => [ 'admin'         ,    'iB Admin Creation'          ],
               );
```

Six steps. Every screen posts back to `installer.cgi` with `act=` set to the next one.
There is no session and no server-side state machine -- the state lives entirely in
`ikonboard.conf`, which is rewritten after every step.

#### 5.1 System Profiler (`act=Splash`)

Screenshot: `img\SystemProfiler.jpg`. Seven rows, then a recommendation, then a next
action. The probing happens in a `BEGIN` block at `functions.pm:16-27`:

```perl
BEGIN {
        @AnyDBM_File::ISA = qw(DB_File GDBM_File NDBM_File SDBM_File);
        $iB::EVAL = { DB_File => 'no',
                      CGI     => 'no',
                      DBI     => 'no',
                    };
        for my $mod (qw/DB_File CGI DBI/) {
            if (eval "require $mod") {
                $iB::EVAL->{$mod} = 'Yes';
            }
        }
 }
```

Note the `@AnyDBM_File::ISA` assignment: it forces a preference order, `DB_File` first,
falling back through GDBM, NDBM and finally SDBM. The profiler reports which one you
actually got.

What the screen asks, and what it means:

| Question | Source | Fatal? |
|---|---|---|
| Can the installer write into the directory 'INSTALL_DATA'? | `-w` test | Yes -- blocks the Proceed link |
| Can the installer write into the file 'ikonboard.conf'? | `-w` test | Yes -- blocks the Proceed link |
| Is my perl installation ok? | `$] > 5.004` | Yes |
| Is the CGI.pm module installed? | `require CGI` | Yes |
| Can I use the mySQL version of Ikonboard? | `require DBI` | **No** |
| Do I have the DB_File library installed (for DBM database)? | `require DB_File` | No, but see below |
| What DBM library will my system use? | `$AnyDBM_File::ISA[0]` | informational |

The screenshot has a hand-drawn ellipse and the words "**Can say No**" pointing at the
mySQL row -- the guide's way of saying the one red answer you are allowed to have. The
accompanying text confirms it: "*If there is a No next to the MySQL query, but yes to the
others, you may proceed with installation.*"

The recommendation text is conditional (`functions.pm:202-225`). Baseline:

> We recommend that you use the **DBM** database for your board. This will be sufficient for small to meduim sized boards. In the future, you might want to consider installing the DBI modules and mySQL.

If `AnyDBM_File` resolved to something other than `DB_File`, it adds:

> We strongly recommend that you build the DB_File on your server. DB_File is the best DBM library currently available. You might run into problems with large posts without it.

That warning is not decorative -- see section 6 on the SDBM 1000-byte record limit. And if DBI
loaded, it adds a nudge toward MySQL.

#### 5.2 Step One: paths, URLs, mail (`act=start`)

Screenshot: the nine-tile `Image8_*.jpg` grid. Thirteen fields, in
`install_modules/start.pl:277-346`. Four of them are the ones that break installs, and
each has a `[?]` link that pops a 1 KB explainer window from `INSTALL_DATA/`
(`cgi_path.html`, `non-cgi_path.html`, `cgi_url.html`, `non-cgi_url.html`):

| Field | Config key | Guessed from |
|---|---|---|
| Your CGI Path? | `IKON_DIR` | `DOCUMENT_ROOT` + `SCRIPT_NAME`, minus the script name |
| Your NON-CGI Path? | `HTML_DIR` | `IKON_DIR` + `/iB_html` -- *usually wrong*, see below |
| Your CGI URL? | `BOARD_URL` | `http://` + `HTTP_HOST` + `SCRIPT_NAME`, minus the script name |
| Your NON-CGI URL? | `IMAGES_URL` | `BOARD_URL` + `/iB_html` |
| Incoming / outgoing email | `ADMIN_EMAIL_IN` / `_OUT` | literal `in-admin@domain.com` / `out-admin@domain.com` |
| Email method | `EMAIL_TYPE` | dropdown: "Send mail (Good for \*NIX)" / "SMTP (Good for NT)" |
| Path to Sendmail | `SEND_MAIL` | filesystem probe of four paths |
| SMTP server | `SMTP_SERVER` | `localhost` |
| Website name / Board name / description / website URL | `HOME_NAME`, `BOARDNAME`, `BOARD_DESC`, `HOME_URL` | `Ikonboard.com`, `Ikonboard`, `Website Forums`, `http://` + host |

The `HTML_DIR` default is the trap. The installer guesses `iB_html` is *inside* the CGI
directory, because that is where it is in the download; on a real host it is in the web
root, two or three levels away. The Installer Guide leads with a warning about exactly
this -- "*If you have CHMOD all your files correctly and your iB\_html is NOT in your
cgi-bin*" -- and the guide's advice for finding the right value is mechanical: take the
CGI path and strip the `cgi-bin` and board-folder components off the end.

Validation is two-pass (`start.pl:56-105`). First, five fields are marked required and
blanks are collected and reported by friendly name. Then trailing slashes are stripped
from all five path/URL fields and `IKON_DIR` and `HTML_DIR` are `-e` tested; if either
does not exist you get a "Possible Path Errors" table showing what you typed next to
`Found` / `Not Found` in red.

Pass that, and `start.pl:136` writes `ikonboard.conf` for the first time and then checks
nine directories for existence and writability (`start.pl:140-149`):

```perl
    my $directories = { BACK_UP    => [ 'c', 'w' ],
                        Data       => [ 'c', 'w' ],
                        Database   => [ 'c', 'w' ],
                        INCOMING   => [ 'c', 'w' ],
                        Languages  => [ 'c', 'w' ],
                        OUTGOING   => [ 'c', 'w' ],
                        Skin       => [ 'c', 'w' ],
                        Sources    => [ 'c', 'w' ],
                        uploads    => [ 'h', 'w' ],
                       };
```

That produces the table in `img\Installation1.jpg` -- nine rows, `Exists?` and `Writable?`
columns, `No` in bold red where it fails, and a PROCEED button only if everything is
green. The Installer Guide's instruction for the failure case is simply: fix it in your
FTP client, hit refresh.

The config writer is worth a look (`functions.pm:409-431`), because it explains something
about the file's permissions:

```perl
    my $file = $iB::OBJ->{tmp_path}.'ikonboard.conf';

    chmod( 0777, $file );

    open (FH, ">" .$file) or die "Cannot write to $file ($!)";

    for my $key (sort { $a cmp $b } keys %{$data}) {
        print FH qq~$key\t\t=\t$data->{ $key }\n~;
    }

    close FH or die $!;

    chmod ( 0644, $file );
```

It chmods to `0777` before writing and back to `0644` after -- so the file the guide told
you to make world-writable stops being world-writable the moment the installer touches it,
and stays that way for the rest of the install because the process owns it.

#### 5.3 Extract (`act=tar`)

Screenshots: `img\Extract.jpg` (the choice) and `img\Installation2.jpg` (the verification
table, despite the guide printing it before `Extract.jpg` -- the guide's ordering does not
match the code's).

The dropdown offers "Extract the tar archives for me" or "No extraction - I have uploaded
the files manually" (`tar.pl:346`). Choosing extraction gets you a second form with two
destructive options (`tar.pl:315-321`):

> **Remove old ikonboard files before installing new?** This will remove any custom skin installations you may have. *(default: Yes)*
>
> **Remove any old databases (members and posts)? This is for DBM users only. If your using SQL select yes.** If you select no, you will need to replace manually all the files contained in the /Database/config/ directory. You will also need to create two new empty directories named *topic_views* and *member_notepads* located in the /Databese directory. *(default: No)*

Typos original. Those two answers become `tmp_REMOVE` and `tmp_RM_DB` in `ikonboard.conf`
and drive `rmtree` calls in `tar.pl:157-167`.

Extraction is then done **one archive per HTTP request**, chained by a meta refresh
(`tar.pl:226`):

```html
<meta http-equiv="refresh" content="3; url=installer.cgi?act=tar&DO=1&id=$real_id">
```

with a "Click here if your browser does not forward you" fallback link underneath. Six
archives, six page loads, three seconds apart. The reason is stated in the form text: "*To
save system resources, this is done in steps with a page refresh in between*" -- that is,
to stay under the shared host's CPU-seconds-per-process limit and avoid being killed
mid-extract. The `id` parameter is the sequence number from the routing table; `Sources.tar`
carries the "last" flag and ends the chain.

Before writing anything, each archive's file list is validated (`tar.pl:184-194`):

```perl
       for (@files) {
           next if $_ eq 'Icon+';
           # Test for illegal characters
           if ($_ !~ /^(?:[\.\w\d\+\-\_\/\\]+)$/) {
                &iB::install_error( "... The tar file did not contain legal file names ..." );
           }
           # Test for embedded paths
           if ($_ =~ m!^$dir[/\\](\S+)[/\\]$dir!) {
                &iB::install_error( "... The tar file contained embedded paths ..." );
           }
       }
```

A whitelist character class and a repeated-directory check. This is a path-traversal
defense in a 2002 hobbyist forum installer, and while the second test is oddly specific
(it looks for the archive's own directory name appearing twice) the first one is a real,
correct whitelist. Worth noting on the credit side of the ledger.

Extraction proper is `chdir $to; ... $tar->extract(@files, $to); chdir $from;`
(`tar.pl:181-196`). The `$to` argument passed to `extract` is inert -- the bundled
`Archive::Tar::extract` signature is `my (@files, $cwd_path) = @_;` (`Archive/Tar.pm:490`),
so `@files` swallows it and `$cwd_path` is always `undef`. The `chdir` is what actually
does the work. It functions, but it functions by accident, and under `mod_perl` a `chdir`
changes the working directory of the whole server process.

The step ends with the table in `Installation2.jpg`: five CGI-side directories plus the
HTML directory, each checked for existence and for whether `readdir` returned anything
(`tar.pl:58-85`).

#### 5.4 Database (`act=database`)

Screenshot: `img\Database.jpg`, with the dropdown open showing four choices. Covered in
detail in section 6. Whichever branch you take ends at `end_setup` (`database.pl:685`), which
calls `functions.pm:write_boardinfo` -- the step that actually creates the board's
configuration module -- and prints the screen in `img\DBComplete.jpg`.

`write_boardinfo` (`functions.pm:433-485`) generates `Data/Boardinfo.cgi` as literal Perl
source:

```perl
print FH <<_END_PRINT_;
package Boardinfo;

  sub new {
    my \$pkg = shift;
    my \$obj = {
_END_PRINT_

    for my $key (sort { $a cmp $b } keys %{$data}) {
        my $space = " " x (20 - (length($key)));
        $data->{$key} =~ s|!|&#33;|g;
        print FH qq~'$key' $space => q!$data->{ $key }!,\n~;
    }
```

Every value is wrapped in `q!...!` and every literal `!` in the data is escaped to `&#33;`
first so it cannot terminate the quote. The file is then chmodded `0644`. This
configuration-as-generated-source pattern is used throughout the admin panel too -- the
same `make_module` idea appears in `Lib/ADMIN.pm`.

Two consequences worth recording. First, `write_boardinfo` dumps *every* key of
`$iB::CONFIG`, which by this point includes the installer's own scratch values --
`tmp_TAR`, `tmp_REMOVE`, `tmp_RM_DB` -- so they get baked into the board's live config and
sit there forever. Second, **the database password is written in plaintext.** It stays
that way until the first request to `ikonboard.cgi`, which detects that no `*.pwd` key
file exists in `Data/`, generates a random 16-character name, and rewrites `Boardinfo.cgi`
with the password ARC4-encrypted and Base64-encoded (`ikonboard.cgi:211-267`). Since this
tree was never installed there is no `.pwd` file here to examine -- that sequence is
reconstructed from the code alone.

#### 5.5 Populate (`act=populate`)

Screenshots: `img\Populate.jpg` (a single paragraph and a PROCEED button) and
`img\PopComplete.jpg`.

`populate.pl` requires the freshly written `Data/Boardinfo.cgi`, opens a database handle
through `iDatabase::SQL` with `allow_create` and `allow_drop` both **0**, and then reads
seven seed files out of `INSTALL_DATA/` and inserts them:

| Seed file | Target table | Contents |
|---|---|---|
| `mem_groups.dat` | `mem_groups` | 4 rows: *Awaiting Authorisation*, *Guests*, *Members*, *Super Administrators*, each a `\|^\|`-delimited list of 28 permission flags |
| `email_template.dat` | `email_templates` | 5.3 KB of outbound mail bodies |
| `board_rules.dat` | `forum_rules` | one row: `00\|^\|Board Rules\|^\|Please respect fellow members...` |
| `news.html` | `ssi_templates` (id `news`) | the SSI news block |
| `ssi_templates.dat` | `ssi_templates` | the remaining SSI blocks |
| `global_template.html` | `templates` (id `global`) | the page wrapper |
| `register.html` | `templates` (id `register`) | the registration page |
| `help.txt` | `help` | 5.3 KB, parsed on `[Section Name]` headers |

The permission flags line up with the defaults in `ikonboard.conf`: `AUTHORISE_GROUP = 1`,
`GUEST_GROUP = 2`, `MEMBER_GROUP = 3`, `SUPAD_GROUP = 4`.

There is one piece of period engineering here worth pointing out (`populate.pl:21-28`):

```perl
if ($AnyDBM_File::ISA[0] eq 'SDBM_File') {
    $limit = 1000;
} elsif ($AnyDBM_File::ISA[0] eq 'NDBM_File') {
    $limit = 4000;
}
```

and then, before inserting the templates, `$text = substr($text, 0, $limit)`. SDBM has a
1024-byte limit on the combined size of a key/value pair and NDBM has a 4096-byte one.
Rather than fail, the installer silently truncates your page templates to fit. That is why
the profiler nags so hard about `DB_File`: install on a host with only SDBM and your board
comes up with a global template chopped at 1,000 characters.

#### 5.6 Create admin (`act=admin`)

Screenshots: `img\CreateAdmin.jpg` (five fields) and `img\AdminCreated.jpg`.

Validation (`admin.pl:60-74`): all five fields non-blank, passwords match, password at
least five characters, email addresses match. The guide adds the advice the form does not
enforce: "*Pick a password that has at least 5 characters. Larger, complex passwords are
highly recommended to prevent someone hacking into your board.*"

The account is then built (`admin.pl:104-108`):

```perl
    my $Time = time;
    my $IdPart = substr($iB::IN{'MEMBER_NAME'}, 0, 1);
       $IdPart = ord $IdPart;
    my $member_id = "$IdPart".'-'."$Time";
    my $member_pass = crypt ($iB::IN{'MEMBER_PASSWORD'}, lc (substr($iB::IN{'MEMBER_NAME'}, 0, 2 )));
```

Member IDs are `<ordinal of first character>-<unix timestamp>` -- so an admin called
`Matt` gets an ID beginning `77-`. Passwords are DES `crypt` with the salt derived from
the lowercased first two characters of the username, which means the salt is public,
guessable, and identical for every account whose name starts with the same two letters.
That is a security matter for another chapter; here it is enough to note that this is the
line that created the credential on tens of thousands of boards.

Two indexes are created (`MEMBER_NAME` and `MEMBER_EMAIL`, both mapping to `MEMBER_ID`),
the row is inserted with `MEMBER_GROUP => 4`, and then the installer shuts the door behind
itself (`admin.pl:160-166`):

```perl
    # Lock down the board:

    open FILE, ">$iB::INFO->{'IKON_DIR'}install.lock";
    print FILE "Go away and annoy someone else";
    close FILE;

	unlink "$iB::INFO->{'IKON_DIR'}ikonboard.conf"; # deleting config file.
```

The final screen suggests what to do next: log in, click "AdminCP", create a category,
create a forum, try a test post.

#### 5.7 The lock, and the board's refusal to start

This is the part that deserves genuine credit.

There are two independent mechanisms, and they point in opposite directions.

**One: the installer locks itself.** `installer.cgi:117-119` checks for `install.lock`
before doing anything else, and if it exists prints a full-page refusal
(`installer.cgi:232-248`):

> **Permission Denied, Installer locked**
>
> As a safety precaution, the installer will lock itself after a successful install. To unlock the installer, please remove the **'install.lock'** file from your ikonboard CGI directory. This installer will not run until you have done so.

So a completed install cannot be re-run and re-populated by a passer-by who finds
`installer.cgi` still sitting in your `cgi-bin`. And because `admin.pl:166` also deletes
`ikonboard.conf`, even removing the lock is not enough -- `functions.pm:load_config` will
fail with "Cannot locate ikonboard.conf" and the wizard will not start. A deliberate
reinstall requires re-uploading the config file from the download.

**Two: the board refuses to run while the installer is present.** `ikonboard.cgi:135-140`,
before any request handling at all:

```perl
if (  (-e $iB::INFO->{'IKON_DIR'}."installer.cgi")
   && (!(-e $iB::INFO->{'IKON_DIR'}."install.lock")) ) {
   &catch_die("FATAL ERROR:<br>The installer (installer.cgi) is still present in the root ikonboard ".
              "directory. Ikonboard will not run until this file is removed!<br>".
              "Please remove it. You may continue when removed by <a href='$ENV{HTTP_REFERER}'>clicking here</a>");
}
```

This is the screen in `img\cgi.jpg`, and the Installer Guide presents seeing it as a
*success* condition: "*You will now either be taken to your board or you will see an error
like below. Either way, you have had a successful installation.*" It then tells you to
delete `installer.cgi`, delete the lock file, and delete the six tarballs.

The logic is: *unlocked installer present* is the dangerous state, and the board hard-fails
rather than serve a single page in it. The lock file is the "I have finished, this is
now merely untidy rather than dangerous" marker, which is why the condition is an AND. In
2002, when the industry norm for a Perl web app was a `README` line saying "remember to
delete install.php," having the application itself refuse to boot in the unsafe
configuration is a notably good piece of design, and it should be recorded as such.

It is not perfect. The check is hardcoded to the literal string `installer.cgi`, so a
Windows installation renamed to `installer.pl` -- which the guide explicitly tells Windows
users to do -- is not covered at all. And the lock file's existence is what suppresses the
error, so an operator who deletes the lock but not the installer gets their board back
into the state the check exists to prevent. Still: the instinct was right, and it was
right early.

---

### 7.6 Choosing a backend

There are **four** selectable backends. The dropdown in `img\Database.jpg` offers them in
this order (`install_modules/database.pl:44`):

```
DBM Database          <-- pre-selected default
mySQL Database
PostgreSQL Database
Oracle Database
```

So the operator's real choice in 2002 was: the flat-file option that needs no database
server at all, or one of three that do. DBM being the pre-selected default is the right
call for this audience -- the majority of people downloading a free Perl forum in 2002 had
no database and no way to get one, and the wizard's happy path should not require them to
understand the question. The Installer Guide reinforces it:

> This is where you select your database. A majority of users will simply leave the default DBM Database as the choice and proceed. You can upgrade to other databases later on if your host provides it should you change your mind or have a very large, active board..

A fifth driver file, `Sources/iDatabase/Driver/CSV.pm`, ships in `Sources.tar` but is not
offered by the installer, is not reachable through the config, and could not have worked if
it were. It is covered at the end of this section for completeness; it was never one of the
choices.

#### DBM -- the no-database option, and what almost everybody chose

`setup_dbm` (`database.pl:76-90`) does essentially nothing: it confirms
`Sources/iDatabase/SQL.pm` will load, writes `DB_DRIVER` and `DB_DIR` into the config, and
goes straight to `end_setup`. There is no connection to test, no schema to create, no
credentials to get wrong. That is the entire point: you took this branch because you did
not have a database server, and every question the other three branches ask is a question
you could not have answered.

Physically, a DBM board is the `Database/` directory: one subdirectory per table
(`member_profiles`, `forum_posts`, `forum_topics`, `active_sessions`, `templates`, and so
on -- 30 of them), plus `Database/config/` holding a `.cfg` file per table that declares the
schema in Perl. `member_profiles.cfg` opens:

```perl
package IMPORT;

$STRING = { "TABLE"   => "member_profiles",
            "P_KEY"   => "MEMBER_ID",
            "INDEX"   => {
                            'MEMBER_NAME'  => 'MEMBER_ID',
                            'MEMBER_EMAIL' => 'MEMBER_ID',
                         },
          };

%{ $COLS } = (        "MEMBER_ID"           => [0 ,  'string',    32, 1],
                      "MEMBER_NAME"         => [1 ,  'string',    32, 1],
                      "MEMBER_GROUP"        => [2 ,  'num'   ,    2 , 1],
```

Cost of choosing it: no SQL, so no `phpMyAdmin`, no external reporting, no easy bulk
edits. Locking is advisory `flock` (there is a `FLOCK` setting in `ikonboard.conf`) on a
shared host where `flock` may or may not be honored over NFS. And the record-size ceiling
if you got NDBM or SDBM instead of DB_File (section 5.5). Benefit: it worked on every host that
could run Perl at all, with no extra money and no support ticket.

#### mySQL -- what you moved to when the board got big

`setup_mysql` (`database.pl:96-159`) collects username, password, database name, server,
optional port, and a table prefix defaulting to `ib_`. It also offers to *not* create the
tables:

> **Allow Ikonboard to create the needed tables?** You may choose 'No' and use phpMyAdmin to create the tables. Use "/INSTALL_DATA/mysql_schema.txt" as your guide.

`_create_mysql` (`database.pl:162-288`) then runs a four-stage check, each with its own
error message: is DBI loadable; does `DBI->available_drivers` include something matching
`mysql`; does `DBI->connect` succeed; and does `show databases` list the database you
named. Only then does it read `INSTALL_DATA/mysql_schema.txt`, split it on `;\n`, rewrite
the `ib_` prefix if you changed it, and execute the 29 `CREATE TABLE` statements one at a
time, collecting errors as it goes.

The table-prefix rewrite is a plain regex substitution:

```perl
            $SQL =~ s/CREATE TABLE ib_(\w+) \(/CREATE TABLE $iB::IN{'DB_PREFIX'}$1 (/ig;
```

which handles the `CREATE` statements and nothing else -- which is fine, because in this
schema nothing else references a table by name.

Cost: money, usually. Benefit: real indexes, real concurrency, `phpMyAdmin`, and a
database you could back up independently of the board.

#### PostgreSQL -- contributed, and it shows

`setup_pgsql` / `_create_pgsql` (`database.pl:295-474`) are credited in a comment:

```perl
####################################################
# SET UP pgSQL By Infection
####################################################
```

The code has a different accent from the surrounding module -- a `foreach` with a `# coolest`
comment on it, a helpful diagnostic that fires when Postgres says the database is missing:

```perl
		push @fatal_errors, "Use psql for creating database \"$iB::IN{'DB_NAME'}\" before them" if($DBI::errstr=~/does not exist in the system catalog/);
```

It also carries a copy-paste bug: the Postgres branch opens filehandle `PGSQL` and then
closes `MYSQL` (`database.pl:415-417`). Harmless in practice -- the enclosing block scopes
`$/` and the process is about to move on -- but it is a fingerprint of how the branch was
made.

#### Oracle -- the strange one

Oracle support in a free-to-download hobbyist bulletin board is genuinely unusual, and
worth pausing on. In 2002 nobody was running a fan forum on Oracle. The driver
(`Sources/iDatabase/Driver/Oracle.pm`, 1,112 lines) exists because somebody wanted it --
the installer credits `# SET UP Oracle; Oracle Driver By Andrey Prokopenko` -- most
plausibly to put a departmental forum on a database the enterprise already licensed and
the DBA already backed up. It is a contribution accepted rather than a market pursued, and
Ikonboard's team said so on the form itself (`database.pl:506`):

> NOTE: Oracle support has NOT been tested and is provided as-is.  Most likely you will make some changes to the code for it to work.

They were not exaggerating. The Oracle branch has three defects visible on inspection:

1. **The form's field labels were never changed.** Every one still says "mySQL"
   (`database.pl:512-526`): "*Your mySQL Username*", "*Your mySQL Password*", "*Your mySQL
   Database Name*", "*Your mySQL Database Server*".
2. **The last option is truncated in the shipped source.** `database.pl:538` reads
   `<option value='n'>No, I hav$` -- the line is cut off mid-word, mid-tag, in the file as
   distributed. The generated HTML is malformed.
3. **`setup_oracle` calls the wrong handler.** `database.pl:485-487`:

```perl
    if ($iB::IN{'create'}) {
        return $obj->_create_mysql();
    }
```

   `_create_oracle` exists, immediately below it, and is **never called from anywhere**.
   Submitting the Oracle form runs the MySQL creation path, which builds a
   `DBI:mysql:...` DSN and reads `mysql_schema.txt`. An Oracle install cannot succeed
   through this wizard.

   And `_create_oracle`, had it been reachable, would not have worked either: it issues
   `show databases` (`database.pl:596`), which is MySQL syntax, and its error message on
   `CREATE TABLE` failure still says "mySQL create table error" (`database.pl:629`).

`INSTALL_DATA/oracle_schema.txt` is real, though -- 12.7 KB, 29 `CREATE TABLE` statements,
same as the other two. So the schema work was done; only the wizard wiring was not.

#### CSV -- a file in the box, not a backend

For completeness, since the file is in the distribution and its name invites the
assumption that there were five backends: `Sources/iDatabase/Driver/CSV.pm` is 1,014 lines
of abandoned work, and four independent things establish that it was never a usable option.

**1. It is not in the menu.** `database.pl:44` lists four `<option>` elements and
`functions.pm:120-131` dispatches on exactly four values (`DBM`, `mySQL`, `pgSQL`,
`Oracle`). There is no path through the wizard that writes `DB_DRIVER = CSV`.

**2. It does not declare the right package.** Every working driver names itself for its
slot; CSV does not:

| File | `package` line |
|---|---|
| `Driver/Base.pm` | `package iDatabase::Driver::Base;` |
| `Driver/DBM.pm` | `package iDatabase::Driver::DBM;` |
| `Driver/mySQL.pm` | `package iDatabase::Driver::mySQL;` |
| `Driver/pgSQL.pm` | `package iDatabase::Driver::pgSQL;` |
| `Driver/Oracle.pm` | `package iDatabase::Driver::Oracle;` |
| **`Driver/CSV.pm`** | **`package iDatabase;`** |

`iDatabase::SQL` builds the class name as `"iDatabase::Driver::$args{DB_DRIVER}"` and calls
into it (`Sources/iDatabase/SQL.pm:45-55`), so even if the file loaded, there would be no
`iDatabase::Driver::CSV` to find.

**3. It is missing the driver contract.** The four real drivers each carry two `@ISA`
statements and a `sub newSQL`; CSV has neither -- zero `@ISA`, no `newSQL`. And there is no
matching search backend: `Sources/Search/API/` contains `api_DBM.pm`, `api_mySQL.pm`,
`api_pgSQL.pm`, `api_Oracle.pm` and `api_global.pm`, and nothing for CSV.

**4. It does not compile, and never could have.** Line 471:

```perl
        for (@ids) {
            push @keys, $_->{$obj->{'cur_p_key'};
        }
```

The subscript is missing its closing brace -- a hard syntax error in every version of Perl 5
that has ever existed. Confirmed by `perl -c`:

```
Global symbol "@keys" requires explicit package name (did you forget to declare "my @keys"?)
  at Sources/iDatabase/Driver/CSV.pm line 471.
syntax error at Sources/iDatabase/Driver/CSV.pm line 472, near "}"
```

The reading that fits all four: CSV was started, got most of the way to a driver, was
abandoned before it was wired into the abstraction layer, and was left in the `Driver/`
directory when `Sources.tar` was rolled. It is an artifact of development, and the only
reason it belongs in this chapter is to close off the question.

#### The practical advice of the era

| You had | You picked | What it cost you |
|---|---|---|
| Cheap shared hosting, no database at all | **DBM** (the default) | No external tooling; `flock` reliability on NFS; SDBM/NDBM truncation if `DB_File` was missing |
| Shared hosting with a MySQL add-on | **mySQL** | A few dollars a month; one more thing to back up; a `DB_PASS` sitting in a file in your `cgi-bin` |
| A box you controlled, or a Postgres shop | **pgSQL** | Fewer other users to ask for help; less-trodden code path |
| An Oracle license and a reason | **Oracle** | It does not install. Expect to patch `database.pl` yourself, exactly as the form warns |

Four options, and for most of the audience the honest count was one: DBM, pre-selected,
next.

---

### 7.7 `Tools\`

Seven scripts in five directories. The readme describes the folder as "*an assortment of
tools which maybe be needed in installing, running, or maintenance of ikonboard*," which is
accurate and undersells the danger.

The pattern shared by five of the seven: upload to the board directory, `chmod 0755`, hit
the URL, and delete afterwards. None of them authenticates. All of them are named
predictably. The headers say to delete them; nothing enforces it.

#### `HELP\perl_test.cgi` -- "is Perl even working"

**Risk: none.** 103 lines, no dependencies beyond core. Six `eval { require ... }` probes
(Perl 5, `DB_File`, `CGI`, `DBI`), `$]` for the version, `Cwd::cwd()` for the path, and a
twelve-entry filesystem probe for a sendmail binary. Prints the block reproduced in section 2.

This is the first thing you ran and the only one you ran *before* uploading anything else.
Its diagnostic value is as much in *failing* as in succeeding: if the browser shows you
Perl source, CGI is not enabled in that directory; if you get a 500, your permissions are
wrong. Its accompanying `read_me.txt` is signed and dated, the only personally signed
document in the package:

```
--Matt Mecham (<matt@ikonboard.com>)

29/5/01
```

Note the date format -- 29/5/01, day-first, the British author showing through.

#### `create_indexes.cgi` -- rebuild the member indexes

**Risk: moderate, and it deletes rows.** Creates the `MEMBER_EMAIL` and `MEMBER_NAME`
indexes on `member_profiles`, then walks every member row, lowercases the email, and
writes both index entries. This is a repair tool: the same two `create_index` calls run
during installation (`admin.pl:111-119`), so you reached for this after an import, a
conversion from iB2, or index corruption. Its own blurb:

> This convertor will speed up Ikonboard on new registrations and log in. It creates 2 new indexes, MEMBER_EMAIL, contains the email addresses of all the members, MEMBER_NAMES contains all the member names.

It is not purely additive. `create_indexes.cgi:129-133`:

```perl
        if ($m->{'MEMBER_ID'} =~ /^32-.+?$/) {
            push @delete, $m->{'MEMBER_ID'};
            print "DELETE: $m->{'MEMBER_NAME'} (ID: $m->{'MEMBER_ID'} )<br>";
            next;
        }
```

`32` is `ord(' ')`. Any member whose name begins with a space is collected and then
**deleted** from `member_profiles` (`:151-155`), with no prompt. Presumably a cleanup for
junk rows produced by a converter, but a tool advertised as an index rebuilder should not
be deleting member accounts.

It also has two coding defects. Line 38 is `use "Boardinfo.cgi";` -- `use` takes a bareword
module name, not a string, so this is a syntax error and the script cannot run as shipped.
And lines 72-76 declare `my $create` and `my $drop` twice each, in the same scope. Whoever
merged the 3.1 ARC4 password-decryption block into Matt Mecham's original ("*Modified by
Camil to run on 3.1*") pasted over the top of the existing lines and did not test it.

#### `rm_old_install.cgi` -- undo an extraction

**Risk: high. Destroys the application, one GET request, no confirmation beyond a link.**

```perl
sub run {
    my $message;

    rmtree "$iB::PTH/Data";
    rmtree "$iB::PTH/Database";
    rmtree "$iB::PTH/Languages";
    rmtree "$iB::PTH/Sources";
    rmtree "$iB::PTH/Skin";
    rmtree "$iB::INFO->{'HTML_DIR'}non-cgi";

    print "All done, you can now reinstall iB3";
```

Six recursive deletes. `Database` is in that list, so despite the description --

> This script will remove all unpacked tar files, it won't remove the tar files themselves, just the directories and files created by the installer.

-- **it deletes your data too** if you are on DBM, because on DBM the data *is* the
extracted directory tree. It also deletes `Data/`, which contains `Boardinfo.cgi` and the
`.pwd` key, so an SQL board loses the ability to decrypt its own stored database password.

The "confirmation" is a hyperlink to `rm_old_install.cgi?act=run`. A GET. Anything that
follows links -- a crawler, a link-prefetching browser, an accelerator proxy, an antivirus
URL scanner -- will fire it.

#### `rm_Database.cgi` -- wipe the board's data

**Risk: maximal. Two lines of code, one GET, no confirmation.**

```perl
sub run {
    rmtree $iB::INFO->{'DB_DIR'};
    mkdir ($iB::INFO->{'DB_DIR'}, 0777);
    print "Database Directory Emptied";
```

`DB_DIR` is `<board>/Database`. On a DBM board this is every post, every topic, every
member, every private message, gone, with an empty world-writable directory left in its
place. On an SQL board it removes the `config/` table definitions and the `Temp/` staging
area but leaves the SQL tables -- the script's own text does not make that distinction:

> This script will remove your current, populated Database - only do this if you want to clobber all of your database data!
>
> **REMOVE THIS SCRIPT AFTER USE!**

The header comment block in this file is a straight copy of `rm_all_backups.cgi`'s -- it
identifies itself as `"rm_all_backups.cgi"` and says "THIS SCRIPT REMOVES ANY BACK UPS
CURRENTLY IN THE BACK_UP DIRECTORY." The line it inherited is nonetheless the most
accurate thing in the file:

```
#| Use with EXTREME Caution. You only get asked once if you wish to proceed
```

"Asked once" means one hyperlink.

#### `rm_all_backups.cgi` -- empty `BACK_UP/`

**Risk: high, but bounded.**

```perl
    $iB::INFO->{'BACKUP_DIR'} ||= $iB::INFO->{'IKON_DIR'}."BACK_UP";
    rmtree $iB::INFO->{'BACKUP_DIR'};
    mkdir ($iB::INFO->{'BACKUP_DIR'}, 0777);
```

Deletes every export the admin panel has ever written and recreates the directory `0777`.
Its purpose is real: `Admin/Backup.pm` writes exports as `BACK_UP/EXPORT-<timestamp>/`
directories full of `tmp_*` files plus a `.tar`, and on a 2002 hosting quota of 50-100 MB
a few full exports would fill your account. This is the tool you ran when the board
started failing to write posts because the disk was full.

Same GET-link "confirmation." Same warning: "*Download any that you want to save before
running this script!*"

#### `restore_admin.cgi` -- the lockout recovery path

**Risk: this is a back door, and the file says so.**

The purpose is legitimate and the need is real: you demoted your own account, or deleted
it, and you can no longer reach the AdminCP. Without a shell you cannot fix the row by
hand. So the vendor shipped a CGI that does it.

**It performs no authentication of any kind.** There is no password field, no session
check, no `$iB::MEMBER` lookup, no shared-secret constant to edit, no IP restriction. The
entire access control is: the file must be present on the server. The whole of `run()`
that matters is:

```perl
sub run {

    die "No name specified!" unless $iB::Q->param('NAME');

    my $name = &_clean_value($iB::Q->param('NAME'));
    ...
    $db->update( TABLE  => 'member_profiles',
                 WHERE  => "MEMBER_NAME eq '$name'",
                 VALUES => { MEMBER_GROUP => $iB::INFO->{'SUPAD_GROUP'} }
               );
```

Anyone who can reach the URL can type any username into a text box and promote that
account to `SUPAD_GROUP` -- group 4, Super Administrator, every permission flag set. If
they can also register, they can promote their own new account. The board's own file
manager, SQL client, DBM client, template editor and skin editor are all behind the
AdminCP, so this one request is a complete compromise of the board and, through the file
manager, of anything else in the account.

The author knew exactly this. The header comment is unusually blunt
(`Tools\restore_admin.cgi:23-25`):

```
#| VERY IMPORTANT!!!
#| Make sure you remove this script after use, there is NO protection of ANY kind
#| Leaving it on your server is A) Stupid, B) Stupid and C) Stupid
```

and the page repeats it in red at 4-point-larger type -- "*>>>>> REMOVE THIS SCRIPT AFTER
USE! <<<<<<*" -- and again on the success page: "*DELETE THIS SCRIPT FROM YOUR SERVER
NOW!!*".

Assessment: the design is not careless so much as it is a deliberate trade. Given FTP-only
access, a recovery tool that authenticated against the board would be useless in exactly
the case it exists for (you are locked out of the board). Given no shell, a
command-line-only tool would be equally useless. Presence-on-disk *is* the authentication
factor, and it is a real one -- you need FTP credentials to put the file there. The failure
is that the factor is only valid while you are watching it: the moment the operator
forgets step three, a permanent unauthenticated privilege-escalation endpoint at a
guessable filename is live. The file name is fixed, the form field is fixed, and the
request is a single POST.

Two smaller notes. The script does escape the input -- `_clean_value` maps `'` to `&#39;`
before it reaches the `WHERE` string -- so a quote cannot break out of the expression. And
its `$iB::PTH` defaults to `'.'` with a commented-out override that carries a Windows
path, `#$iB::PTH = 'E:\inent\wwww\htdocs\ikonboard';`, which is presumably a developer's
own machine and contains a typo.

#### `mod_perl\start_up.pl`

**Risk: none; requires server config you probably did not have.** A `PerlRequire`
startup script: compile `CGI.pm` with `CGI->compile(':all')`, add the board's `Sources` to
`@INC`, and try to preload `iPerl::mod_perl`. It ships with a real path from the author's
own test server still in it:

```perl
use lib ("/home/ikonboar/public_html/z_test/Sources");
```

`ikonboard.cgi` is written to be mod_perl-tolerant throughout (`use constant IS_MODPERL`,
`*iB::exit = IS_MODPERL ? \&Apache::exit : sub { CORE::exit }`, the explicit re-zeroing of
package globals at the top of every request), which was ambitious for 2002. Almost no
shared host offered mod_perl, so this file was aspirational for most of the audience.

#### `writing_hacks\module_template.pm`

**Risk: none.** A 200-line annotated skeleton for writing a new board module, aimed at
someone who has never written Perl. It walks through the `BEGIN` require of `Lib/FUNC.pm`,
the `new` constructor, the `%Mode` dispatch table keyed on the URL's `CODE` parameter, and
finishes with a POD stub. The commentary is chatty in the house style -- "*Don't worry
about how it does that, just accept that it does :D*", "*Seeing a pattern? :D*" -- and it
is, considered as documentation, better written than either of the install guides. It also
contains at least one bug of its own (`my $raw_date = @_;` assigns the count, not the
value), which is a nice reminder that the hack culture around this board was learning Perl
from the board itself.

---

### 7.8 Day-to-day operation

Once installed, everything happens in a browser.

#### Getting into the control panel

There is no separate admin URL. The AdminCP is reached by adding `AD=1` (or `CP=1`) to a
normal board request, and `ikonboard.cgi` branches on it before doing anything else
(`ikonboard.cgi:396-401`):

```perl
    if ($iB::IN{'AD'} or $iB::IN{CP}) {
        require Admin::Functions;
        my $ad = Admin::Functions->new();
        $ad->process($db);
        return "0 but true";
    }
```

The two names exist for a period-specific reason, explained in the comment just above:

> As the admin link has "AD=1" in it, some firewalls/banner blockers will produce a blank page, not what we want. As Ikonboard 3 has used AD=1 since day 1, I don't want to have to weed through the code looking for every single instance it's been used, so we merely use perls' excellent reg-ex to turn AD into CP.

Ad-blockers of 2002 pattern-matched `AD=` in query strings. The fix is one line at
`ikonboard.cgi:180`: `$iB::IN{AD} ||= $iB::IN{CP};`.

Entry then requires three things (`Admin/Functions.pm:45-100`): you must be logged in;
your member group must have the `ACCESS_CP` flag; and there must be a current admin
session, recorded as a file at `Database/Temp/admin-<MEMBER_ID>.cgi` containing a
timestamp, which expires after 480 minutes and is refreshed on every admin request. If
`ACCESS_CP` is missing, the attempt is logged to
`Database/Temp/log-<MEMBER_ID>.cgi` and -- notably -- the member's `ALLOW_POST` is set to 0.
Poking at the admin panel got you silently muted.

The second gate is weaker than it looks. The re-login form collects `UserName` and
`PassWord` (`Admin/SKIN.pm`, log_in), but `dologin` (`Admin/Functions.pm:152-178`) only
checks that you are a logged-in member and that `UserName` is non-empty before writing the
admin session file; `PassWord` is never read. The real gate is the `ACCESS_CP` group flag,
which is checked on the following request. Worth flagging here because operators of the
era believed the AdminCP had its own password.

The panel is a frameset with ten sections, which you can enumerate from the `WHERE =>`
argument every admin screen passes to the output wrapper:

| Section | Screens | What lived there |
|---|---|---|
| `OPTIONS` | 28 | board settings, registration, email, posting rules, word filter |
| `STYLES` | 26 | skin and template editing |
| `MEMBERS` | 26 | member search/edit, groups, titles, authorisation queue |
| `FORUMS` | 16 | forum and permission editing |
| `DATABASE` | 13 | export, import, conversion |
| `MAINTAIN` | 9 | temp files, logs, stats rebuild, tools |
| `CATS` | 8 | categories |
| `SQLCLIENT` | 6 | raw SQL console |
| `LANGUAGES` | 4 | language pack editing |
| `MODERATE` | 2 | moderator controls |

#### Backups

`Sources/Admin/Backup.pm` (Database -> Export). The form asks three questions
(`Backup.pm:73-102`): create a `.tar`; how many topics to export at a time; how many files
to put in each tar.

The batching is the interesting part, and it is the same idea as the installer's
meta-refresh chain: an export of a large forum is broken into many small HTTP requests, so
that no single CGI invocation runs long enough to be killed by the host. It writes into
`BACK_UP/EXPORT-<timestamp>/` as a swarm of intermediate files --
`tmp_forums`, `tmp_topics-<n>`, `tmp_fposts-<n>-<topicid>`, `message_data.txt` -- and then
tars them (`Backup.pm:419-431`), chmodding each file `0777` on the way past
(`Backup.pm:447`). The archives were then yours to download over FTP, which was the whole
point; there was no cron, no offsite, no automation. Backup was a thing you remembered to
do.

One defect worth recording, since this chapter is about operations. The super-admin gate
on the export module is **commented out** (`Admin/Backup.pm:404-408`):

```perl
    # Only allow super admins access...

    #unless ($iB::MEMBER->{'MEMBER_GROUP'} == $iB::INFO->{'SUPAD_GROUP'}) {
    #    $ADMIN->Error( DB => $db, STD => $std, MSG => "Sorry, only the board owners have access to this part");
    #}
```

The equivalent check in `Admin/Tools.pm:444-446` is live. So on a board where you had
granted `ACCESS_CP` to a moderator team -- the normal reason to have member groups at all --
any of them could export the complete database, including the member table, and download
the tar. Whether this was disabled deliberately or in debugging and never restored cannot
be determined from the file.

#### Pruning

Ikonboard has no scheduler, so "pruning" happens opportunistically inside ordinary
requests. The settings live in `ikonboard.conf` and are editable from Options:

| Setting | Default | Meaning |
|---|---|---|
| `PRUNE_DAYS` | 30 | topic cut-off, settable globally and per forum (`Admin/ForumControl.pm:690`) |
| `MSG_PRUNE_DAYS` | 30 | private messages older than this are cleared |
| `AUTHORISE_PRUNE` | 30 | days a pending registration may sit before deletion |
| `HISTORIC_LIMIT` | 60 | days of post-marker history to retain, "pruned automaticaly" per the Options label |
| `SESSION_EXPIRATION` | 3000 | session lifetime |

`Admin/Index.pm:39-60` runs `clean_registrations` when the admin home page loads: it
queries the `authorisation` table for rows older than `AUTHORISE_PRUNE` days and deletes
the corresponding member and calendar rows. The admin home then reports what it did -- "*N
members being pruned from the database* (showing last 10)". Loading your control panel was
the cron job.

Because the prune passes take locks, and because a CGI killed mid-pass leaves the lock
behind, the panel has a screen just for deleting stale lock files (see below).

#### Maintenance chores

`Sources/Admin/Tools.pm` (Maintain -> Tools) holds four one-off repair jobs, gated to super
administrators only:

| Tool | What it does |
|---|---|
| **Remove Member Group Dupes** | de-duplicates member group rows, asking whether to favor higher or lower IDs ("*Higher ID's are preferable, there is a greater chance of the dupes being the higher ID value*") |
| **Remove Custom Member Titles** | clears a named custom title from every member. Its help text names the cause: "*This is handy for those who converted iB2 members and found that the member titles won't advance*" |
| **Styles/Skin Control** | rebuilds the `.cfg` files that back the skin HTML editor -- "*This is useful if when editing the HTML, the text areas appear blank, or if you've edited the perl modules by hand*" |
| **Calendar cleanup** | removes calendar events pointing at forums that no longer exist |

Three of the four exist to clean up after something else went wrong. That is a fair
summary of forum administration in 2002.

`Sources/Admin/Tempfiles.pm` (Maintain -> Temporary Files) is the other routine screen. It
reports how many temp files exist and offers checkboxes to remove: search log files, the
calendar update lock, the members prune-historic lock, the online-list SSI generation lock,
the messenger prune lock, the calendar SSI generation lock, and -- with their sizes shown in
KB -- the error log, email log and notice log. Every one of the locks in that list is a file
that only exists because a previous request died holding it.

`Sources/Admin/Stats.pm` (Maintain -> Rebuild Board Stats) recomputes the front-page
counters: total members from `$db->count`, total topics and replies summed across
`forum_info`, and the last-registered member from a one-row sorted query. Its help text
states the ordering dependency plainly:

> Only do this after a forum recount, it will recount the total members as well as the post/reply counters

The forum recount itself is separate -- `Admin/ForumControl.pm:223` / `:433`, per-forum,
recounting topics and posts from the actual rows. The rule an admin learned was: recount
the forums first, then rebuild the stats, or the board index will show numbers that
disagree with the forums.

Everything the panel does is written to a moderation/admin log
(`$ADMIN->write_log( TITLE => 'Statistics Rebuilt')` and equivalents), viewable and
deletable under Maintain.

---

### 7.9 Running it today (2026)

The honest answer: **the board is much closer to running on a modern Perl than its age
suggests, and the two things that would break it are three characters each.**

#### Test setup

Perl 5.26.2 (`C:\Program Files\Git\usr\bin\perl.exe`) was the only interpreter available on
this machine -- an msys build that lacks `CGI`, `DB_File` and `DBI`, but has `Archive::Tar`
2.24. No period Perl (5.005 / 5.6) was available, so nothing below is a claim about how
this code behaved in 2002; it is a claim about how it behaves now.

Because the tree was never installed there is no `Data/Boardinfo.cgi`, and 82 of 179 Perl
files `require` it transitively. A minimal stub `Boardinfo.cgi` was written into a
throwaway copy of the tree so that compilation could proceed; nothing under
`ib311/` was modified. Each module was then loaded the way the application loads
it -- by its `@INC`-relative name, e.g. `require 'Admin/Options.pm'` with `./Sources` on the
path -- rather than by file path, because path spelling matters here (see "circular
requires" below).

Result: **142 of 169 modules load clean on Perl 5.26.2.** The 27 failures break down as
follows.

#### A. Missing dependencies -- 15 files, not the software's fault

| Missing | Files affected |
|---|---|
| `DB_File` (via `AnyDBM_File`) | 6 -- the DBM driver, the DBM search API, `Convert_ib.pm`, `iPerl/mod_perl.pm`, and the installer's `install_modules/*` |
| `DBI` | 4 -- `mySQL.pm`, `pgSQL.pm`, `Oracle.pm`, `Admin/SQLclient.pm` |
| `CGI` / `CGI::Carp` | 4 -- `ikonboard.cgi`, `installer.cgi`, `iDatabase/Admin/a_mySQL.pm`, `a_Oracle.pm` |
| `Compress::Zlib` XS object | 1 -- the bundled `Sources/Compress/Zlib.pm` |

`DB_File` and `DBI` are still available and still work; this particular msys Perl just does
not ship them. **`CGI.pm` is the one real change:** it was removed from the Perl core in
5.22 (2015). Every install of Ikonboard from 2015 onward requires an explicit
`cpanm CGI` first, and the board's diagnostic for its absence -- the installer's own
`Can't locate ...` help text -- will point you at the wrong conclusion, since in 2002 a
missing `CGI.pm` meant a broken host rather than a normal one.

The bundled `Compress::Zlib` cannot load anywhere without the compiled object, but as
established in section 3 the vendor already disabled compression in their `Archive::Tar` copy, so
nothing calls it. It is dead weight in the distribution, not a blocker.

#### B. Fatal on modern Perl, fine on old Perl -- 2 files

This is the real answer to "what breaks."

**Unescaped left brace in a regex.** Deprecated in Perl 5.20 (2014), a fatal error from
5.26 (2017) onward. Two occurrences, both reproduced here:

```
Unescaped left brace in regex is illegal here in regex; marked by <-- HERE in
  m/\A{ <-- HERE (.+?)=(.+?)}\Z/ at Sources/Admin/Options.pm line 1495

Unescaped left brace in regex is illegal here in regex; marked by <-- HERE in
  m/\$iB::INFO->{ <-- HERE 'IMAGES_URL'}/Skin/.+?// at Sources/Admin/SkinHandler.pm line 610
```

`Admin/Options.pm:1495` and `:1501` are the word-filter parser -- the code that reads
`{word}` syntax out of the admin form and turns it into the `WORD_FILTER` config string:

```perl
        for ( split (/<br>/, $iB::IN{'WORD_FILTER'}) ) {
            if (/\A{(.+?)=(.+?)}\Z/) {
                $word .= $1.":e:".$2."|";
            }
```

`Admin/SkinHandler.pm:610` (and `:337`, same construct) is the skin-rename rewriter, where
the `$` is backslash-escaped precisely so the literal text `$iB::INFO->{'IMAGES_URL'}`
appears in the pattern -- dragging an unescaped `{` in with it:

```perl
		$sk =~ s!\$iB::INFO->{'IMAGES_URL'}/Skin/.+?/!\$iB::INFO->{'IMAGES_URL'}/Skin/$new_skin/!g;
```

Consequences if you ran this today on stock Perl: **the board itself would come up.**
Neither file is on the request path for reading or posting. `Admin/Options.pm` is the
AdminCP's `ops` section -- the largest module in the codebase at 2,298 lines, and where
essentially every board setting is edited -- so the entire Options area of the control panel
would 500. `Admin/SkinHandler.pm` would take the skin management screens with it.

The fix is to escape three braces: `\{` in each of the three patterns. That is the total
extent of the modern-Perl regex problem in 72,000 lines.

#### C. Defects that were always defects -- 2 files

**`Sources/iDatabase/Driver/CSV.pm:471`** -- the missing `}` documented in section 6. Never
compiled, on any Perl, ever.

**`Sources/Admin/Import.pm:801`** -- `iB::exit;` as a bareword under `use strict`:

```
Bareword "iB::exit" not allowed while "strict subs" in use at Sources/Admin/Import.pm line 801
```

This one is subtler than it looks, and it is not a modern-Perl issue. The call is legal
*only* because `ikonboard.cgi:92` executes `use subs qw(exit);` inside `package iB;`, which
forward-declares `iB::exit` and makes the bareword resolvable in every module compiled
afterwards. Verified directly:

```
$ perl -e 'package iB; use subs qw(exit); package Foo; use strict;
           eval q{ sub x { iB::exit; } }; print $@ ? "ERR: $@" : "COMPILES OK\n";'
COMPILES OK

$ perl -e 'package Foo; use strict;
           eval q{ sub x { iB::exit; } }; print $@ ? "ERR: $@" : "COMPILES OK\n";'
ERR: Bareword "iB::exit" not allowed while "strict subs" in use
```

So `Admin/Import.pm` works under the board and fails under any tool that loads it
standalone. It is a load-order dependency masquerading as a syntax error.

#### D. Load-order artifacts -- 10 files, not runtime bugs

Ten modules fail in isolation and are fine in the application:

- **Seven skin-view requires.** `Sources/Profile.pm:17`, `Warn.pm:7`, `Posters.pm:22`,
  `Misc/AOL.pm:26`, `Misc/ICQ.pm:27`, `Misc/MSN.pm:27`, `Misc/Invite.pm:29` all do
  `require $iB::SKIN->{'DIR'} . '/XView.pm'` at module scope, which resolves to
  `/XView.pm` when `$iB::SKIN` has not been populated. At runtime `ikonboard.cgi:338` sets
  it before any of these load.
- **Three circular-require casualties.** `Sources/Lib/FUNC.pm:1453` requires
  `iTextparser.pm`, which requires `Lib/FUNC.pm` straight back. This is safe **only if
  every requirer spells the path identically** -- `%INC` is keyed on the string, so
  `require 'Lib/FUNC.pm'` and `perl -c Sources/Lib/FUNC.pm` are two different entries and
  the file gets compiled twice. On the second pass, `FUNC::STD::ForumJump` is already
  defined with its `($)` prototype, and `ForumJump->new()` at `FUNC.pm:692` is then parsed
  as a call to the prototyped sub rather than as a class method:

  ```
  Not enough arguments for FUNC::STD::ForumJump at Sources/Lib/FUNC.pm line 692, near "ForumJump->new"
  ```

  Loaded the way the board loads it, it is clean:

  ```
  $ perl -I. -I./Sources -I./Data -e 'require "Lib/FUNC.pm"; print "LOADED OK\n";'
  LOADED OK
  ```

  Confirmed by A/B: deleting the `($)` prototype from `FUNC.pm:681` makes the
  double-compile succeed too. `Admin/SKIN.pm` and `Lib/ADMIN.pm` fail standalone for the
  same reason and load fine in sequence.

  This is not currently a bug. It is a fragility: the codebase is one inconsistent
  `require` string away from a confusing failure, and someone modernizing it would trip
  over this within the hour.

#### E. Constructs specifically checked for, and not found

The chapter brief asked about a list of Perl-5-isms that no longer compile. For the record,
searched across all 179 Perl files:

| Construct | Status in this codebase |
|---|---|
| `defined(@array)` / `defined(%hash)` (fatal since 5.22) | **not present** |
| `$*` (multiline flag, removed) | **not present** |
| Pseudo-hashes / `use fields` (removed in 5.10) | **not present** |
| `$[` (array base) | **not present** |
| `goto &sub` | present only in the bundled `Compress::Zlib` AutoLoader stubs (`:88`, `:95`) -- standard, still legal |
| `goto LABEL` | 20+ sites (`database.pl` uses `goto 'ERRORS'` seven times; `tar.pl:162`, `Admin/Category.pm`, `Admin/dbHandler.pm`). Ugly, still legal |
| Bareword filehandles + two-argument `open` | everywhere -- 187 `open` sites across 46 files. Still legal; discouraged; a security matter rather than a compatibility one |

So the "removed features" list, which is what usually kills 2002 Perl, is empty here. The
codebase reads as old but not as *dead*.

#### The `ps axww | gzip` line

`ikonboard.cgi:278`, inside `my_gen_key`, which generates the 16-character random filename
used as the ARC4 key for the stored database password:

```perl
  srand (time ^ $$ ^ unpack "%L*", `ps axww | gzip`);
```

The idea is a 1990s entropy-gathering idiom: shell out for a process list, compress it so
the bytes are dense, and checksum that into the seed. Its portability is poor in three
distinct ways.

1. **Windows.** There is no `ps` and, on a stock system, no `gzip`. The backticks invoke
   the shell, the shell fails, `` `...` `` returns the empty string, `unpack "%L*", ""`
   returns 0, and the seed degrades to `time ^ $$`. It does not crash -- it silently gets
   worse. (Ikonboard explicitly supports Windows hosting, per section 1.)
2. **Non-Linux Unix.** `ps axww` is BSD-style. On SVR4-derived systems -- Solaris, HP-UX,
   AIX, and IRIX, which is what a fair number of 2002 hosts actually ran -- `ps` wants
   `-ef` and will reject `axww` with a usage message on stderr. Same silent degradation.
3. **Anywhere.** The seed is at best a checksum of a process list that an attacker sharing
   the box can read, and at worst `time ^ $$`, both of which are far short of what a key
   derivation wants.

Practically: this line runs exactly once per board, on the first request after
installation. If it degrades, the ARC4 key filename is drawn from a small, predictable
space. Note also the peculiar line 251 immediately above it --

```perl
		if ($^O eq 'MacOS' && ($^O eq 'MSWin32' || !Win32::IsWin95())) {
```

-- which requires `$^O` to be two different values at once and is therefore dead code; the
`FLOCK` override it guards can never fire.

#### Would it run?

Assuming a Linux box with Apache, `mod_cgi`, Perl 5.26+, and CPAN `CGI` and `DB_File`
installed:

| Component | Verdict |
|---|---|
| `installer.cgi` and the six install modules | Compile clean. Would run, given `CGI`, `CGI::Carp` and `DB_File`. |
| Bundled `Archive::Tar`, uncompressed-tar extraction | Compiles clean on 5.26; the six archives are valid `ustar`. Should work. |
| `ikonboard.cgi` core request path | Compiles clean apart from `CGI.pm`. No removed-feature usage. |
| DBM backend | Should work; `DB_File` is still core-adjacent and maintained. |
| mySQL / pgSQL backends | Driver code compiles; DBI/DBD are still available. Untested -- schemas use 2002 MySQL 3.x/4.0 idioms (`int(10)`, `tinyint(1)`) that MySQL 8 accepts with deprecation grumbles. |
| Oracle backend | No. Broken in the installer as shipped (section 6), independent of Perl version. |
| CSV | Not a backend. Unreachable, wrong package, no `@ISA`/`newSQL`, no search API, and does not compile (section 6). |
| AdminCP -> Options | **Fails on 5.26+** until `Admin/Options.pm:1495`/`:1501` braces are escaped. |
| AdminCP -> skin handling | **Fails on 5.26+** until `Admin/SkinHandler.pm:337`/`:610` braces are escaped. |
| AdminCP -> Import | Works under the board; fails any standalone tooling. |
| mod_perl mode | No. Written for mod_perl 1's `Apache::exit`, gone for twenty years. |

**Conclusion.** With CGI.pm installed from CPAN and three backslashes added to two files,
Ikonboard 3.1.1 would very likely install and serve a board on a 2026 Linux system running
Perl 5.26 through 5.38. That is a startlingly good result for 24-year-old CGI Perl, and it
is a direct consequence of the code being conservative: it uses almost nothing exotic, no
XS beyond `DB_File`, no removed syntax, and no CPAN dependency it did not bundle.

What has not been tested, and cannot be from this tree, is anything requiring a running
board: the ARC4 password round-trip, the DBM record layer under load, session handling,
the tar extraction against a live filesystem, or whether the 2002 HTML renders acceptably
in a modern browser. The verdict above is a compile-and-load verdict, and it is offered as
exactly that.

Whether you *should* run it is a different chapter. The `Tools\` scripts alone (section 7) are
disqualifying for anything internet-facing, `restore_admin.cgi` most of all, and the
password storage is DES `crypt` with a username-derived salt.

---

### 7.10 Summary

The installation story of Ikonboard 3.1.1 is the story of one missing capability. No shell
on the server means: ship the code in tarballs, bundle a pure-Perl untar, extract it in
six chained HTTP requests to survive the CPU limit, run configuration through a web wizard,
generate the config as Perl source, then have the wizard lock itself and have the
application refuse to boot until the wizard is gone. Every one of those decisions is
downstream of FTP-only access, and taken together they are a coherent, competently executed
answer to a genuinely hard deployment problem.

The same absence produced the `Tools\` folder, where the answer was not coherent: five
unauthenticated CGI scripts, three of them destructive on a single GET, one of them a
complete privilege escalation whose only access control is the operator's memory. The
authors knew -- the comments say "there is NO protection of ANY kind" and "You only get
asked once" -- and shipped anyway, because with FTP and a browser there was no better answer
available and the alternative was leaving people with a broken board and no way back in.

Twenty-four years later the application code still compiles, and the two things that stop
it are a regex deprecation and a missing core module. The parts that have aged badly are
not the Perl. They are the `0777`, the DES `crypt`, and `restore_admin.cgi`.

---

## 8. Upgrade path

Ikonboard 3.1.1 shipped in July 2002 under Jarvis Entertainment Group, Inc. The version it was replacing on most live servers was Ikonboard 2.1.9 -- Matt Mecham's flat-file Perl CGI board, released June 2001 under "Ikonboard.com". Between those two releases the product was rewritten from scratch. This chapter is about what that cost the operator: which migration paths actually shipped, what the converter carried across, what it silently left behind, and what a board owner in 2026 should do with a dead board of either generation.

Three points of method, stated up front:

* The 3.1.1 tree examined here was never installed. There is no sample data on either side of the conversion. Every record layout in this chapter is read out of code and out of the shipped `.cfg` schema declarations, not out of a live board.
* Where the answer to "does X convert?" cannot be determined from the code, this chapter says so explicitly rather than guessing.
* Quotations from the 2001-2002 documents are verbatim, including their British spellings and their typos. The migration guide says "seperate" and "convertor"; those stand as written.

---

### 8.1 The three upgrade paths that shipped

Three distinct migrations are packaged in `ib311/Upgrading/`. They are not variations on one mechanism. They are three unrelated pieces of engineering, written at different times by different hands, solving three different problems.

| Path | Mechanism | Where it lives | Who it was for | Character |
|------|-----------|----------------|----------------|-----------|
| 2.1.8 / 2.1.9 -> 3.x | In-admin data converter, batched over many HTTP requests | `Sources/Admin/Convert_ib.pm` (1273 lines), guide at `Upgrading/iB2-iB3_Upgrading/Read_Me.txt`, three redirect stubs in `Tool_Box/` | Everybody still on the flat-file 2.x board -- the large majority of installs | A **conversion**. New board installed clean and empty; old data read out and re-inserted. The old board is untouched and keeps running. |
| 3.0.x -> 3.1.1 | One-shot `ALTER TABLE` script run against the live database, then a fresh install pointed at the migrated tables | `Upgrading/from_3.0.x_to_3.1.1/mysql_table_updater/alter_table.cgi` (26 KB), procedure in `upgrade_info_mySQL.txt` | 3.0.x operators **on MySQL only** | A **schema migration**. Destructive, non-idempotent, no rollback, and it drops and recreates one table outright. |
| 3.1.0 -> 3.1.1 | Manual file replacement -- untar, upload over the top | `Upgrading/to_3.1.1 from 3.1.0/readme.txt` | 3.1.0 operators | A **patch**. 28 source files, 4 skin modules, 2 language files, `ikonboard.cgi`, and a help directory. No data changes at all. |

The asymmetry is worth naming. The 2->3 path got a purpose-built 1273-line converter with a batching engine, a resume mechanism, a UI, and a written guide. The 3.0->3.1 path got a flat list of `$dbh->do(...)` calls with the error handler commented out on one table. The 3.1.0->3.1.1 path got a text file that says "upload all those files".

Two of the three paths are also incomplete in the shipped package:

* The 3.0.x -> 3.1.1 migrator exists **for MySQL only**. Ikonboard 3.1.1 supports five storage backends (CSV, DBM, MySQL, PostgreSQL, Oracle). Four of them have no shipped 3.0->3.1 migration. Section 9 works through what that meant.
* The 3.1.0 -> 3.1.1 readme's step 4 tells you to unpack `upgrading/to_3.1.1 from 3.1.0/admin_CP.zip`. That file is not in the package. The directory contains `readme.txt` and nothing else.

#### 1.1 What is not here

There is no upgrade path from anything older than 2.1.8. The guide is blunt:

```
Upgrading/iB2-iB3_Upgrading/Read_Me.txt:36
The converter program will only work on Ikonboard v2.1.8 and v2.1.9.
If you are using an Ikonboard 'older' than that, please download Ikonboard 2.1.9 and upgrade
now. Upgrading instructions are always packaged with the zip files.
```

That is a two-step upgrade for anyone on 2.1.7 or earlier: 2.1.7 -> 2.1.9 by the old file-replacement method, then 2.1.9 -> 3.x by the converter.

There are also no importers from other forum software. `Sources/Admin/Import.pm` is not, despite what its name suggests, a converter from UBB or vBulletin or phpBB. A grep of the entire 3.1.1 tree for the names of the competing packages of the era returns exactly one hit, and it is a false positive:

```
$ grep -ril "ubb|vbulletin|phpbb|snitz|wwwthreads|yabb|infopop" cgi-bin/
./Languages/en/PostWords.pm      # matches "no_poll_data ... rubbish poll"
```

`Import.pm` is the read half of Ikonboard's own backup system: it restores an `EXPORT-<timestamp>-*.tar` produced by `Sources/Admin/Backup.pm` into a chosen backend (`Import.pm:110-120` offers DBM, mySQL, pgSQL, Oracle). Its real purpose is moving an existing 3.x board from one storage engine to another. It is discussed in section 8.9, where it matters.

#### 1.2 The vestigial fourth path

`Sources/Upgrade.pm` is registered in the main dispatcher as `act=Upgrade`, and it does something, but it is not a general upgrade facility. `Upgrade.pm:61-108` inserts a single `MASS_MAIL` row into `email_templates`, unlinks an `Email-log` file, and adds one graphic slot (`B_POLL_LOCKED`) to the Default skin before regenerating `Styles.pm` and `gfx_data.cfg`. That is a targeted fixup for a board that predates the mass-mailer and the locked-poll icon -- a leftover from an intermediate 3.0.x release. It is not referenced by any of the three upgrade documents, and it carries an obvious defect at `Upgrade.pm:70`:

```perl
	if (-e $iB::INFO{'DB_DIR'} . "Email-log") {
		unlink $iB::INFO->{'DB_DIR'} . "Email-log";
	}
```

`$iB::INFO{'DB_DIR'}` (element of the hash `%iB::INFO`) is a different variable from `$iB::INFO->{'DB_DIR'}` (key of the hashref `$iB::INFO`). The test looks for `Email-log` relative to the current directory; the unlink targets the real path. The guard and the action disagree.

---

### 8.2 Why 2 -> 3 was a conversion and not an upgrade

The word the guide uses is "convertor", and it is the correct word. Ikonboard 3 is not Ikonboard 2 with more features bolted on. It shares essentially no code with its predecessor.

#### 2.1 The shared-source-line test

The pre-computed analysis in `out_delta.txt` normalizes every source line in both trees, discards comments and lines shorter than 24 characters, and intersects the two sets by content rather than by filename -- so a line that moved from `topic.cgi` to `Sources/Topic.pm` would still be counted as shared.

```
  2.1.9 distinct lines : 3499
  3.1.1 distinct lines : 16150
  shared               : 17
  as % of 2.1.9        : 0.49%
  as % of 3.1.1        : 0.11%
```

Seventeen lines. That number alone would be striking. What makes it conclusive is that all seventeen are boilerplate -- not one is a piece of forum logic:

| # | Shared line | What it is |
|---|-------------|------------|
| 1 | `<!-- Cgi-bot Active Users -->` | HTML comment marker |
| 2 | `<!-- Cgi-bot End of Active Users -->` | HTML comment marker |
| 3 | `<!-- Cgi-bot End of script page footer -->` | HTML comment marker |
| 4 | `<!-- Cgi-bot Script page footer -->` | HTML comment marker |
| 5 | `</table></td></tr></table>` | Table close tags |
| 6 | `<SCRIPT LANGUAGE="JavaScript">` | Script open tag |
| 7 | `<script language="javascript">` | Script open tag, lowercase |
| 8 | `<table cellpadding=3 cellspacing=1 border=0 width=100%>` | Table open tag |
| 9 | `if ($hour == 0) { $hour = 12; }` | JavaScript clock |
| 10 | `if ($hour > 11) { $ampm = "pm"; }` | JavaScript clock |
| 11 | `if ($hour > 12) { $hour = $hour - 12; }` | JavaScript clock |
| 12 | `if ($min < 10) { $min = "0$min"; }` | JavaScript clock |
| 13 | `if ($sec < 10) { $sec = "0$sec"; }` | JavaScript clock |
| 14 | `if ($total_results > 0) {` | Generic conditional |
| 15 | `my @months = ('Jan.','Feb.','Mar.','April','May','June','July','Aug.','Sep.','Oct.','Nov.','Dec.');` | Month name array |
| 16 | `print "Content-type: text/html\n\n";` | CGI header |
| 17 | `use CGI::Carp "fatalsToBrowser";` | Debug pragma |

Five of the seventeen are one JavaScript wall-clock widget that survived intact because nobody rewrote a five-line 12-hour-format converter. Four are HTML comment delimiters for a third-party bot. One is the CGI content-type header that every Perl CGI script on earth contains. One is a `use` line.

There is no shared forum code. Not a posting routine, not a permission check, not a template call, not a data accessor. Ikonboard 3 was written on a blank page.

#### 2.2 The bulk numbers

```
                                            2.1.9          3.1.1 change
------------------------------------------------------------------------------
Perl files                                     43            179 +4.2x
Perl lines                                  15586          72805 +4.7x
subroutines                                    88           1624 +18.5x
files with `use strict`                        0%            74%
```

The subroutine ratio is the most informative of the four. Files and lines grew about 4.5x; named subroutines grew 18.5x. 2.1.9 averages roughly two subroutines per file across 43 files, because the 2.x scripts are not decomposed -- `post.cgi` is 45 KB of top-level straight-line code with `if ($action eq "...")` branches, and `postings.cgi` is 57 KB of the same. 3.1.1 averages nine subroutines per file and dispatches through per-module `%Mode` tables.

The `use strict` line is the cultural marker. Not one file in 2.1.9 declares it. 2.1.9 runs entirely on package globals -- `$inmembername`, `$forumgraphic`, `$threadposts` are all unqualified globals visible to every `require`d file, which is why `ikon.lib` can hand a script twenty-two member fields by side effect (`ikon.lib:383`). 74% of 3.1.1's files are strict.

#### 2.3 The capability map

Structurally, the 2.x script inventory maps onto 3.x modules roughly one-to-one, which confirms the feature set was carried forward even though the code was not:

| 2.1.9 | 3.1.1 | Note |
|-------|-------|------|
| `ikonboard.cgi` | `ikonboard.cgi` + `Sources/Boards.pm` | 3.x turns the script into a pure dispatcher |
| `forums.cgi` | `Sources/Forum.pm` | |
| `topic.cgi` | `Sources/Topic.pm` | |
| `post.cgi` | `Sources/Post.pm` + `Post2.pm` | |
| `postings.cgi` | `Sources/Post2.pm` | |
| `register.cgi` | `Sources/Register.pm` | |
| `profile.cgi` | `Sources/Profile.pm` | |
| `loginout.cgi` | `Sources/LogInOut.pm` + `Sessions.pm` | 3.x adds real server-side sessions |
| `search.cgi` | `Sources/Search/api.pm` + `Search/API/*` | per-backend search API |
| `messenger.cgi` | `UserCP/Messenger.pm`, `Messsend.pm`, `Messview.pm` | |
| `newposts.cgi` | `Sources/Newest.pm` | |
| `printpage.cgi` | `Sources/PrintPage.pm` | |
| `whosonline.cgi` | `Sources/Online.pm` | |
| `help.cgi` | `Sources/Help.pm` | |
| `misc.cgi` | `Sources/Misc/*` | |
| `ikonfriend.cgi` | `Sources/Misc/Invite.pm` | |
| `viewip.cgi` | `Sources/ModCP.pm` | |
| `checkboard.cgi` | `Sources/Admin/Tools.pm` | |
| `checklog.cgi` | `Sources/Admin/Adminlogs.pm` | |
| `privacy.cgi` | **(dropped)** | see section 8.8 |
| `announcements.cgi` | **(effectively dropped)** | see section 8.8 |
| `admincenter.cgi` | `Admin/Index.pm` + `Functions.pm` | |
| `setforums.cgi` | `Admin/ForumControl.pm` + `Category.pm` | |
| `setmembers.cgi` | `Admin/MemberControl.pm` | |
| `setmembertitles.cgi` | `Admin/MemberControl.pm` (the `member_titles` table) | see section 8.8.3 |
| `setstyles.cgi` | `Admin/SkinControl.pm` + `SKIN.pm` | 3.x compiles skins to Perl |
| `settemplate.cgi` | `Admin/Templates.pm` + `BoardTemplates.pm` | |
| `setvariables.cgi` | `Admin/Options.pm` | |
| `setbadwords.cgi` | `Admin/Options.pm` | |
| `forumoptions.cgi` | `Admin/ForumControl.pm` | |
| `install.cgi` | `installer.cgi` + `install_modules/*` | 3.x is a multi-step wizard |
| `ikon.lib` | `Sources/Lib/FUNC.pm` | |
| `ikonadmin.lib` | `Sources/Lib/ADMIN.pm` | |
| `ikonmail.lib` | `Sources/Mail/Sendmail.pm` | |

Note the two entries in that table that read "dropped". They are the subject of section 8.8, and they are not the only ones.

---

### 8.3 The storage model change

This is the reason the 2->3 migration had to be a conversion. The two boards do not merely disagree about field names; they disagree about what a database is.

#### 3.1 What 2.1.9 stored

Ikonboard 2.1.9 keeps its data in a fixed directory layout under the CGI directory. There is no abstraction layer. Every script opens files by literal path.

```
cgi-bin/
  members/<Membername_with_underscores>.cgi   one file per member, ONE line, 22 pipe fields
  messages/<Membername>_msg.cgi               private message inbox
  messages/<Membername>_out.cgi               private message outbox
  forum<N>/list.cgi                           the master topic index for forum N
  forum<N>/<topicid>.pl                       per-topic header, 10 pipe fields (derived)
  forum<N>/<topicid>.thd                      the posts, one per line, 7 pipe fields
  data/allforums.cgi                          forum + category table, 15 pipe fields per row
  data/boardinfo.cgi                          board settings (a Perl source file)
  data/boardstats.cgi                         counters
  data/membertitles.cgi                       post-count titles (a Perl source file)
  data/badwords.cgi   data/banlist.cgi   data/news.cgi   data/hacklog.cgi
  data/onlinedata.dat data/progs.cgi    data/styles.cgi data/template.dat
  data/register.dat   data/privacy.dat
```

The key structural fact, and it is easy to get wrong: **the posts live in `.thd`, not in `.pl`.** The `.pl` file is a ten-field topic header and nothing more. This can be read straight off the write sites in `post.cgi`:

```perl
post.cgi:345    # writes forum<N>/<newthreadnumber>.pl  -- the HEADER
print FILE "$newthreadnumber|$intopictitle|$intopicdescription|open|0|0|$inmembername|$currenttime|$inmembername|$currenttime";

post.cgi:354    # writes forum<N>/<newthreadnumber>.thd -- the POSTS
print FILE "$inmembername|$intopictitle|$postipaddress|$inshowemoticons|$inshowsignature|$currenttime|$inpost";
```

`list.cgi` carries one line per topic in exactly the same ten-field layout as the `.pl` header (`postings.cgi:1002`), and it is the authoritative copy: the 2.1.9 repair tool `upgrading/update_forums.cgi` regenerates every `.pl` file *from* `list.cgi`, one topic at a time (`update_forums.cgi:22-44`). The `.pl` files are a denormalized cache. `Convert_ib.pm` never reads them -- it reads `list.cgi` for topics and `<id>.thd` for posts, which is the correct choice.

The converter documents the two layouts in a comment block, and this is the canonical statement of the 2.x record formats as the 3.x author understood them:

```
Sources/Admin/Convert_ib.pm:875-879

#	  0       1               2              3            4            5           6             7             8             9
# $topicid $topictitle $topicdescription $threadstate $threadposts $threadviews $startedby $startedpostdate $lastposter $lastpostdate

#	  0              1             2              3                  4             5          6
# $inmembername $intopictitle $postipaddress $inshowemoticons $inshowsignature $currenttime $inpost
```

The member record is 22 fields, defined by the write in `register.cgi:233` and unpacked at `ikon.lib:383`:

| # | 2.1.9 field | Notes |
|---|-------------|-------|
| 0 | `$membername` | display name, spaces preserved (filename uses underscores) |
| 1 | `$password` | **plaintext** |
| 2 | `$membertitle` | e.g. "Member", "Administrator" |
| 3 | `$membercode` | `me` / `mo` / `ad` |
| 4 | `$numberofposts` | |
| 5 | `$emailaddress` | |
| 6 | `$showemail` | the string `yes` or `no` |
| 7 | `$ipaddress` | registration IP |
| 8 | `$homepage` | |
| 9 | `$aolname` | |
| 10 | `$icqnumber` | |
| 11 | `$location` | |
| 12 | `$interests` | |
| 13 | `$joineddate` | unix time |
| 14 | `$lastpostdate` | `time%%%url%%%topictitle` composite |
| 15 | `$signature` | |
| 16 | `$timedifference` | timezone offset |
| 17 | `$privateforums` | `f1=yes&f4=yes...` per-member forum ACL |
| 18 | `$useravatar` | filename or URL |
| 19-21 | `$misc1` `$misc2` `$misc3` | reserved; only ever initialized blank (`ikon.lib:1121`) |

And the forum/category table, `data/allforums.cgi`, 15 fields (`ikon.lib:103`):

| # | Field | # | Field |
|---|-------|---|-------|
| 0 | `$forumid` | 8 | `$privateforum` |
| 1 | `$category` (name) | 9 | `$startnewthreads` |
| 2 | `$categoryplace` | 10 | `$lastposter` |
| 3 | `$forumname` | 11 | `$lastposttime` |
| 4 | `$forumdescription` | 12 | `$threads` |
| 5 | `$forummoderator` (comma list) | 13 | `$posts` |
| 6 | `$htmlstate` (`on`/`off`) | 14 | `$forumgraphic` |
| 7 | `$idmbcodestate` (`on`/`off`) | | |

Locking is `flock(FILE, 2)` around a full-file rewrite, which is what `postings.cgi` does to `list.cgi` on every reply: read the whole file, rebuild the whole string, truncate, write. On a non-flock filesystem -- which is most shared hosting of the era -- that is a race, and it is exactly the failure mode the 2.1.9 changelog is talking about:

```
Changes.txt:22
In addition a huge rewrite of processing routines to try and stop disappearing threads. (Credit to SmileyMan)
```

There is a self-repair tool, `checkboard.cgi`, and the admin center greets you with `WARNING! Datafiles have been destroyed!` when it detects the damage (`admincenter.cgi:106`). Data loss was a routine operational event.

#### 3.2 What 3.1.1 stores

3.1.1 declares a relational schema: **29 tables, 305 columns**, in `Database/config/*.cfg`. Each `.cfg` is a small Perl module naming the table, its primary key, its index keys, and every column with an ordinal, a type, a width and a not-null flag:

```perl
Database/config/forum_posts.cfg
$STRING = { "TABLE"   => "forum_posts",
            "P_KEY"   => "POST_ID",
            "MTD"     => "single",
            "UPDATE"  => "bottom",
            "DBID"    => "FORUM_ID",
            "ID"      => "TOPIC_ID",
          };

%{ $COLS }  = (  POST_ID     => [0, 'update', 10, 1],
                 AUTHOR      => [1, 'string', 32   ],
                 ENABLE_SIG  => [2, 'num'   , 1    ],
                 ENABLE_EMO  => [3, 'num'   , 1    ],
                 IP_ADDR     => [4, 'string', 16, 1],
                 POST_DATE   => [5, 'num'   , 10, 1],
                 POST_ICON   => [6, 'num'   , 2    ],
                 POST        => [7, 'text'  , -1   ],
                 AUTHOR_TYPE => [8, 'num'   , 1    ],
                 QUEUED      => [9, 'num'   , 1    ],
                 TOPIC_ID    => [10,'num'   , 10, 1],
                 FORUM_ID    => [11,'num'   , 5 , 1],
                 ATTACH_ID   => [12,'string', 64,  ],
                 ATTACH_HITS => [13,'num'   , 5    ],
                 ATTACH_TYPE => [14,'string', 128  ],
          );
```

Behind that declaration sits `Sources/iDatabase/SQL.pm` and five drivers in `Sources/iDatabase/Driver/`: `CSV.pm`, `DBM.pm`, `mySQL.pm`, `pgSQL.pm`, `Oracle.pm`. For the two flat-file drivers the `.cfg` files *are* the schema -- the driver serializes a record by joining values in ordinal order with a `|^|` delimiter (`Driver/Base.pm:232`, `sub decode_record`), and the ordinals in the `.cfg` decide the physical byte order on disk. For the three SQL drivers the DDL lives separately in `mysql_schema.txt`, `postgres_schema.txt`, `oracle_schema.txt`.

The relevant contrast for this chapter is that 2.1.9's storage layout is *implicit* -- it exists only as the shape of the `print FILE "...|...|..."` statements scattered through 31 scripts -- whereas 3.1.1's is *declared* in one place and read by a driver. A converter can be written against the second. It can only be written against the first by hand-transcribing every `print FILE` in the old codebase, which is what `Convert_ib.pm` is.

#### 3.3 A topic and its posts, before and after

**Before.** A four-post topic, id `1017245891`, in forum 3.

`forum3/list.cgi` (one of many lines):

```
1017245891|Best Final Fantasy?|Vote in the thread|open|4|217|Highlander|1017245891|Razz|1017398220
```

`forum3/1017245891.pl` -- a byte-identical copy of that line, regenerable from `list.cgi`, and never read by the converter.

`forum3/1017245891.thd` -- four lines, one per post, oldest first:

```
Highlander|Best Final Fantasy?|10.0.0.4|yes|yes|1017245891|I&#0124;m going with FF6.<br><br>Discuss.
Razz|Best Final Fantasy?|10.0.0.9|yes|no|1017249003|FF7 obviously.
...
```

Note `&#0124;` where the author typed a pipe. 2.1.9 escapes `|` at input (`ikon.lib:611`) precisely so the pipe can be used as a field delimiter, and it escapes `<`, `>`, `&` and `"` in the same pass (`sub cleaninput`, `ikon.lib:602-618`), converting `\n` to `<br>` so a post is always exactly one line.

**After.** One row in `forum_topics` (keyed by the destination forum id) and four rows in `forum_posts` (in a per-forum table, `DBID => 'f<N>'`), plus four rows in `search_log`.

`forum_topics` row, exactly as `Convert_ib.pm:889-908` builds it:

| Column | Value | Source |
|--------|-------|--------|
| `TOPIC_ID` | assigned by `$db->insert` | new sequence |
| `TOPIC_TITLE` | `Best Final Fantasy?` | `list.cgi` field 1 |
| `TOPIC_DESC` | `Vote in the thread` | field 2 |
| `TOPIC_STATE` | `open` | field 3, carried as the literal 2.x string |
| `TOPIC_POSTS` | `4` | field 4 |
| `TOPIC_VIEWS` | `217` | field 5 |
| `TOPIC_STARTER` | looked up from `MEMBER_NAME.idx` | field 6 -> new member id |
| `TOPIC_STARTER_N` | `Highlander` | field 6 |
| `TOPIC_START_DATE` | `1017245891` | field 7 |
| `TOPIC_LAST_POSTER` | looked up | field 8 -> new member id |
| `TOPIC_LASTP_N` | `Razz` | field 8 |
| `TOPIC_LAST_DATE` | `1017398220` | field 9 |
| `FORUM_ID` | destination forum | operator's choice |
| `PIN_STATE` | `0` | hardcoded |
| `TOPIC_ICON` | `0` | hardcoded |
| `TOPIC_AUTHOR_TYPE` | `1` | hardcoded |
| `APPROVED` | `1` | hardcoded |
| `POLL_STATE`, `LAST_VOTE`, `MOVED_TO`, `WATCHED` | **not set** | left undef |

`forum_posts` row per `.thd` line (`Convert_ib.pm:929-947`):

| Column | Value | Source |
|--------|-------|--------|
| `POST_ID` | assigned | new sequence |
| `AUTHOR` | `$N_INDEX{ $bits[0] }` | member name -> new member id |
| `ENABLE_SIG` | `$bits[4]` | **the string `yes`/`no`, into a `num(1)` column** |
| `ENABLE_EMO` | `$bits[3]` | same |
| `IP_ADDR` | `10.0.0.4` | `.thd` field 2 |
| `POST_DATE` | `1017245891` | `.thd` field 5 |
| `POST` | run through `iTextparser::Convert_for_db` | `.thd` field 6 |
| `POST_ICON` | `0` | hardcoded |
| `AUTHOR_TYPE` | `1` | hardcoded |
| `QUEUED` | `0` | hardcoded |
| `TOPIC_ID` | the new topic id | |
| `FORUM_ID` | destination forum | |
| `ATTACH_ID` / `ATTACH_HITS` / `ATTACH_TYPE` | `undef` | explicit |

Note what the `.thd` layout carries that the 3.x row does not need: field 1 of every post line is `$intopictitle`, the topic title repeated on every post. 2.1.9 denormalized it so `printpage.cgi` and the search could work off one file. 3.1.1 drops it, correctly -- but it re-denormalizes it into `search_log.TOPIC_TITLE` (`Convert_ib.pm:963`), which is the same trade made one layer down.

---

### 8.4 `Convert_ib.pm` in detail

`Sources/Admin/Convert_ib.pm`, 1273 lines, 21 subroutines, dated 06/30/2002. It is the only iB2 converter that ever shipped.

#### 4.1 The subroutine map

Eleven public entry points, dispatched by `CODE=` (`Convert_ib.pm:1042-1052`), and ten internal helpers.

| Sub | Line | Role |
|-----|------|------|
| `new` | 39 | constructor, returns a blessed empty hashref |
| `splash` | 46 | the setup form: iB2 path, batch sizes, collision policy, group mapping |
| `step_one` | 142 | validates and persists the setup; renders the links page with per-stage "Successful" flags |
| `members` | 211 | **the members pass.** Batched. Writes DBM directly. |
| `cats` | 323 | checkbox form listing iB2 categories |
| `do_cats` | 382 | inserts selected categories into `categories` |
| `forums` | 428 | checkbox form listing iB2 forums with a destination-category selector per row |
| `d_forums` | 514 | creates per-forum tables, inserts into `forum_info`, rebuilds the forum-jump menu |
| `mods` | 621 | checkbox form listing forums that have a non-empty moderator string |
| `d_mods` | 701 | inserts `forum_moderators` rows |
| `posts` | 759 | source-forum / destination-forum selector, one forum at a time |
| `d_posts` | 843 | **the posts pass.** Batched. Topics + posts + search index + forum recount. |
| `process` | 1037 | `%Mode` dispatch |
| `_load_db_cfg` | 1065 | `do`es a `Database/config/*.cfg` and caches the ordinal-sorted column list |
| `_encode_record` | 1083 | hand-rolled reimplementation of the DBM driver's `|^|` record encoder |
| `_get_old_forums` | 1098 | parses `data/allforums.cgi` into forum hashrefs |
| `_get_old_cats` | 1128 | parses the same file into category hashrefs, deduplicating by name |
| `_split_mem` | 1159 | parses one `members/*.cgi` file into a 3.x `member_profiles` hashref |
| `_load_config` | 1237 | reads `Temp/ib2.data` |
| `_check_convert` | 1251 | tests for the five `Temp/*.lockfile` stage markers |
| `_write_config` | 1262 | writes `Temp/ib2.data` |

#### 4.2 The setup screen

`splash` builds a form with six inputs (`Convert_ib.pm:82-120`):

```perl
$html .= $SKIN->td_input ( TEXT => 'Where is your ikonboard 2?<br>&nbsp;&nbsp;&nbsp;&nbsp;(Enter the full path to where ikon.lib resides)', NAME => 'LOCATION',  VALUE=> $pth, REQ => 1);
$html .= $SKIN->td_input ( TEXT => 'Number of members to convert at a time', NAME => 'MEM_COUNT',   VALUE=> $members, REQ => 1);
$html .= $SKIN->td_input ( TEXT => 'Number of topics to convert at a time'  , NAME => 'POST_COUNT',  VALUE=> $posts  , REQ => 1);
```

plus a two-option collision policy and three member-group selectors (normal members, administrators, moderators). Defaults are `cwd()` for the path and `200` for both batch sizes.

`step_one` validates by probing for two 2.x landmarks:

```perl
Convert_ib.pm:156-161
		my $data_found  = (-e $iB::IN{'LOCATION'}.'/data/allforums.cgi') ? 1 : 0;
		my $board_found = (-e $iB::IN{'LOCATION'}.'/ikon.lib')           ? 1 : 0;

		unless ($data_found || $board_found) {
			$ADMIN->Error( ... MSG => "Cannot find the ikonboard v2.1.9 installation!...");
		}
```

Note `||` and not `&&`: either landmark is sufficient. Seven values are then written to `Temp/ib2.data` in a trivial `KEY = VALUE` format (`_write_config`, line 1262), so the settings survive across the dozens of HTTP requests the conversion will take.

#### 4.3 The mandatory order

The guide is explicit and the code enforces the important half of it:

```
Read_Me.txt:98-105

Before you can convert your posts, you'll need to convert your members. Click on the "Convert
Members" link.
Once the conversion is complete, you'll be taken back to that screen, but you'll notice a few
more options and you'll be informed that the member conversion was a success.

We recommend that you convert the category data next, then the forum data, then the moderators
data before converting the posts. You'll be able to pick and choose with categories/forums and
moderators to convert.
```

The enforcement is in `step_one`: if `Temp/members.lockfile` does not exist, the links page renders one link only.

```perl
Convert_ib.pm:191-195
	} else {
		$html .= qq~You must convert members before converting posts or forums</span><br><br>
					&gt; <a href='...&CODE=members' ...>Convert Members</a><br>...
				  ~;
	}
```

The order is:

```
  1. members   (mandatory first)
  2. categories
  3. forums    (needs categories to exist, to pick a destination category)
  4. moderators (needs forums to exist, and needs members for the name lookup)
  5. topics + posts (needs forums, and needs members for the name lookup)
```

The reason members must come first is mechanical, not stylistic. 2.1.9 has no member IDs -- a post records its author as a **name string**. 3.1.1 keys everything on `MEMBER_ID`. The bridge between the two is the DBM name index, `MEMBER_NAME.idx`, which maps `MEMBER_NAME -> MEMBER_ID`. `d_posts` opens it and does a lookup per topic and per post:

```perl
Convert_ib.pm:886-887
		my $t_starter_id = $N_INDEX{ $fields[6] };
		my $t_lastposter = $N_INDEX{ $fields[8] };
```

```perl
Convert_ib.pm:932
									 VALUES => { AUTHOR        => $N_INDEX{ $bits[0] },
```

Run posts before members and every `AUTHOR` is `undef`. The lookup is unguarded on the post path -- there is no `next unless defined`, unlike `d_mods:717` -- so a post by a member whose file was missing, skipped by the collision policy, or renamed between boards is inserted with a null author and no diagnostic.

The three "recommended" steps (categories, forums, moderators) are not enforced, only advised. Nothing stops you from converting forums into a board with no categories; `d_forums` will happily write `CATEGORY => $iB::IN{'FS_'.$rf}` from an empty selector.

#### 4.4 Batching, and why it exists

The guide's advice:

```
Read_Me.txt:85-88
"Number of members to convert at a time" - If you have a lot of members, you may want to set
this to about "200". This allows the script to take a break between batches and bypasses the
server CGI process limit.
"Number of topics to convert at a time" - Again, this will save your server from a large process
```

Shared hosting in 2002 killed CGI processes on CPU-second or wall-clock limits. A board with 4,000 members would not finish a members pass inside a 30-second limit, and a partial run that died mid-write would leave a half-built DBM file.

The solution is a self-driving redirect chain. Each invocation processes a slice and then issues a meta-refresh to itself with an advanced offset:

```perl
Convert_ib.pm:234-243
	# Make sure we have a valid start variable
	$iB::IN{'st'} ||= 0;
	# Get the end variable
	my $end = $iB::IN{'st'} + $saved->{'MEM_COUNT'};

	$end = $m_total if $end > $m_total;

	# Get the array slice we need
	@members = @members[$iB::IN{'st'} .. $end];
	# Increment the $end variable to use later as our start variable
	++$end;
```

```perl
Convert_ib.pm:305-313
	if ($end >= $m_total) {
	   open LOCKFILE, ">".$INFO->{'DB_DIR'}.'Temp/members.lockfile';
	   print LOCKFILE "Done: ".time."\n";
	   close LOCKFILE;
	   $ADMIN->redirect( URL => "act=convert&CODE=one&skip=1", TEXT => 'Finishing up the member convertion process');
	} else {
		$id += 10; # Just to be safe...
	   $ADMIN->redirect( URL => "act=convert&CODE=members&st=$end&id=$id", TEXT => 'Processing the next batch...');
	}
```

Each batch is one CGI process. A 4,000-member board at 200 per batch is 20 redirects, each a fresh process well inside any limit. The `$id += 10` on the member path is a gap left in the ID sequence between batches, presumably as insurance against an aborted batch being re-run.

Two defects in the slice arithmetic, both benign:

* `@members[$st .. $end]` is inclusive at both ends, and on the final batch `$end` is clamped to `$m_total` -- one past the last valid index. The extra element is `undef`, `_split_mem` fails to open it and returns `{}`, and the loop's `next unless $r_mem->{'MEMBER_NAME'}` (line 271) discards it.
* `next unless $entry =~ m!|!;` at line 883 and `next unless $pants =~ m!|!;` at line 916 are intended to skip lines with no pipe. The regex is unescaped, so it compiles as `empty|empty` and matches every string including the empty one. The guards that actually work are the `next unless $fields[1]` / `next unless $bits[0]` on the following lines.

Progress is tracked in five marker files. `_check_convert` (line 1251) tests for `Temp/members.lockfile`, `categories.lockfile`, `forums.lockfile`, `mods.lockfile`, `posts.lockfile`, and `step_one` renders a red `[ Conversion Successful ]` badge per stage. The posts pass additionally appends the converted source and destination forum ids to `Temp/posts.lockfile` and `Temp/dest_posts.lockfile`, so the forum selector can mark rows `[ Converted ]` (`Convert_ib.pm:792-802`). There is a free-text audit log at `Temp/mem_install.txt`:

```perl
Convert_ib.pm:270-291
		print INSTALL "Attempting to process $r_mem->{'MEMBER_NAME'}...\n";
		...
			print INSTALL "Skipping $r_mem->{'MEMBER_NAME'} as it already exists in the iB3 Database...\n";
		...
		print INSTALL "$r_mem->{'MEMBER_NAME'} added successfully with an ID of $r_mem->{'MEMBER_ID'} ...\n";
```

Nothing equivalent exists for topics, posts, forums, categories or moderators. The member pass is the only stage that leaves an audit trail.

#### 4.5 The membername collision policy

The setup screen asks:

```
Read_Me.txt:89-90
"If an old iB2 member name matches an iB3 membername.." - If you already have an iB3 set up
then you may want to keep your iB3 members, adjust accordingly.
```

Two options, and only two (`Convert_ib.pm:91-93`):

```perl
							   DATA     => [ { VALUE => 1, NAME => 'Overwrite the current iB3 member'  },
											 { VALUE => 0, NAME => 'Keep the current iB3 member' },
										   ]
```

The implementation:

```perl
Convert_ib.pm:272-278
		# Check to see if a member name exists, and if so, what to do.
		unless ($saved->{'OVERWRITE'}) {
			if (exists $N_INDEX{ $r_mem->{'MEMBER_NAME'} }) {
				print INSTALL "Skipping $r_mem->{'MEMBER_NAME'} as it already exists in the iB3 Database...\n";
				next;
			}
		}
```

The check is **name only**. There is no email collision check, even though the converter maintains a `MEMBER_EMAIL.idx` and writes to it unconditionally at line
289. Two members with the same email and different names both land, and the email
index -- a single-valued DBM hash -- silently keeps only the last writer.

"Overwrite" is a misnomer for what actually happens. `OVERWRITE => 1` skips the existence check entirely and proceeds to line 283:

```perl
		++$id;
		$r_mem->{'MEMBER_ID'} = ord( substr($r_mem->{'MEMBER_NAME'},0,1) ).'-'.$id;
```

A **new** ID is minted. The new profile is written under that new key, and only then are the two indexes repointed:

```perl
		$MEMS{$r_mem->{'MEMBER_ID'}}       = $obj->_encode_record($r_mem);
		$N_INDEX{$r_mem->{'MEMBER_NAME'}}  = $r_mem->{'MEMBER_ID'};
		$E_INDEX{$r_mem->{'MEMBER_EMAIL'}} = $r_mem->{'MEMBER_ID'};
```

The pre-existing iB3 profile row is not deleted -- it is orphaned. It stays in `member_profiles.db` under its original key, unreachable by name or email lookup, and any `forum_posts.AUTHOR` already pointing at the old ID still resolves to it. The result of "overwrite" on a board with existing content is two profiles for one name: old posts attributed to the orphan, converted posts to the new record. Post counts, warn levels and PM stats do not merge.

The ID format matches live registration exactly. `FUNC::Member::AddMember`:

```perl
Sources/Lib/FUNC.pm:1210-1215
	my $Time = time;

	my $IdPart   = $obj->convert_to_num($IN->{'MEMBER'}->{'MEMBER_NAME'});
	my $Insert   = { MEMBER_NAME => $IN->{'MEMBER'}->{'MEMBER_NAME'},
					 MEMBER_ID   => "$IdPart".'-'."$Time",
				   };
```

where `convert_to_num` (line 1252) is `ord(substr($name,0,1))`. So `Highlander` registering live at epoch 1017245891 gets `72-1017245891`. The converter produces the same shape from a counter seeded at `time` (line 257) and incremented per member -- which is in fact *safer* than the live path, since `AddMember` will mint a duplicate ID for two members registering in the same second.

#### 4.6 Passwords: the one place the conversion improves the data

2.1.9 stores passwords in plaintext. `crypt` appears exactly twice in the whole 2.x tree (`register.cgi:128`, `profile.cgi:504`) and in both places it is being abused as a random-string generator for a mailed reset password, not as a hash. Login is a string equality test:

```perl
loginout.cgi:95
if (($userregistered ne "no") && ($inpassword eq $password)) {
```

and the plaintext is then written into a 30-day cookie (`loginout.cgi:113-115`).

3.1.1 stores `md5(password . lc(membername))`:

```perl
Sources/Lib/FUNC.pm:1391-1399
sub	MD5 {
	my $obj = shift;
	my ($Name, $Pass) = @_;
	return unless ($Name or $Pass);
	$Name = lc ($Name);
	my $ctx = Crypt::MD5->new;
	$ctx->add($Pass,$Name);
	return $ctx->hexdigest;
}
```

and the converter applies exactly that function to the plaintext it reads:

```perl
Convert_ib.pm:279-280
		# Encrypt the password
		$r_mem->{'MEMBER_PASSWORD'} = $mem->MD5($r_mem->{'MEMBER_NAME'},$r_mem->{'MEMBER_PASSWORD'});
```

**Every member keeps their password and can log in immediately after conversion**, and the board stops storing it in the clear. This is the single unambiguous data-quality improvement in the whole migration. It is only possible because 2.x stored plaintext -- a converter from a properly hashed old board could not have done it.

The username-as-salt is per-user but not per-registration, and unsalted-MD5 of a short password is not defensible in 2026. Against 2.1.9's plaintext it is still a large step forward.

#### 4.7 Moderators -> member groups + `forum_moderators`

The mapping happens in two unrelated places.

**Group assignment**, per member, from the one-character 2.x `$membercode`:

```perl
Convert_ib.pm:1199-1207
	if ($return->{'t_CODE'} eq 'ad') {
		$return->{'MEMBER_GROUP'} = $saved->{'ADMIN_GROUP'};
	} elsif
	   ($return->{'t_CODE'} eq 'mo') {
		$return->{'MEMBER_GROUP'} = $saved->{'MOD_GROUP'};
	} else {
		$return->{'MEMBER_GROUP'} = $saved->{'MEMBER_GROUP'};
	}
	delete $return->{'t_CODE'};
```

`ad` -> the operator's chosen admin group; `mo` -> the moderator group; anything else (`me`, or garbage) -> the members group. The guide's advice on the third selector is that it should usually be the same as the second:

```
Read_Me.txt:91-92
Adjust the member grouping to suit. We suggest that you put all Ikonboard 2 moderators in the
standard members group - there is no need to create a new membergroup for them.
```

That is correct advice, because in 3.1.1 "moderator" is a *per-forum grant*, not a global class. Global group membership and per-forum moderation are orthogonal.

**Per-forum moderator rows.** `mods` presents one row per 2.x forum whose `$forummoderator` field is non-trivial (`Convert_ib.pm:670`, `next unless length($f->{'t_MODS'}) > 1;`), with a destination-forum selector. `d_mods` then splits the moderator string and inserts one row per name:

```perl
Convert_ib.pm:713-738
		my $forum_id = $iB::IN{'FS_'.$m};

		for my $mds ( split(/, /, $iB::IN{'FM_'.$m}) ) {
			my $m_id = $N_INDEX{ $mds };
			next unless defined $m_id;
			$db->insert( TABLE  => 'forum_moderators',
						 VALUES => { FORUM_ID         => $forum_id,
									 MEMBER_NAME      => $mds,
									 MEMBER_ID        => $m_id,
									 EDIT_POST        => 1,
									 EDIT_TOPIC       => 1,
									 DELTE_POST       => 1,
									 DELETE_TOPIC     => 1,
									 VIEW_IP          => 1,
									 OPEN_TOPIC       => 1,
									 CLOSE_TOPIC      => 1,
									 MOVE_TOPIC       => 1,
									 MASS_MOVE        => 1,
									 MASS_PRUNE       => 1,
									 MOVE_TOPIC       => 1,
									 PIN_TOPIC        => 1,
									 UNPIN_TOPIC      => 1,
									 POST_Q           => 1,
									 TOPIC_Q          => 1,
								   },
					   );
```

Every permission is granted, which the guide says was deliberate:

```
Read_Me.txt:124-125
Finally, you might want to adjust the moderator permissions. The converter allows them full access
to the moderation tools (as they did in Ikonboard 2).
```

Three defects in twenty-six lines:

1. **`DELTE_POST` is a typo.** The declared column in `Database/config/forum_moderators.cfg` is `DELETE_POST` (ordinal 6). `DELTE_POST` is not a column. On the SQL drivers the insert either errors or silently drops the key; on the flat-file drivers the encoder walks the declared ordinals and writes `DELETE_POST` as empty. Converted moderators cannot delete posts.
2. **`MOVE_TOPIC` appears twice** in the same hash literal. Harmless -- the second wins with the same value -- but it means one intended permission was typed twice and another was left out. `ALLOW_WARN` (ordinal 19), `ADD_TOPIC_WATCH` (20) and `REMOVE_TOPIC_WATCH` (21) are never set.
3. **The split separator is wrong.** `split(/, /, ...)` requires comma-space. 2.1.9 itself does not assume that -- `ikon.lib` normalizes first, precisely because the field is free text an admin typed into a form:

   ```perl
   ikon.lib:286-287
   $forummoderator =~ s/\, /\,/gi;
   @forummodnames = split(/\,/, $forummoderator);
   ```

   A moderator list stored as `Highlander,Razz` -- legal in 2.1.9 and rendered
   correctly there -- is split by the converter into the single string
   `Highlander,Razz`, which is not in `%N_INDEX`, so `next unless defined $m_id`
   discards it. **The forum silently converts with zero moderators.** No error, no
   log entry.

#### 4.8 What `Convert_ib.pm` does NOT carry across

This is the practical answer to "what did upgrading cost you". Every row below was established by comparing the 2.1.9 write sites against what the converter reads and what it writes. Where a determination could not be made from the code, the row says so.

##### 4.8.1 Member fields

`_split_mem` (`Convert_ib.pm:1159-1234`) reads a 22-field 2.x member record and uses 16 of the fields.

| 2.x field | # | Fate | Detail |
|-----------|---|------|--------|
| `$membername` | 0 | **carried** | `MEMBER_NAME` |
| `$password` | 1 | **carried, hashed** | see 4.6 |
| `$membertitle` | 2 | **DROPPED** | `MEMBER_TITLE => ''` is hardcoded at line 1172. Custom titles set by an admin in `setmembers.cgi` are lost. |
| `$membercode` | 3 | **consumed** | mapped to `MEMBER_GROUP`, then `delete`d (line 1207) |
| `$numberofposts` | 4 | **carried** | `MEMBER_POSTS` -- post counts survive |
| `$emailaddress` | 5 | **carried** | lowercased |
| `$showemail` | 6 | **carried but INVERTED** | see below |
| `$ipaddress` | 7 | **carried** | `MEMBER_IP` |
| `$homepage` | 8 | **carried** | `WEBSITE` |
| `$aolname` | 9 | **carried** | `AOLNAME` |
| `$icqnumber` | 10 | **carried** | `ICQNUMBER` |
| `$location` | 11 | **carried** | re-parsed through `Convert_for_db` |
| `$interests` | 12 | **carried** | re-parsed |
| `$joineddate` | 13 | **carried** | `MEMBER_JOINED` |
| `$lastpostdate` | 14 | **DROPPED** | not read. 3.x has a `LAST_POST` column (`string`, 32) but the converter never sets it. |
| `$signature` | 15 | **carried** | re-parsed with the board's sig policy |
| `$timedifference` | 16 | **carried** | `TIME_ADJUST` |
| `$privateforums` | 17 | **DROPPED** | per-member forum ACL. See 4.8.4. |
| `$useravatar` | 18 | **carried** | with a `.gif` fixup, see below |
| `$misc1-3` | 19-21 | not read | never populated in 2.1.9 either; no loss |

**Member titles are lost.** Line 1172 is unconditional:

```perl
	my $return = { MEMBER_NAME     => $mem_array[0],
				   MEMBER_PASSWORD => $mem_array[1],
				   MEMBER_TITLE    => '',
```

3.1.1 will regenerate a title from the `member_titles` post-count ladder at render time (`Sources/Topic.pm:227`, `Sources/Lib/FUNC.pm:1363`), so most members end up with a plausible title anyway -- but any *manually assigned* title is gone.

**`HIDE_EMAIL` is semantically inverted and type-wrong.** Line 1177:

```perl
				   HIDE_EMAIL      => $mem_array[6] || 0,
```

2.x field 6 is `$showemail`, and it holds the literal strings `yes` or `no` (`register.cgi:455` writes the radio; `profile.cgi:217` reads it as `if ($showemail ne "yes") { $emailaddress = "Private"; }`). 3.x `HIDE_EMAIL` is declared `num(1)` and its sense is the opposite. The converter neither inverts nor normalizes. In Perl both `'yes'` and `'no'` are true, so every converted member gets a truthy `HIDE_EMAIL` regardless of what they chose -- a privacy-safe failure, but a failure. On the MySQL backend `HIDE_EMAIL` is `tinyint(1)` and both strings insert as `0`, which is the privacy-*unsafe* failure. **The behavior differs by storage backend.** This chapter cannot determine which of the two the typical operator saw without a live board; both are wrong.

**Avatars** get a filename fixup contributed by an outside tester:

```perl
Convert_ib.pm:1209-1214
	# Tidy up the members avatar
	# Thanks to 'joshdw1' for his assistance, time and server to fix this up.

	if ($return->{'MEMBER_AVATAR'} =~ /^\w+$/) {
		$return->{'MEMBER_AVATAR'} .= '.gif';
	}
```

2.1.9 stored gallery avatars as a bare name and remote avatars as a full URL; 3.1.1 wants a filename or URL. The regex appends `.gif` only to bare word characters, so URLs are left alone. The **avatar image files themselves are not copied** -- the converter never touches `non-cgi/avatars/`. The operator must move them by FTP or every converted avatar 404s. The guide does not mention this.

`AVATAR_DIMS` is hardcoded `'32x32'` (line 1188) for every member regardless of the real image size.

Nine `member_profiles` columns are set to fixed values rather than converted: `MEMBER_LEVEL => 1`, `ALLOW_ADMIN_EMAILS => 1`, `ALLOW_POST => 1`, `VIEW_SIGS => 1`, `VIEW_IMG => 1`, `VIEW_AVS => 1`, `EMAIL_FULL_POST => 0`, `PM_REMINDER => '1&1'`, `LAST_UPDATE => time`. Twelve more columns declared in `Database/config/member_profiles.cfg` are never mentioned by `_split_mem` at all and land empty: `PHOTO`, `CANCEL_SUBS`, `YAHOONAME`, `MSNNAME`, `MEMBER_SKIN`, `WARN_LEVEL`, `LANGUAGE`, `LAST_POST`, `LAST_LOG_IN`, `LAST_ACTIVITY`, `GENDER`, `MEMBER_NAME_R`, `POST_FONT_COLOR`. Most have no 2.x source and are correctly empty; `LAST_POST` and `WARN_LEVEL` are discussed above and below respectively.

##### 4.8.2 Forum and category fields

`_get_old_forums` reads 8 of the 15 fields in `data/allforums.cgi`:

```perl
Convert_ib.pm:1110-1121
		my @t_f = split(/\|/, $f);
		my $html = $t_f[6] eq 'off' ? 0 : 1;
		my $code = $t_f[7] eq 'off' ? 0 : 1;

		$temp[ $t_f[0] ] = { FORUM_NAME  => $t_f[3],
							 FORUM_DESC  => $t_f[4],
							 FORUM_IBC   => $code,
							 FORUM_HTML  => $html,
							 t_MODS      => $t_f[5],     # Get the mods, complete with commas
							 t_ID        => $t_f[0],
							 t_CAT       => $t_f[1],
						   };
```

| 2.x field | # | Fate |
|-----------|---|------|
| `$forumid` | 0 | consumed as the source key |
| `$category` | 1 | used to preselect the destination category dropdown |
| `$categoryplace` | 2 | used by `_get_old_cats` for `CAT_POS` |
| `$forumname` | 3 | **carried** |
| `$forumdescription` | 4 | **carried** |
| `$forummoderator` | 5 | **carried** (with the split bug of 4.7) |
| `$htmlstate` | 6 | **carried**, normalized `on`/`off` -> `1`/`0` |
| `$idmbcodestate` | 7 | **carried**, normalized |
| `$privateforum` | 8 | **DROPPED** |
| `$startnewthreads` | 9 | **DROPPED** -- the 2.x per-forum "who may start threads" setting |
| `$lastposter` | 10 | **DROPPED** -- recomputed by `d_posts` |
| `$lastposttime` | 11 | **DROPPED** -- recomputed |
| `$threads` | 12 | **DROPPED** -- recomputed |
| `$posts` | 13 | **DROPPED** -- recomputed |
| `$forumgraphic` | 14 | **DROPPED, permanently** -- see 8.5 |

Fields 10-13 are correctly discarded because `d_posts` recomputes them at `Convert_ib.pm:995-1028`. Fields 8, 9 and 14 are real losses.

Every new forum is created world-open:

```perl
Convert_ib.pm:564-567
								 FORUM_START_THREADS     => '*',
								 FORUM_REPLY_THREADS     => '*',
								 FORUM_VIEW_THREADS      => '*',
								 FORUM_PROTECT           => '',
```

and every new category likewise (`VIEW => '*'`, line 407). The guide is candid that this is a manual cleanup step, and it is the most consequential warning in the document:

```
Read_Me.txt:118-123
Next, you may want to set up the reading/posting and replying permissions on your forums
and categories. By default - they are set to world readable and world writable. Do this by
click on "Edit Forum" or "Edit Category".
If you had any private forums, you'll need to reset them to. You can either use the member group
permission masks, or enter a password to "protect" the forum.
```

Read plainly: **at the moment the conversion completes, every previously private forum on the board is publicly readable and writable, and every previously restricted category is open.** A staff-only forum's contents are world-visible until the admin manually reconstructs the permission masks in a separate UI. The converter has both pieces of information -- `$privateforum` on the forum row and `$privateforums` on each member row -- and uses neither.

`FORUM_POSITION` is set to `$forums` (line 561), which is the *array reference* returned by the earlier `$db->query`, not the loop counter `$forum`. Every converted forum gets the same stringified-reference position value. Ordering is whatever the sort collapses to.

Categories are deduplicated by consecutive-name comparison (`_get_old_cats:1145`, `if ($this_cat ne $last_cat)`), which is correct only if `allforums.cgi` is grouped by category. 2.1.9's admin center does keep it sorted, so in practice this holds; a hand-edited `allforums.cgi` with interleaved categories would produce duplicate 3.x categories. `CAT_DESC` is hardcoded `''` (line 405) -- 2.1.9 has no category description field, so nothing is lost.

##### 4.8.3 Topics and posts

Topic and post *content* converts. What does not:

* **`TOPIC_STATE` is copied raw.** Line 893, `TOPIC_STATE => $fields[3]`. 2.1.9 stores the strings `open`, `closed` and `moved` (`post.cgi:784` tests `if ($threadstate eq "closed" or $threadstate eq 'moved')`). 3.x declares `TOPIC_STATE` as `string(8)`, so the value fits; whether the 3.x renderer treats the literal string `closed` as closed **cannot be determined from the converter alone** and would need a trace through `Sources/Topic.pm` against a live board. Flagged, not asserted.
* **`ENABLE_SIG` and `ENABLE_EMO` carry the 2.x strings into numeric columns**, and the author knew:

  ```perl
  Convert_ib.pm:920-921
			#$bits[4] = $bits[4] eq 'yes' ? 1 : 0;
			#$bits[3] = $bits[3] eq 'yes' ? 1 : 0;
  ```

  Two commented-out normalization lines, immediately above the code that uses the
  un-normalized values. Both `yes` and `no` are true in Perl, so on the flat-file
  backends a post whose author unticked "show emoticons" gets them parsed anyway --
  and `$bits[3]` is also passed as the `SMILIES` flag to `Convert_for_db` at line
  923, so the emoticon substitution is baked into the stored text, not just the
  display flag. It cannot be undone later by fixing the column.
* **HTML-enabled forums lose their HTML.** 2.1.9 escapes all post input at write time (`ikon.lib:602-618`) and *un*escapes at render time only when the forum has HTML on:

  ```perl
  topic.cgi:258-259
           if ($htmlstate eq 'on') {
              $post =~ s/&lt;/</g; $post =~ s/&gt;/>/g; $post =~ s/&quot;/\"/g;
  ```

  The converter calls `Convert_for_db` with `HTML => 0` unconditionally
  (`Convert_ib.pm:922-926`), so the escaping is frozen into the stored text. It
  *does* carry `FORUM_HTML` into `forum_info` (line 562), so the destination forum
  is marked HTML-allowed -- but its imported posts render their markup as literal
  `<b>` text forever. New posts in the same forum behave differently from old ones.
* **Post icons, pin state, approval, author type are hardcoded**, which is correct -- 2.1.9 has none of these concepts.
* **`TOPIC_VIEWS` carries**, `TOPIC_POSTS` carries.
* Search is rebuilt from scratch: `d_posts` inserts a `search_log` row per post with stopwords stripped and truncated to `MAX_CHARS` (`Convert_ib.pm:951-970`). This is correct and is the only stage that populates the search index.

##### 4.8.4 The complete "does not convert" list

| Data | Present in 2.1.9? | Converted? | Evidence |
|------|-------------------|------------|----------|
| **Private messages** | Yes -- `messages/<Name>_msg.cgi` (inbox) and `_out.cgi` (outbox), 31 KB `messenger.cgi` | **NO** | `Convert_ib.pm` never opens anything under `messages/`. The only `$saved->{'LOCATION'}` paths it touches are `/data/allforums.cgi`, `/members/`, `/forum<N>/list.cgi`, `/forum<N>/<id>.thd`. No `message_data` or `message_stats` rows are created for any converted member. |
| **Polls** | **No** | n/a | `grep -lin poll` over all 2.1.9 scripts and `ikon.lib` returns nothing. Polls are a 3.x feature (`forum_polls`, `forum_poll_voters`, `Sources/iPoll.pm`). Nothing to lose. |
| **Attachments** | **No** | n/a | `grep -lin attach` over 2.1.9 returns nothing. 3.x-only (`Misc/Attachments.pm`). |
| **Post counts** | Yes, field 4 | **YES** | `MEMBER_POSTS => $mem_array[4]`, line 1175 |
| **Member titles (manual)** | Yes, field 2 | **NO** | `MEMBER_TITLE => ''`, line 1172 |
| **Member titles (post-count ladder)** | Yes, `data/membertitles.cgi` | **NO** | The ladder file is never read. 3.1.1 has an equivalent `member_titles` table, but the converter does not populate it -- the operator re-enters the thresholds by hand. The 3.1.1 defaults are not the 2.1.9 defaults. |
| **Topic subscriptions** | **No** | n/a | 2.1.9's `notify` param is a per-post email flag, not a stored subscription. There is no subscription store. 3.x `forum_subscriptions` + `Misc/Track.pm` is new. `d_forums:541` does create the empty per-forum subscriptions table. |
| **Signatures** | Yes, field 15 | **YES** | line 1185, re-parsed through `Convert_for_db` with the board's `SIG_ALLOW_*` settings |
| **Avatars (the setting)** | Yes, field 18 | **YES** | line 1187 with the `.gif` fixup |
| **Avatars (the image files)** | Yes, `non-cgi/avatars/` | **NO** | not copied; manual FTP required |
| **Emoticon images** | Yes, `non-cgi/emoticons/` | **NO** | not copied |
| **Per-member private forum ACL** | Yes, field 17 | **NO** | field 17 not read |
| **Per-forum private flag** | Yes, `allforums` field 8 | **NO** | field 8 not read; every forum lands world-open |
| **Per-forum "who can start threads"** | Yes, `allforums` field 9 | **NO** | field 9 not read |
| **Per-forum graphic** | Yes, `allforums` field 14 | **NO -- no destination exists** | `forum_info.cfg` has no image column. See 8.5. |
| **Announcements** | Yes, `announcements.cgi` + `data/news.cgi` | **NO -- no destination exists** | See 8.4. |
| **Ban list** | Yes, `data/banlist.cgi` (name / email / IP prefix) | **NO** | never read |
| **Bad-word filter** | Yes, `data/badwords.cgi` | **NO** | never read. 3.x has `WORD_FILTER` in board options with a different `original:method:replacement` syntax (`iTextparser.pm:310-320`); re-entry is manual. |
| **Board settings** | Yes, `data/boardinfo.cgi` | **NO** | never read; the 3.x installer collects everything fresh |
| **Styles / templates** | Yes, `data/styles.cgi`, `data/template.dat` | **NO** | 3.x compiles skins to Perl modules; no format correspondence |
| **Custom help pages** | Yes, `help/*.cgi` and `*.dat` | **NO** | 3.x stores help in a `help` table |
| **Admin / hack log** | Yes, `data/hacklog.cgi` | **NO** | never read |
| **Board statistics** | Yes, `data/boardstats.cgi` | **NO -- by design** | the guide tells you to rebuild them (`Read_Me.txt:117-118`) |
| **"Who's online" state** | Yes, `data/onlinedata.dat` | **NO** | ephemeral; correctly discarded |
| **Member last-post pointer** | Yes, field 14 | **NO** | not read; 3.x `LAST_POST` left empty |
| **Warning levels** | **No** | n/a | 3.x-only (`Sources/Warn.pm`) |
| **Notepads, calendar, birthdays** | **No** | n/a | 3.x-only |
| **Privacy statement page** | Yes, `privacy.cgi` + `data/privacy.dat` | **NO -- no destination exists** | See 8.2. |

The short version: **the converter moves members, forums, categories, moderators, topics and posts. Everything else on a 2.1.9 board is retyped by hand or lost.**

The single largest omission is private messages. 2.1.9 shipped a 31 KB messenger with an inbox and an outbox per member, and the guide never mentions that they will not come across.

#### 4.9 A structural note: the converter is DBM-only

This is not stated in the guide, and it changes the shape of the whole path.

Every stage except `members` goes through the abstraction layer -- `$db->insert`, `$db->query`, `$db->create_table` -- and would work on any of the five backends. The `members` stage does not. It bypasses `iDatabase` entirely and ties three DBM files by hand:

```perl
Convert_ib.pm:245-252
	my $base_dir = $INFO->{'DB_DIR'}.'member_profiles';

	# =tie a hash to the names index DB
	tie (my %N_INDEX, $AnyDBM_File::ISA[0], "$base_dir/MEMBER_NAME.idx", O_RDWR|O_CREAT, 0777);
	# =tie a hash to the email index DB
	tie (my %E_INDEX, $AnyDBM_File::ISA[0], "$base_dir/MEMBER_EMAIL.idx", O_RDWR|O_CREAT, 0777);
	# =tie a hash to the members DB
	tie (my %MEMS,  $AnyDBM_File::ISA[0], "$base_dir/member_profiles.db", O_RDWR|O_CREAT, 0777);
```

Those paths are exactly the DBM driver's layout (`iDatabase/Driver/DBM.pm:119`, `:995`) and nothing else's. It serializes records with a private copy of the driver's encoder rather than calling it:

```perl
Convert_ib.pm:1083-1095
sub	_encode_record {
	my ($obj, $values) = @_;
	my ($return, $cnt);
	for my $i (0 .. $obj->{'total_cols'}) {
		$values->{$obj->{'col_name'}->[$i]}  =~ s!^\s+!!g;
		...
		$return .= $values->{$obj->{'col_name'}->[$i]}."|^|";
	}
  $return =~ s!\Q|^|\E$!!;
  return $return;
}
```

And `d_mods` and `d_posts` both read `MEMBER_NAME.idx` back through a raw `tie` (lines 709 and 872) to resolve names to IDs, so the dependency propagates to every later stage.

Install 3.1.1 on MySQL and run the converter: the member pass writes DBM files that MySQL will never read, then the posts pass looks up authors in that same DBM index -- which does exist, so the lookups succeed -- and inserts posts into MySQL referencing member IDs that have no MySQL profile rows. A board full of authorless posts.

The shipped admin menu says so, in a label the migration guide predates by ten months:

```
Sources/Admin/Menuadmin.pm:633
			<br><span style='color:red'>&gt;</span> <a href='$url?AD=1&act=convert&s=$iB::SESSION' target='BODY'>iB 2 Import into DBM</a>
```

The guide, written 08/30/2001, says:

```
Read_Me.txt:79-80
When you are logged into your Ikonboard 3 Admin Center, Scroll to the bottom of the menu
and click on "IB 2 Import"
```

The menu label acquired "into DBM" at some point between the guide and the 3.1.1 release. `Menuadmin.pm` is also the only file in the tree re-edited after the 07/15/2002 development freeze -- see section 8.10.4.

So the documented 2.x -> 3.x -> MySQL path is a **two-hop**: convert into DBM, then Export Database (`Admin/Backup.pm`) and Import Database (`Admin/Import.pm`) into MySQL. Neither the migration guide nor the readme mentions the second hop.

---

### 8.5 The safety property

The guide's strongest claim, and the reason a 2001 board owner could try this at all:

```
Read_Me.txt:27-29

The convertor will NOT remove any of your Ikonboard 2 data - even if you
install Ikonboard 3 in the same location. The Ikonboard 2 database is perfectly
safe. It only reads from the Ikonboard 2 database, it never writes to it.
```

That is a strong, checkable, falsifiable claim about a program's behavior. It holds.

Enumerating every filesystem operation in `Convert_ib.pm` -- there are eighteen, and no `unlink`, `rename`, `mkdir`, `chmod` or `truncate` anywhere in the file:

| Line | Operation | Mode | Target | Under |
|------|-----------|------|--------|-------|
| 221 | `opendir DIR` | read | `.../members` | **iB2** |
| 261 | `open INSTALL` | `>>` append | `Temp/mem_install.txt` | iB3 |
| 306 | `open LOCKFILE` | `>` write | `Temp/members.lockfile` | iB3 |
| 414 | `open LOCKFILE` | `>` write | `Temp/categories.lockfile` | iB3 |
| 606 | `open LOCKFILE` | `>` write | `Temp/forums.lockfile` | iB3 |
| 745 | `open LOCKFILE` | `>` write | `Temp/mods.lockfile` | iB3 |
| 764 | `open TEMPFILE` | read | `Temp/posts.lockfile` | iB3 |
| 768 | `open DESTFILE` | read | `Temp/dest_posts.lockfile` | iB3 |
| 862 | `open LISTFILE` | **read** | `.../forum<S>/list.cgi` | **iB2** |
| 910 | `open POSTFILE` | **read** | `.../forum<S>/<id>.thd` | **iB2** |
| 985 | `open TEMPFILE` | `>>` append | `Temp/posts.lockfile` | iB3 |
| 989 | `open DESTFILE` | `>>` append | `Temp/dest_posts.lockfile` | iB3 |
| 1067 | `do` | read | `config/<table>.cfg` | iB3 |
| 1101 | `open FORUMDATA` | **read** | `.../data/allforums.cgi` | **iB2** |
| 1131 | `open FORUMDATA` | **read** | `.../data/allforums.cgi` | **iB2** |
| 1162 | `open MEMBER` | **read** | `.../members/<name>.cgi` | **iB2** |
| 1241 | `open CONFIGFILE` | read | `Temp/ib2.data` | iB3 |
| 1264 | `open CONFIGFILE` | `>` write | `Temp/ib2.data` | iB3 |

Six operations reference `$saved->{'LOCATION'}` -- the iB2 tree -- and all six are read-only: one `opendir` and five bare-mode `open`s. Every write target (`>` or `>>`) is under `$INFO->{'DB_DIR'}`, the iB3 database directory. The three tied DBM handles at lines 248-252 are also under `$INFO->{'DB_DIR'}`.

**Verified. The converter genuinely never opens a 2.x file for writing.** The old board survives byte-for-byte and can keep serving traffic throughout, which is what makes the parallel-install strategy of section 8.6 workable.

Two footnotes. The property is about `Convert_ib.pm` only -- installing 3.1.1 *over* a 2.x board on the same paths can clobber files by name collision (both ship an `ikonboard.cgi`), which is a different hazard and is the reason the guide offers the "totally seperate location" option. And nothing in the converter is transactional on the *new* side: a batch that dies mid-loop leaves partially written DBM files and no rollback. The guarantee is one-directional.

---

### 8.6 The Tool_Box redirect stubs

`Upgrading/iB2-iB3_Upgrading/Tool_Box/` contains three files:

```
-rwxr-xr-x 1164 Aug 30  2001 forums.cgi
-rwxr-xr-x 1164 Aug 30  2001 ikonboard.cgi
-rwxr-xr-x 1164 Aug 30  2001 topic.cgi
```

Identical size, identical mtime, and identical content -- MD5 `b1000a472c7105deff3784964d51911d` for all three. They are one file under three names. Whole thing:

```perl
#!/usr/bin/perl
###############################################
#
# Simple Little Redirect Script
# For use with Ikonboard 3
#
###############################################

use strict;
my $location = '';

###############################################
#
# SET UP
# ------
#
#
# Edit the variable"$location" to reflect the URL of your
# Ikonboard 3.


$location = "http://www.domain.com/cgi-bin/ib3/ikonboard.cgi";

# END OF SET UP, DO NOT EDIT ANYTHING UNDER THIS LINE
###############################################

print "Content-type: text/html\n\n";

print qq~<html>
          <head><title>We've upgraded!</title>
            <meta http-equiv="refresh" content="4; url=$location">
          </head>
          <body bgcolor='#FFFFFF'>
          <font face='verdana' size='3'><b>We've upgraded our forums!</b>
          <br><br>The bulletin board has been moved <a href='$location'>here</a>
          <br>Please update your bookmarks.
          <br><br><font size='2'>( <a href='$location'>Click here if you are not automatically redirected</a> )</font>
          </body>
         </html>
        ~;

exit();
```

The "header for simple instructions" the guide refers to is lines 12-22: one comment block and one assignment. Edit `$location` to your new board's `ikonboard.cgi` URL, save, upload, `chmod 0755`. That is the whole procedure:

```
Read_Me.txt:69-72
Open each of the files up in a text editor and read the header for simple instructions on how
to edit to suit your server. When you have completed the edit, simply save, and upload to your
Ikonboard 2 location - remembering to CHMOD them to the required value for CGI scripts (usually
0755).
```

These are **not** HTTP redirects. There is no `Location:` header and no 301 or 302 -- it is `Content-type: text/html` followed by a four-second `<meta http-equiv= "refresh">` and a human-readable interstitial. Search engines of 2001 treated a meta-refresh as a weak signal at best, and the four-second delay is a deliberate "read this notice" pause. The goal is telling *members* the board moved, not preserving link equity.

They are also indiscriminate. Every incoming URL -- `topic.cgi?forum=3&topic=1017245891`, `forums.cgi?forum=9` -- lands on the new board's index. Deep links are not translated, and they could not be: the converter mints new sequence-assigned topic IDs (`Convert_ib.pm:889`, `my $new_id = $db->insert(...)`), so the old topic number has no forward mapping. Every bookmark and every inbound link on the internet to a specific thread breaks permanently at conversion. Nothing in the package mitigates that.

#### 6.1 The two deployment scenarios

```
Read_Me.txt:60-68
You'll notice three CGI scripts in the directory called "Tool_Box". These are designed to
make it as easy as possible to redirect the traffic to your new location. Even if you have
installed over your current installation, some of the Ikonboard 2 scripts are redundant and
accessing them may produce errors for your members. With this in mind, you may wish to
do the following.
If you have installed your Ikonboard 3 in the same location as your Ikonboard 2, then ONLY
edit and upload "topic.cgi" and "forums.cgi".
If you have installed your Ikonboard 3 in a different location from your Ikonboard 2, then edit
and upload all three scripts to your Ikonboard 2 location.
```

| Scenario | Upload | Why |
|----------|--------|-----|
| **3.x installed over the 2.x board, same directory** | `topic.cgi` and `forums.cgi` **only** | 3.1.1's own `ikonboard.cgi` already occupies that name and is the live dispatcher. Overwriting it with a redirect stub would point the new board at itself -- an infinite loop. `topic.cgi` and `forums.cgi` are the two 2.x script names 3.1.1 does *not* claim, so they linger as orphans serving errors to anyone with a bookmark. |
| **3.x installed elsewhere** | all three | Nothing at the old path is live. All three names get a stub, including `ikonboard.cgi`, and the old directory becomes a pure signpost. |

#### 6.2 Why `topic.cgi` and `forums.cgi` specifically

Because those two names carry essentially all of a 2.1.9 board's inbound links. From `data/progs.cgi`, the 2.x URL vocabulary is:

```
$forumsummaryprog = "ikonboard.cgi";   # board index
$forumsprog       = "forums.cgi";      # topic list for one forum
$threadprog       = "topic.cgi";       # the thread itself
```

Every deep link anyone ever pasted into an email, a signature, another forum or a search index is `topic.cgi?forum=N&topic=ID`. Every "browse this forum" link is `forums.cgi?forum=N`. The board index is `ikonboard.cgi`. The remaining twenty-eight 2.x scripts are either behind a login (`profile.cgi`, `messenger.cgi`, `post.cgi`), admin-only (`set*.cgi`, `admincenter.cgi`), or transient (`whosonline.cgi`, `newposts.cgi`, `search.cgi`) -- nobody bookmarks them, so they get no stub. Three files covers the whole externally-visible surface.

The guide's stated motivation is precise, and it is a support-load argument rather than an SEO one: "some of the Ikonboard 2 scripts are redundant and accessing them may produce errors for your members." A 2.x `topic.cgi` left in place next to a 3.x install would try to `require "ikon.lib"` and read `forum3/list.cgi` -- files that may still exist, in which case it serves stale content indistinguishable from the live board, or may not, in which case it dies to the browser with `CGI::Carp "fatalsToBrowser"` and a raw Perl error. Both outcomes generate support tickets. The stub converts either into a sentence a member can act on.

---

### 8.7 Feature-by-feature: what 3.1.1 gained

Grouped by subsystem. "2.1.9 equivalent" is assessed against the 2.x tree, not against the guide's marketing.

#### 7.1 Identity, sessions, permissions

| Feature | 3.1.1 module | 2.1.9 equivalent |
|---------|--------------|------------------|
| Server-side sessions | `Sources/Sessions.pm`, `active_sessions` table (12 cols) | **None.** 2.1.9 authenticates by re-reading the member file and comparing plaintext against a cookie on every request (`loginout.cgi:95`, `ikon.lib:383`). |
| Password hashing | `Lib/Crypt.pm`, `Lib/MD5.pm`, `FUNC::Member::MD5` | **None.** Plaintext. |
| Member groups + permission masks | `Admin/MemberGroups.pm`, `mem_groups` table (34 cols) | **None.** 2.1.9 has one field, `$membercode`, with three values: `me`, `mo`, `ad`. |
| Registration authorization queue | `Admin/Authorise.pm`, `authorisation` table | **None.** 2.1.9 registers immediately; the only gate is an optional emailed password (`register.cgi:124-131`). |
| Warning levels | `Sources/Warn.pm`, `member_profiles.WARN_LEVEL` | **None.** |
| Per-forum moderator permission sets | `Sources/ModSet.pm`, `forum_moderators` (21 permission columns) | **Partial.** A comma list of names in `allforums` field 5. All-or-nothing. |
| Moderator control panel | `Sources/ModCP.pm` (22 `CODE=` actions incl. mass move, mass prune, merge) | **Partial.** `postings.cgi` (57 KB) has per-topic moderation; no batch operations, no merge. |
| Moderator action logging | `moderator_logs` table | **None.** `data/hacklog.cgi` logs failed admin auth, not moderator actions. |
| IP lookup | folded into `Sources/ModCP.pm` | `viewip.cgi` |

#### 7.2 Content

| Feature | 3.1.1 module | 2.1.9 equivalent |
|---------|--------------|------------------|
| Polls | `Sources/iPoll.pm`, `forum_polls` + `forum_poll_voters` | **None.** No occurrence of "poll" anywhere in the 2.1.9 tree. |
| File attachments | `Misc/Attachments.pm`, `attachments` table, `forum_info.ALLOW_ATTACH`, `mem_groups.ATTACH_MAX` | **None.** `install.cgi` sets `$CGI::DISABLE_UPLOADS = 1` board-wide. |
| Topic subscriptions | `Misc/Track.pm`, `forum_subscriptions` (10 cols) | **None.** 2.1.9's `notify` is a one-shot per-post email flag. |
| Post reporting | `Misc/Report.pm`, `mod_email` table | **None.** |
| Post queue / moderated forums | `forum_posts.QUEUED`, `forum_topics.APPROVED`, `forum_info.MODERATE`, `mod_posts` table | **None.** |
| Topic pinning | `forum_topics.PIN_STATE`, `Moderate.pm` CODE 15/16 | **None.** |
| Topic merge | `ModCP.pm` CODE `merge` | **None.** |
| Per-topic read tracking | `topic_views` table (**new in 3.1**, see section 8.9) | **Partial.** A cookie of `topicid-timestamp` pairs (`ikon.lib:418-432`) -- client-side, size-limited. |
| Forum rules | `forum_rules` table, `forum_info.SHOW_RULES` | **None.** |
| Auto-prune | `forum_info.PRUNE_DAYS`, `ModCP.pm` prune actions | **None.** |
| Printable view | `Sources/PrintPage.pm` | `printpage.cgi` |
| Post icons | `forum_posts.POST_ICON`, `forum_topics.TOPIC_ICON` | **None.** |

#### 7.3 Messaging

| Feature | 3.1.1 module | 2.1.9 equivalent |
|---------|--------------|------------------|
| Private messaging | `UserCP/Messenger.pm` (26 KB), `Messsend.pm` (27 KB), `Messview.pm` (21 KB) + `message_data` (13 cols) and `message_stats` (11 cols) | `messenger.cgi`, 31 KB, one script, two flat files per member. **Rewritten and 3x larger.** |
| Virtual folders | `message_data.VIRTUAL_DIR`, `message_stats.VIRTUAL_DIR` | **None.** Fixed inbox + outbox. |
| Address book | `address_books` table | **None.** |
| PM quota per group | `mem_groups.MAX_MESSAGES` (**new in 3.1**) | **None.** |
| New-PM popup | `message_stats.SHOW_POPUP` | **None.** |
| Mass private messaging | `Sources/Massmsend.pm` (18 KB) | **None.** |
| Mass email to members | `email_templates.MASS_MAIL`, `member_profiles.ALLOW_ADMIN_EMAILS` | **None.** |
| Email a member / forward a topic / invite a friend | `Misc/MailMember.pm`, `Misc/Forward.pm`, `Misc/Invite.pm` | `ikonfriend.cgi` covers invite only |
| Mail transport | `Mail/Sendmail.pm` with Base64 | `ikonmail.lib`, 3.3 KB |

#### 7.4 Member-facing extras

| Feature | 3.1.1 module | 2.1.9 equivalent |
|---------|--------------|------------------|
| Event calendar | `Sources/Calendar.pm`, `calendar` table | **None.** |
| Birthday announcements | `Sources/Happybd.pm` | **None.** |
| Member notepads | `Sources/NotePad.pm`, `member_notepads` table (**new in 3.1**) | **None.** |
| Member list with sort/filter | `Sources/Memberlist.pm` | **Partial.** `setmembers.cgi`, admin-only. |
| Top posters | `Sources/Posters.pm` | **None.** |
| Per-member skin selection | `member_profiles.MEMBER_SKIN` | **None.** |
| Per-member language | `member_profiles.LANGUAGE` | **None.** |
| Post font color | `member_profiles.POST_FONT_COLOR` (**new in 3.1**, contributed -- the `.cfg` carries `# added by kevaholic00`) | **None.** |
| MSN / Yahoo fields | `MSNNAME`, `YAHOONAME` | AOL and ICQ only |
| Gender | `member_profiles.GENDER` (**new in 3.1**) | **None.** |
| Real name | `member_profiles.MEMBER_NAME_R` (**new in 3.1**), optional or required per `Register.pm:78-82` | **None.** |
| Lost password | `UserCP/Lostpass.pm` with MD5 token | `profile.cgi?action=lostpassword`, emails a `crypt`-derived random string |

#### 7.5 Presentation and localization

| Feature | 3.1.1 module | 2.1.9 equivalent |
|---------|--------------|------------------|
| Skin engine | `Admin/SkinControl.pm`, `Admin/SkinHandler.pm`, 61 `Skin/Default/*.pm` + `.cfg` pairs compiled to Perl | `setstyles.cgi` writing `data/styles.cgi` -- a flat list of color variables |
| Multiple skins, per-member selection | `SkinControl.pm` + `MEMBER_SKIN` | **None.** One global style. |
| Skin import/export | `SkinControl.pm` `CODE=import` (`Menuadmin.pm:566`) | **None.** |
| Language packs | `Admin/LangControl.pm`, `Languages/en/*.pm` (33 files) | **None.** Every string is a literal inside a script. |
| Board templates | `Admin/BoardTemplates.pm`, `Admin/Templates.pm`, `templates` table | `settemplate.cgi` (7.6 KB) writing `data/template.dat` |
| SSI for the host site | `Sources/SSI/Parser.pm`, `ssi_templates` table | **None.** |
| Emoticon administration | `Admin/EmoticonControl.pm` | **None.** Emoticons are hardcoded in `misc.cgi`. |
| Web ring | `Admin/WebRing.pm` | **None.** |

#### 7.6 Storage, search, operations

| Feature | 3.1.1 module | 2.1.9 equivalent |
|---------|--------------|------------------|
| Storage abstraction, 5 backends | `iDatabase/SQL.pm` + `Driver/{CSV,DBM,mySQL,pgSQL,Oracle}.pm` | **None.** Flat files, hardcoded paths, in every script. |
| Declared schema | `Database/config/*.cfg`, 29 tables / 305 columns | **None.** Implicit in the `print FILE` statements. |
| Per-backend search API | `Search/api.pm` + `Search/API/api_{DBM,mySQL,pgSQL,Oracle}.pm`, `search_log` table | `search.cgi` (26 KB) grepping `.thd` files |
| Backend switching | `Admin/dbHandler.pm` `CODE=switch` | **None.** |
| Database backup / export | `Admin/Backup.pm` -> `EXPORT-<time>-*.tar` | **None.** |
| Database restore / import | `Admin/Import.pm` | **None.** |
| SQL client in the admin CP | `Admin/SQLclient.pm` | **None.** |
| DBM reindexer | `Admin/DBMclient.pm` | **None.** |
| In-browser file manager | `Admin/Filemanager.pm` | **None.** |
| DB password encryption at rest | `Sources/ARC4.pm` + `MIME/Base64.pm`, keyed off a `.pwd` file in `Data/` | **None.** 3.0.x also stored it plain -- see the `upgrade_info_mySQL.txt` warning in section 8.9. |
| mod_perl support | `Sources/iPerl/mod_perl.pm`, `Tools/mod_perl/start_up.pl` | **None.** |
| Integrity check | `Admin/Tools.pm` | `checkboard.cgi` |
| Admin log viewer | `Admin/Adminlogs.pm` | `checklog.cgi` |
| Board statistics | `Admin/Stats.pm` | `data/boardstats.cgi` |
| Temp file cleanup | `Admin/Tempfiles.pm` | **None.** |
| Multi-step install wizard | `installer.cgi` + `install_modules/*` | `install.cgi`, 36 KB, single page |
| iB2 converter | `Admin/Convert_ib.pm` | n/a |

The cryptography column deserves its own summary, because it is the widest single gap. In 2.1.9 exactly two files reference any crypto primitive, and in both cases `crypt` is being used as a random-string generator. In 3.1.1, twenty-two files reference MD5, ARC4, Base64 or `crypt`, for password hashing, session tokens, registration tokens, lost-password tokens, and encryption of the stored database password.

---

### 8.8 What 2.1.9 had that 3.1.1 dropped

The honest answer to this is not "nothing", and it is not a long list either. Going through `ib219/cgi-bin/` file by file against the capability map turns up five items with no home in 3.1.1, of which two are significant.

#### 8.1 Method

Thirty-one `.cgi` scripts and three `.lib` files in `ib219/cgi-bin/`. Of those, twenty-nine map cleanly onto a 3.1.1 module (section 8.2.3). The residue:

* `privacy.cgi` -- the capability map already flags it as dropped. Confirmed below.
* `announcements.cgi` -- the capability map claims `Admin/Options.pm`. This is wrong.
* `setmembertitles.cgi` -- the capability map claims `Admin/MemberGroups.pm`. Also wrong, and the real answer is more favorable than the map suggests.
* Six help topic files (`help/Deleting_Threads.cgi`, `Locking_threads.cgi`, `Moderation_mode.cgi`, `Moving_Topics.cgi`, `Postings.cgi`, `Unlocking_threads.cgi`) and three data files (`data/membertitles.cgi`, `data/progs.cgi`, `data/styles.cgi`) that the map lists as unmapped. These are content and configuration, not capabilities -- 3.1.1 has a `help` table, a dispatcher instead of `progs.cgi`, and a skin engine instead of `styles.cgi`. Nothing is lost structurally.

Plus one item that is not a script at all: the per-forum graphic.

#### 8.2 `privacy.cgi` -- dropped, confirmed

2.1.9 ships a privacy statement page: `privacy.cgi` (3.4 KB) rendering `data/privacy.dat` (2.5 KB of default text, dated 10/31/2000) inside the board chrome, linked from the board footer.

3.1.1 has no equivalent. Grepping the whole tree for "privacy" returns seven hits and every one of them is a different meaning -- the *invisible mode* login option:

```
Languages/en/LoginWords.pm:27
        'privacy'               => q!<b>Privacy</b>, do you want to appear on the active users list?!,
```
```
Sources/LogInOut.pm:41-46
	# As we use $iB::IN{Privacy}, we'll use the param
	...
	if ($iB::CGI->param('Privacy') == 1) {
```

There is no privacy statement page, no `privacy` template, no admin field for one. A board upgrading from 2.1.9 to 3.1.1 in 2002 lost its published privacy policy and had no supported place to put it back short of writing a custom module or wedging it into the help table.

#### 8.3 Post-count member titles -- preserved, and improved

The capability map says `setmembertitles.cgi -> Admin/MemberGroups.pm`, glossed as "3.x replaces post-count titles with member groups". Checking this directly: `MemberGroups.pm` contains **zero** references to `member_titles` or to any post-count threshold. The `member_titles` table is administered from `Admin/MemberControl.pm` (lines 616, 698, 712, 725, 737, 744) and consumed at render time by `Sources/Topic.pm:227` and `Sources/Lib/FUNC.pm:1363`.

So the old behavior is **not** replaced. It survives, in a strictly richer form:

| 2.1.9 `data/membertitles.cgi` | 3.1.1 `member_titles` table |
|---|---|
| `$mpostmark<N>` -- post threshold | `POSTS` |
| `$mtitle<N>` -- title text | `TITLE` |
| `$mgraphic<N>` -- pip image | `PIPS` |
| fixed at 5 rungs plus `$admingraphic` | unlimited rows, `ID` primary key |
| -- | `ADVANCE_GROUP` -- auto-promote to a member group at N posts |

The capability map is wrong on this point; the correct mapping is `setmembertitles.cgi -> Admin/MemberControl.pm`. Nothing was lost.

What *was* lost is the data: as established in 4.8, the converter reads neither `data/membertitles.cgi` nor the per-member `$membertitle` field, so the ladder is re-entered by hand and manual titles are gone. A capability preserved, a dataset dropped.

#### 8.4 Announcements -- dropped, and the dispatch table still points at it

2.1.9's `announcements.cgi` is 24 KB and implements a full CRUD announcement system over `data/news.cgi`: add, edit, delete, with display on the board index. `$announceprog = "announcements.cgi";` is a first-class entry in `data/progs.cgi`.

3.1.1 has nothing. The word "announce" appears exactly once in the entire tree -- in the moderator dispatch table:

```perl
Sources/Moderate.pm:1401
			 '11'     => \&Announcement,
```

And `sub Announcement` **is not defined anywhere in the shipped release**. A recursive grep for `sub\s*Announcement` across the whole tree returns nothing. The entry is a live reference to a nonexistent subroutine; `\&Announcement` compiles fine as a forward reference, and calling it with `CODE=11` dies with `Undefined subroutine &Moderate::Announcement called`.

There is no `announcements` table in the 29-table schema. No admin module manages announcements. `Admin/Options.pm` -- the capability map's proposed home -- does not mention them. The feature was planned, wired into the dispatcher, and never built.

For a board converting from 2.1.9 this is the second-largest loss after private messages: the announcement content in `data/news.cgi` is neither converted nor placeable anywhere in the new board.

#### 8.5 Per-forum graphics -- dropped, no destination column

2.1.9 gives each forum an optional custom image, field 14 of `allforums.cgi`, edited through `setforums.cgi` and rendered by `forums.cgi`:

```perl
forums.cgi:129-132
        if ($forumgraphic) {
        $forumgraphic = qq~<a href="$forumsprog?forum=$inforum"><img src="$imagesurl/images/$forumgraphic" border=0></a>~;
        }
        else { $forumgraphic = qq~<a href="$forumsprog?forum=$inforum"><img src="$imagesurl/images/$boardlogo" border=0></a>~; }
```

`Database/config/forum_info.cfg` has 25 columns and none of them is an image. `categories.cfg` *does* have `IMAGE` (ordinal 7) and `URL` (8) -- so category graphics exist in 3.1.1 and forum graphics do not. The capability is gone one level down the hierarchy. Any board that gave each forum its own banner loses that distinction on conversion.

#### 8.6 Things that look like losses and are not

For completeness, four candidates checked and cleared:

* **Per-member private forum access.** 2.1.9's `$privateforums` field (`f1=yes&f4=yes...`) is more *granular* than anything 3.1.1 offers -- 3.1.1 grants by member group (`FORUM_VIEW_THREADS` matched against a group mask) or by a shared per-forum password (`FORUM_PROTECT`). Per-individual grants are not expressible. In practice group masks are a better tool and 2.x's per-member ACL was unmanageable at scale, so this is a design change rather than a regression -- but it is a genuine expressiveness loss, and the *data* is dropped outright (4.8.1).
* **`checkboard.cgi`'s data restoration.** 2.1.9 could partially reconstruct destroyed data files. 3.1.1's `Admin/Tools.pm` performs integrity checks. The 2.x tool existed because the 2.x storage model corrupted itself routinely; the 3.x model does not have the same failure mode. Not a loss.
* **`misc.cgi`'s ICQ / AIM / emoticon / IkonCode popups.** All four survive as `Misc/ICQ.pm`, `Misc/AOL.pm`, `Legends.pm` (`CODE=emoticons`, `CODE=ibcode`).
* **`data/progs.cgi`.** A configurable script-name table, needed in 2.1.9 because each feature was a separate CGI file. 3.1.1 has one entry point and a dispatch table. Obsoleted, not lost.

#### 8.7 Summary

| Lost | Severity | Notes |
|------|----------|-------|
| Private messages (the data) | **High** | 4.8.4. Converter never touches `messages/`. Undocumented. |
| Announcements (the feature and the data) | **High** | 8.4. No table, no module, dangling dispatch entry. |
| Privacy statement page | Medium | 8.2. No equivalent anywhere in 3.1.1. |
| Per-forum graphic | Low-Medium | 8.5. No destination column. |
| Per-member forum ACL granularity | Low-Medium | 8.6. Replaced by group masks; the data is dropped. |
| Manually assigned member titles | Low | 4.8.1. Capability survives, data does not. |
| Ban list, bad-word list, board settings, styles, help pages, hack log | Low each | Capabilities all survive; every dataset is retyped. |

---

### 8.9 The 3.0.x -> 3.1.1 MySQL migration

A different kind of migration entirely: same product, same schema *shape*, changed column types and a handful of new tables and columns. It is delivered as one CGI script with a single link on it.

#### 9.1 The procedure

From `upgrade_info_mySQL.txt`, verbatim in outline:

1. Recover the database credentials by hand out of the old `/Data/Boardinfo.pm` (`DB_NAME`, `DB_USER`, `DB_PASS`, `DB_PREFIX`, `DB_IP`, `DB_PORT`). The document is emphatic about why: *"You must take good note of that info because this new version will encrypt the password for more security."* 3.0.x stored the database password in plaintext in `Boardinfo.pm`; 3.1.1 ARC4-encrypts and Base64-encodes it against a key file in `Data/*.pwd`. After the upgrade the plaintext is no longer recoverable from the board.
2. Take the board offline -- the document's advice is to *delete* `ikonboard.cgi*: *"I recommend that you simply remove you existing ikonboard.cgi."*
3. Remove any columns you added yourself. *"If you have added any new feilds to the database tables they MUST BE REMOVED BEFORE you start the upgrade process."*
4. Upload `alter_table.cgi` into the old board's base directory, `chmod 0755`, run it in a browser. Delete it afterward: *"When you have finished with the script, you MUST remove it for SECURITY reasons."* -- which is not optional advice, since the script takes no authentication whatsoever and will happily re-run its `DROP TABLE` for anyone who requests the URL.
5. Run the 3.1.1 installer against the migrated database, answering **no** when it offers to create tables, and stop the moment `Data/Boardinfo.cgi` appears -- before the installer populates base data.

Step 5 is the delicate one, and the document knows it:

```
upgrade_info_mySQL.txt:171
Where it is asking you if you want IB to create the tables for you, you say NO, YOU MUST SAY NO THERE.
```

The instruction to stop the wizard by watching for a file to appear over FTP -- *"look inside the /Data folder using an ftp client and try to find a file called Boardinfo.cgi, if it's not there click again on the arrow"* -- is the least reassuring sentence in the entire package. There is no "upgrade" mode in the installer. The operator is being asked to drive a fresh-install wizard partway and abort it at exactly the right step.

#### 9.2 What actually changed between 3.0 and 3.1

`alter_table.cgi` is a flat sequence of `$dbh->do(...)` calls across seventeen tables. Reading it as a diff, this is what late 3.x development added:

**New tables (2):**

| Table | Columns | Feature it enables |
|-------|---------|--------------------|
| `topic_views` | `ID`, `TOPIC_ID`, `FORUM_ID`, `MEMBER_ID`, `VIEWED`, `POSTED_IN`, `SENT` | Server-side per-member read tracking -- replaces the cookie-based post markers. `POSTED_IN` and `SENT` also drive subscription mail. |
| `member_notepads` | `MEMBER_ID`, `NOTEPAD_TEXT`, `SAVED_P`, `SAVED_M` | `Sources/NotePad.pm` -- member scratchpad plus saved post and saved message drafts. |

**Table dropped and recreated (1):** `mod_email`. Destructive -- `DROP TABLE` at `alter_table.cgi:478` with no export first. Any pending post reports are discarded. The new definition adds `CHOICE`, `WHENE` and `SENT` columns.

**New columns (13):**

| Table | Column | Type | Reads as |
|-------|--------|------|----------|
| `forum_moderators` | `ADD_TOPIC_WATCH` | `tinyint(1)` | new moderator permission |
| `forum_moderators` | `REMOVE_TOPIC_WATCH` | `tinyint(1)` | new moderator permission |
| `forum_topics` | `WATCHED` | `tinyint(1)` | topic-watch flag |
| `calendar` | `UTIME` | `int(10)` | real timestamp alongside day/month/year |
| `calendar` | `FORUM_ID` | `smallint(5)` | calendar events linked to topics |
| `calendar` | `TOPIC_ID` | `bigint(10)` | same |
| `mem_groups` | `ADD_EVENT` | `char(3)` | per-group calendar posting |
| `mem_groups` | `UPLOAD_AVATARS` | `tinyint(1)` | per-group avatar upload |
| `mem_groups` | `MAX_MESSAGES` | `int(3)` | per-group PM quota |
| `member_profiles` | `LAST_LOG_IN` | `int(10)` | |
| `member_profiles` | `LAST_ACTIVITY` | `int(10)` | |
| `member_profiles` | `GENDER` | `tinyint(1)` | |
| `member_profiles` | `MEMBER_NAME_R` | `varchar(40)` | real name |
| `member_profiles` | `POST_FONT_COLOR` | `varchar(15)` | the `kevaholic00` contribution |

**Renamed (1):** `forum_subscriptions.LAST_SENT` -> `SEND_ONCE`, with the type changed to `tinyint(1)`. A semantic inversion: 3.0 recorded *when* a notification was last sent; 3.1 records *whether* to send only once.

**Widened (many).** Every `TOPIC_ID` and `POST_ID` in every table moves to `bigint(10)`; `FORUM_ID` to `smallint(5)`; `MODERATOR_ID` to `mediumint(5)`; `LOG_ID` to `bigint(20) unsigned`; `TOPIC_VIEWS` and `ATTACH_HITS` to `int(5)`; `FORUM_TOPICS`/`FORUM_POSTS` to `int(6)`; `USER_AGENT` to `varchar(80)`; `active_sessions.LOCATION` to `varchar(160)`; `member_profiles.INTERESTS` to `text`. That is the signature of ID columns overflowing on real boards: 3.x mints IDs from `time`, and a 10-digit unix timestamp does not fit an `int` in the way 3.0 declared it.

**Indexes (13 `CREATE INDEX` statements).** `authorisation(DATE_ENTERED)`, `authorisation(MEMBER_ID)`, `search_log(FORUM_ID,DATE)`, `forum_polls(FORUM_ID,POLL_ID)`, `calendar(FORUM_ID)`, `calendar(TOPIC_ID)`, `forum_posts(TOPIC_ID,QUEUED)`, `forum_posts(FORUM_ID,TOPIC_ID,QUEUED)`, `forum_posts(POST_DATE)`, `forum_poll_voters(MEMBER_ID,POLL_ID,FORUM_ID)`, `forum_topics(FORUM_ID,PIN_STATE,TOPIC_LAST_DATE)`, `forum_topics(WATCHED,FORUM_ID)`, `forum_topics(TOPIC_LAST_DATE)`, `forum_subscriptions(FORUM_ID,TOPIC_ID,MEMBER_ID)`, `message_data(MEMBER_ID)`, `message_data(DATE)`. Plus an inline index on the new `topic_views(MEMBER_ID)`.

That last group is the most telling. **3.0.x shipped a MySQL schema with essentially no secondary indexes at all.** Every one of these covers a hot query path -- the forum topic list sorted by last post date, post lookup by topic, search by forum and date, PM lookup by member. 3.1 is the release where somebody put a real board under load and read the slow query log. The document acknowledges the cost:

```
upgrade_info_mySQL.txt:166
Note that it may take a few minutes to run through this script if you are on a large database. We are creating indexes and that takes time to do.
```

#### 9.3 Defects in `alter_table.cgi`

* **Not idempotent, no transaction, no rollback.** `ALTER TABLE ... ADD` on an existing column is an error; `CREATE INDEX` on an existing index is an error; `DROP TABLE` on the already-dropped `mod_email` is an error. `sub errors` prints and `exit`s. A re-run after a partial failure stops at the first already-applied statement, in an indeterminate state, with no way to resume.
* **`ADD WATCHED` is issued twice**, at lines 376 and 380, both printing "WATCHED column added to table". The second is guaranteed to fail -- and on the `forum_topics` block alone the error handler has been **commented out** on every statement:

  ```perl
  alter_table.cgi:376-382
    $dbh->do("ALTER TABLE ".$pre."forum_topics ADD WATCHED tinyint(1) NULL");
    #&errors("mySQL error: $DBI::errstr") if $DBI::errstr;
    print "WATCHED column added to table<br>";

    $dbh->do("ALTER TABLE ".$pre."forum_topics ADD WATCHED tinyint(1) NULL");
    #&errors("mySQL error: $DBI::errstr") if $DBI::errstr;
    print "WATCHED column added to table<br>";
  ```

  The suppression was almost certainly added *because* of the duplicate -- it is the
  one block where an error is expected, so the check was silenced rather than the
  duplicate removed. The sample output in `upgrade_info_mySQL.txt:103-104` faithfully
  shows "WATCHED column added to table" twice, which means the author ran it, saw
  the doubled line, and shipped it.
* **`YEAR` is widened to `smallint(4)` but the message says `smallint(2)from DAY`** (line 276) -- a copy-paste in the progress output, not in the SQL.
* **No authentication.** It reads `Boardinfo` for credentials and executes on any GET to `alter_table.cgi?act=run`. Hence the shouted instruction to delete it.
* **No backup step anywhere in the procedure.** The document never says to dump the database first, despite the process including a `DROP TABLE`.

#### 9.4 The other four backends

`alter_table.cgi` is MySQL-specific by construction -- `DBI:mysql:` DSN at line 80, MySQL-only type names throughout. There is no `alter_table` for CSV, DBM, PostgreSQL or Oracle, and no equivalent procedure documented anywhere in the package.

Could the Export/Import pair substitute? Examining it: `Admin/Backup.pm` writes `EXPORT-<time>-NUM<n>.tar` files of `|^|`-encoded records; `Admin/Import.pm` reads them back into a chosen driver (`Import.pm:110-120`). The decoder is positional:

```perl
Sources/iDatabase/Driver/Base.pm:228-242
sub decode_record {
    my ($obj, $record) = @_;
    my $return = {};
    chomp $record;
    my @Tmp = split (/\Q|^|\E/, $record);
    for my $i (0 .. $obj->{'total_cols'}) {
        ...
        $return->{ $obj->{'col_name'}->[$i] } = $Tmp[$i];
    }
    return $return;
}
```

`$obj->{'col_name'}` comes from `load_cfg`, which reads the **installed** `.cfg`. Feed a 3.0-era export -- written against 3.0's column list, which lacks `member_profiles.GENDER`, `MEMBER_NAME_R`, `POST_FONT_COLOR`, `LAST_LOG_IN` and `LAST_ACTIVITY`, and lacks `forum_topics.WATCHED` -- into a 3.1.1 install, and every field after the first divergence shifts by one position. Names land in `GENDER`, timestamps land in strings. **Export/Import is a backend-migration tool, not a version-migration tool**, and using it as one silently corrupts the data.

The realistic conclusion for a 3.0.x operator in 2002: if you were on MySQL you had a rough but workable path. **If you were on DBM, CSV, PostgreSQL or Oracle, no shipped upgrade to 3.1.1 existed.** Your options were to hand-write the equivalent schema changes against your own store, or to stay on 3.0.x. Given that DBM was the *default* backend -- it is the one the iB2 converter targets, and the one `Import.pm:114` lists first -- this is not a small population. `Admin/DBMclient.pm` and `Admin/dbHandler.pm` exist and can reindex and switch drivers, but neither performs a version migration.

One more per-backend asymmetry, unrelated to upgrading but worth recording since it sits in the same layer: the search API loads its driver by name.

```perl
Sources/Search/api.pm:29
require "Search/API/api_".$iB::INFO->{DB_DRIVER}.".pm";
```

`Sources/Search/API/` contains `api_DBM.pm`, `api_mySQL.pm`, `api_pgSQL.pm`, `api_Oracle.pm` and `api_global.pm`. There is **no `api_CSV.pm`.** A board installed on the CSV backend dies on the first search with a `require` failure.

---

### 8.10 The 3.1.0 -> 3.1.1 patch

The smallest of the three paths, and the most informative about what 3.1.1 was.

#### 10.1 The procedure

```
Upgrading/to_3.1.1 from 3.1.0/readme.txt:1-13

Here is how to do it:

You must transfer all the .html, .pm, .txt, .css and .cfg files in ASCII mode and all the others in BINARY mode (all the images files).

1 - Uncompress the file IB3_UPLOAD/cgi-bin/Sources.tar and upload all those files and directories to the /Sources directory of your board.

2 - Uncompress the file IB3_UPLOAD/cgi-bin/Skin.tar and upload the files TopicView.pm, Forumview.pm, PrintView.pm and all their .cfg conterpard to the Default skin directory.

3 - Uncompress the file IB3_UPLOAD/cgi-bin/Language.tar and upload the file PostWords.pm and ErrorWords.pm to the /Languages/en directory.

4 - Uncompress the file upgrading/to_3.1.1 from 3.1.0/admin_CP.zip and upload all the files to your iB_html/non-cgi/help_admin directory. You MUST create the directory to put the files in.

5 - Replace your ikonboard.cgi with the one on this install package.
```

Five steps. No database changes at all -- 3.1.0 and 3.1.1 share a schema. Three naming discrepancies against the shipped package: the directory is `Upload_Files`, not `IB3_UPLOAD`; the archive is `Languages.tar`, not `Language.tar`; and `admin_CP.zip` referenced by step 4 **does not exist in the package** -- the `to_3.1.1 from 3.1.0` directory contains only `readme.txt`.

The skin file names in step 2 are also inexact. The tree has `Skin/Default/ForumView.pm` (not `Forumview.pm`) and `Skin/Default/PrintPageView.pm` (not `PrintView.pm`).

#### 10.2 The changed-file list, grouped

The readme lists **28** source files, in loose subsystem order but unlabeled. Grouped and cross-checked against filesystem mtimes:

**Text parsing and output (2)**

| File | Path | mtime |
|------|------|-------|
| `iTextparser.pm` | `Sources/iTextparser.pm` | 07/02/2002 11:34 |
| `PrintPage.pm` | `Sources/PrintPage.pm` | 07/06/2002 20:50 |

**Core board views (4)**

| File | Path | mtime |
|------|------|-------|
| `Boards.pm` | `Sources/Boards.pm` | 07/09/2002 15:38 |
| `Forum.pm` | `Sources/Forum.pm` | 07/11/2002 20:41 |
| `Topic.pm` | `Sources/Topic.pm` | 07/11/2002 21:03 |
| `Post.pm` | `Sources/Post.pm` | 07/08/2002 19:29 |

**Identity, session, registration (5)**

| File | Path | mtime |
|------|------|-------|
| `Sessions.pm` | `Sources/Sessions.pm` | 07/13/2002 16:20 |
| `Register.pm` | `Sources/Register.pm` | 07/14/2002 17:26 |
| `Profile.pm` | `Sources/Profile.pm` | 07/09/2002 18:21 |
| `Cookies.pm` | `Sources/Misc/Cookies.pm` | 07/10/2002 19:58 |
| `FUNC.pm` | `Sources/Lib/FUNC.pm` | 07/13/2002 18:47 |

**Messaging (4)**

| File | Path | mtime |
|------|------|-------|
| `Messview.pm` | `Sources/UserCP/Messview.pm` | 07/07/2002 10:50 |
| `Messsend.pm` | `Sources/UserCP/Messsend.pm` | 07/07/2002 10:39 |
| `Massmsend.pm` | `Sources/Massmsend.pm` | 07/02/2002 18:16 |
| `Sendmail.pm` | `Sources/Mail/Sendmail.pm` | 07/09/2002 18:19 |

**Search and SSI (2)**

| File | Path | mtime |
|------|------|-------|
| `Api.pm` | `Sources/Search/api.pm` (lowercase on disk) | 07/06/2002 14:49 |
| `Parser.pm` | `Sources/SSI/Parser.pm` | 07/14/2002 19:20 |

**Admin control panel (11)**

| File | Path | mtime |
|------|------|-------|
| `Menuadmin.pm` | `Sources/Admin/Menuadmin.pm` | **11/25/2002 05:05** |
| `ModControl.pm` | `Sources/Admin/ModControl.pm` | 07/15/2002 08:47 |
| `Tools.pm` | `Sources/Admin/Tools.pm` | 07/12/2002 20:52 |
| `Options.pm` | `Sources/Admin/Options.pm` | 07/10/2002 20:24 |
| `Authorise.pm` | `Sources/Admin/Authorise.pm` | 07/10/2002 19:43 |
| `MemberControl.pm` | `Sources/Admin/MemberControl.pm` | 07/10/2002 18:36 |
| `FileManager.pm` | `Sources/Admin/Filemanager.pm` (lowercase `m` on disk) | 07/04/2002 21:07 |
| `SQLclient.pm` | `Sources/Admin/SQLclient.pm` | 07/01/2002 10:15 |
| `Convert_ib.pm` | `Sources/Admin/Convert_ib.pm` | 06/30/2002 21:42 |
| `Import.pm` | `Sources/Admin/Import.pm` | 06/30/2002 21:30 |
| `Templates.pm` | `Sources/Admin/Templates.pm` | 06/26/2002 17:31 |

Plus, from the numbered steps rather than the list: `ikonboard.cgi` (07/12/2002), `Skin/Default/{TopicView,ForumView,PrintPageView}.pm` and their `.cfg` pairs (07/11-07/12/2002), and `Languages/en/{PostWords,ErrorWords}.pm` (07/13, 07/06).

#### 10.3 The mtime cross-check

Sorting every `.pm` in `Sources/` by mtime and marking membership in the patch list gives a clean boundary:

* **24 files** in `Sources/` have an mtime of 07/02/2002 or later. **All 24 are in the patch list.** Not one late-dated file is missing from it.
* The newest `Sources/` file **not** in the list is `Sources/Online.pm` at 07/01/2002 10:32, tied with `Sources/Newest.pm` the same day.
* Four patch-list files predate that boundary: `SQLclient.pm` (07/01), `Convert_ib.pm` and `Import.pm` (both 06/30), `Templates.pm` (06/26).
* `Sources/Admin/Backup.pm` at 06/30 -- same day as its counterpart `Import.pm` -- is *not* in the list.

The consistent reading: 3.1.0 was cut around 07/01/2002, and the 3.1.1 patch is everything touched after that cut, plus four files that were finished shortly before the cut and evidently did not make it into the 3.1.0 archive. That the export half (`Backup.pm`) shipped in 3.1.0 while the import half (`Import.pm`) did not is consistent with the pair being finished within an hour of each other on 06/30 (21:30 and 21:42) and the packaging catching only one.

#### 10.4 What 3.1.1 was fixing

Reading the grouping and the dates as a narrative, 3.1.1 is a bug-fix release concentrated on three areas, worked in roughly this order across two weeks:

**06/26 - 07/02 -- admin tooling and text handling.** `Templates.pm`, `Import.pm`, `Convert_ib.pm`, `SQLclient.pm`, then `Massmsend.pm` and `iTextparser.pm` on 07/02. `iTextparser.pm` is the parser every post passes through; touching it means the IkonCode / emoticon / URL-autolink pipeline had defects. That `Convert_ib.pm` is in the list at all confirms the iB2 converter was still being fixed a fortnight before the final freeze -- 3.1.0 shipped with a converter its authors were not done with.

**07/04 - 07/10 -- the admin control panel and messaging.** `Filemanager.pm`, `api.pm`, `PrintPage.pm`, `Messsend.pm`/`Messview.pm` (both 07/07, within eleven minutes of each other -- one change spanning the send and view halves), `Post.pm`, then a dense 07/09-07/10 block: `Boards.pm`, `Profile.pm`, `Sendmail.pm`, `MemberControl.pm`, `Authorise.pm`, `Options.pm`, `Cookies.pm`. The `Authorise.pm` + `Sendmail.pm` + `Cookies.pm` + `Profile.pm` cluster reads as one investigation: registration authorization emails and the session cookie they hand back.

**07/11 - 07/15 -- the view layer and login.** `Forum.pm` and `Topic.pm` on 07/11 with their three skin modules, `ikonboard.cgi` and `Tools.pm` on 07/12, `FUNC.pm` and `Sessions.pm` and `RegisterView.pm` and `PostWords.pm` on 07/13, `Register.pm` and `Parser.pm` and `Styles.pm` on 07/14, and `ModControl.pm` at 08:47 on 07/15. `FUNC.pm` + `Sessions.pm` + `Register.pm` + `Cookies.pm` + `LogInOut`-adjacent code across five days is a login/session bug being chased down.

Development stops at `ModControl.pm`, 07/15/2002 08:47.

**And then, four months later, one file moves.** `Sources/Admin/Menuadmin.pm` carries an mtime of **11/25/2002 05:05** -- verified against the mtime recorded *inside* `Upload_Files/cgi-bin/Sources.tar`, not merely on the extracted filesystem, where a UTC-6 local rendering shows it as 11/24 23:05. The distribution tarballs `Sources.tar`, `Skin.tar` and `non-cgi.tar` are themselves dated 11/24/2002 -- the repackaging. `Menuadmin.pm` is dated *after* the tarballs it sits inside, meaning it was edited and the archive rebuilt.

It is the only file in the entire tree with a post-freeze date, and it is the only file that appears both in the 3.1.0 -> 3.1.1 patch list **and** in that repackaging. `Menuadmin.pm` is the admin control panel's menu -- pure navigation markup, no logic. Whatever the final edit was, it was to a link list.

The one substantive thing that link list says, at line 633, is `iB 2 Import into DBM` -- the label discussed in section 8.4.9, which is more precise about the converter's real limitation than the migration guide ever was. This chapter cannot establish that the 11/25 edit is what added "into DBM", since no 3.1.0 copy of the file is available to diff against. What can be established: the last edit anyone made to Ikonboard 3.1.1 was to the admin menu, four months after development otherwise stopped, in a file whose most notable content is a warning that the iB2 converter only targets one backend.

---

### 8.11 A practical 2026 migration note

If you are holding a dead Ikonboard board today -- a directory tree off an old backup, a `cgi-bin` from a decommissioned host, an archive.org snapshot -- this is what actually works.

#### 11.1 Do not run the original converter

`Convert_ib.pm` is not a data-extraction tool. It is an admin-panel screen inside a working Ikonboard 3.1.1 installation. To run it you would need, in order:

1. A Perl 5.6-era interpreter with `CGI.pm`, `DBI`, `AnyDBM_File` bound to a `DB_File`/`GDBM_File`/`NDBM_File`/`SDBM_File` that can create files the 2002 code expects, plus `Archive::Tar` and `File::Path`. Modern Perl will run most of it, but `@AnyDBM_File::ISA = qw(DB_File GDBM_File NDBM_File SDBM_File)` (`Convert_ib.pm:26`) silently selects whichever of those four is installed, and the on-disk format differs between them.
2. A web server serving it as CGI, since every stage advances by issuing a `<meta refresh>` to `ikonboard.cgi?act=convert&CODE=...&st=...`. There is no command-line entry point and no loop -- the batching *is* the HTTP redirect chain.
3. A completed 3.1.1 install: `installer.cgi` run to the end, `Data/Boardinfo.cgi` generated, a `.pwd` key file present, an admin account created, a session established. `Convert_ib.pm` reads `$INFO->{'DB_DIR'}`, `$iB::SESSION`, `$iB::INFO->{'BOARD_URL'}`, `$iB::INFO->{'CGI_EXT'}`, `$iB::INFO->{'SKIP_WORDS'}`, `$iB::INFO->{'MAX_CHARS'}` and the `SIG_ALLOW_*` settings, and it calls into `Admin::SKIN`, `FUNC::ADMIN`, `FUNC::Member`, `iTextparser` and `Boardinfo`. It cannot be lifted out standalone.
4. The DBM backend specifically, per section 8.4.9.

You would be standing up a complete 2002 web application in order to read some pipe-delimited text files. And at the end of it you would have lost the private messages, the announcements, the ban list and the permissions anyway (section 8.4.8), and inherited the `HIDE_EMAIL` inversion, the `DELTE_POST` typo and the comma-space moderator split.

#### 11.2 What to do instead

**For a 2.1.9 board.** The formats are trivial and this chapter documents them completely. Two hundred lines of Python reads the whole board:

* `data/allforums.cgi` -- one row per forum, `split('|')`, 15 fields, mapped in 3.3.
* `members/*.cgi` -- one file, one line, `split('|')`, 22 fields, mapped in 3.1. Password is field 1, in plaintext. **Treat that file as a credential dump.** People reuse passwords for twenty-five years.
* `forum<N>/list.cgi` -- one line per topic, `split('|')`, 10 fields.
* `forum<N>/<id>.thd` -- one line per post, `split('|')`, 7 fields, oldest first. Field 6 is the post body, already HTML-escaped by `ikon.lib:602-618`, with `\n` converted to `<br>` and `|` to `&#0124;`.
* `messages/<Name>_msg.cgi` and `_out.cgi` -- the private messages the official converter drops. Inspect the format directly rather than trusting any secondary description of it.
* `data/news.cgi` -- the announcements, likewise dropped by the converter.

Two decoding notes that will bite you. Member *filenames* have spaces replaced by underscores (`register.cgi:225-226`) while the name *inside* the file keeps its spaces -- join on the field, not the filename. And the escaping is single-level and 2002-vintage: `&amp;` `&lt;` `&gt;` `&quot;` `&#0124;` and literal `<br>`/`<p>`, with IkonCode tags (`[b]`, `[quote]`, `[url=...]`) left raw for the renderer. Decide deliberately whether your target wants that unescaped, and remember that whether the original board rendered a given post's HTML depended on the *forum's* `htmlstate` (field 6 of `allforums.cgi`), not on anything in the post.

**For a 3.x board on MySQL, PostgreSQL or Oracle.** You already have a relational database. Dump it and map the 29 tables directly; the column semantics are in `Database/config/*.cfg` and in section 8.3.2. Ignore the upgrade tooling entirely.

**For a 3.x board on DBM or CSV.** The records are `|^|`-delimited strings whose field order is the ordinal order declared in the matching `Database/config/*.cfg`. Read the `.cfg` from *that board's own installation*, not from a generic 3.1.1 package -- the whole lesson of section 8.9.4 is that these declarations changed between 3.0 and 3.1, and a positional decode against the wrong version shifts every field after the divergence. For DBM, iterate the `.db` files with Python's `dbm` module (trying `dbm.gnu` and `dbm.ndbm` -- you will not know which C library wrote it until one of them opens); each value is one encoded record. `MEMBER_NAME.idx` and `MEMBER_EMAIL.idx` are separate DBM files holding plain `name -> MEMBER_ID` maps and are the fastest way to enumerate members. Note `decode_record`'s two quirks (`Driver/Base.pm:228-242`): `\\n` in the stored string means a real newline, and a literal `$` was rewritten to `&#36` -- without the trailing semicolon -- on the way in.

**In both cases, extract to something durable first.** JSON or SQLite, one record per row, before you attempt any rendering or import. The 2002 formats are simple but lossy in specific ways, and you want the raw decode preserved so you can revisit a decision about escaping or timezone offsets without re-reading the original media.

#### 11.3 The thing worth remembering

The 2.x -> 3.x conversion, done properly with the shipped tool on a live 2002 server, moved members, forums, categories, moderators, topics and posts, and dropped everything else. Twenty-four years later, a modern script reading the same flat files can recover **more** than the official converter ever did -- the private messages, the announcements, the ban list, the per-member permissions, the manual titles, the per-forum graphics -- because none of it was ever encrypted, compressed or obfuscated. It is all pipe-delimited ASCII.

The obstacle to recovering an Ikonboard was never the format. It was that the only tool anyone shipped for the job had to be run from inside a running copy of the software that replaced it.

---

## 9. Archaeology

Everything in this chapter comes from evidence internal to the download: file
modification times, the structure of the six tarballs, the copyright headers,
and the comments the developers left in shipped code. No external source is
used, and where a reading is an inference rather than a fact, it is labeled as
one.

The reason this is worth doing at all is that Ikonboard 3 ships its source
inside tar archives, and a tar header carries a modification time for every
file it holds. A zip file's own timestamps are unreliable -- they are stored in
local time with no zone, and they get rewritten by half the tools that touch
them. The timestamps *inside* a tar are Unix epoch seconds, they survive
transfer intact, and nothing on the operator's side ever rewrites them. So this
distribution preserves the vendor's own build clock, file by file, from a
machine that has not existed for twenty years.

---

### 9.1 The tree was never installed

This has to be settled first, because it decides how every other date in this
chapter is read. If the tree were a copy of a board somebody ran, the mtimes
would record an operator's activity. If it is an unpacked download, they record
the vendor's.

Ikonboard 3 generates a specific set of files when it is installed and when it
first serves a request. None of them are here.

| Expected file | Written by | Why it must exist on a live board |
|---|---|---|
| `cgi-bin/Boardinfo.cgi` | installer, final step | `require`d at `ikonboard.cgi:123`; nothing runs without it |
| `cgi-bin/install.lock` | installer, on completion | without it, `ikonboard.cgi:135-140` refuses to start while `installer.cgi` exists |
| `cgi-bin/Data/*.pwd` | `ikonboard.cgi:211-226`, first request | the ARC4 key for the stored database password |
| `Database/active_sessions/*` | every request, guests included | session rows |
| `Database/member_profiles/*` | `Register.pm` | the member table |
| `Database/forum_posts/*` | `Post.pm` | the posts |
| `Data/timeout_log` | `ikonboard.cgi:578-585` | slow-process log |

All absent. `ib_provenance.py` section 0 automates the check.

The `Boardinfo.cgi` result is the decisive one on its own. Every module that
does anything requires it, directly or through `Lib/FUNC.pm`, and the installer
is what writes it. Its absence means no page was ever served from this tree, so
no operator ever touched it.

**Verdict: an unpacked distribution, never installed. Every mtime below records
the vendor.**

One near-miss is worth recording because it caught an earlier draft of the
analysis script. `Database/Temp/` contains a one-byte file called `Searches`,
which looks exactly like the residue of a live board. It is not -- it ships
inside `Database.tar`, and it is there to stop the directory being empty, since
an empty directory does not survive some FTP clients. The same trick appears
throughout the tree as `index.html` and `.htaccess` stubs. Placeholder files in
data directories are a persistent hazard for this kind of dating, because they
sit in exactly the places runtime data would.

---

### 9.2 Six tarballs, six build events

The distribution's Perl is in six archives under `Upload_Files/cgi-bin/`. Each
has its own filesystem timestamp, and each holds files with timestamps of their
own. Setting the two side by side gives the packaging sequence.

| Archive | Archive mtime | Oldest file inside | Newest file inside |
|---|---|---|---|
| `Data.tar` | 2002-06-13 02:41 | 2001-05-29 16:42 | 2002-06-03 04:40 |
| `Database.tar` | 2002-06-22 07:05 | 2002-06-21 02:34 | 2002-06-21 02:35 |
| `Languages.tar` | 2002-06-14 06:47 | 2002-06-21 02:35 | 2002-07-14 00:23 |
| `Skin.tar` | 2002-11-24 06:49 | 2002-06-23 02:52 | 2002-07-15 00:39 |
| `Sources.tar` | 2002-11-24 06:38 | 2002-06-22 01:37 | 2002-11-25 05:05 |
| `non-cgi.tar` | 2002-11-24 06:52 | 2002-06-23 02:34 | 2002-06-27 00:13 |

Two things stand out.

**The archive timestamps split into two groups.** `Data.tar`, `Database.tar`
and `Languages.tar` are dated mid-June 2002. `Skin.tar`, `Sources.tar` and
`non-cgi.tar` are dated 11/24/2002 within fourteen minutes of each other --
06:38, 06:49, 06:52. The root `readme.txt` is dated 11/24/2002 05:53. Those
four files are a single packaging session, five months after the other three
archives were built.

**Two archives contain files newer than themselves.** `Languages.tar` holds a
file 29 days newer than the archive's own timestamp; `Sources.tar` holds one
about 22 hours newer. That ordering is impossible for an archive written once
and left alone.

A caution on the second point. The archives' own timestamps arrive through a
zip file, which stores DOS timestamps in local time with no zone, so they can
be displaced by several hours relative to the epoch-based times inside the tar.
That is enough to explain a 22-hour discrepancy only if the packaging clock was
badly skewed, and it is nowhere near enough to explain 29 days. The safe
reading is that the archives' filesystem timestamps are not reliable to the
hour, and that `Languages.tar` in particular was modified -- or replaced from an
older copy -- after it was first built. The timestamps *inside* the archives
carry no such caveat and are what the rest of this chapter relies on.

---

### 9.3 Development stopped in July, and then one file moved in November

Aggregating every file in the reconstructed tree by month:

```
2001-06     8
2001-07     1
2001-08     1
2002-05     1
2002-06   218   ########################################
2002-07    71   #############
2002-11     1
```

Ikonboard 3.1.1 was built in a nine-week burst across June and July 2002. The
last week of real work is legible file by file:

```
2002-07-12 02:06:18  Skin/Default/*.cfg          (25 files, one batch)
2002-07-12 02:08:00  Skin/Default/PrintPageView.cfg + .pm
2002-07-12 02:09:58  Skin/Default/TopicView.cfg + .pm
2002-07-13 01:33:34  ikonboard.cgi
2002-07-13 01:52:58  Sources/Admin/Tools.pm
2002-07-13 21:20:54  Sources/Sessions.pm
2002-07-13 23:35:48  Skin/Default/RegisterView.cfg + .pm
2002-07-13 23:47:08  Sources/Lib/FUNC.pm
2002-07-14 00:23:03  Languages/en/PostWords.pm
2002-07-14 04:12:50  INSTALL_DATA/tiker.html
2002-07-14 04:31:28  INSTALL_DATA/news.html
2002-07-14 22:26:36  Sources/Register.pm
2002-07-15 00:20:14  Sources/SSI/Parser.pm
2002-07-15 00:39:40  Skin/Default/Styles.pm
2002-07-15 13:47:12  Sources/Admin/ModControl.pm
```

Then nothing for four months. And then, alone:

```
2002-11-25 05:05:08  Sources/Admin/Menuadmin.pm
```

**One file, 133 days after the release was finished.** It is the last thing
anyone ever changed in Ikonboard 3.1.1.

#### What that file is

`Sources/Admin/Menuadmin.pm` renders the navigation menu down the side of the
admin control panel. It is almost entirely a list of links. The section that
was edited is at lines 123-144 -- a collapsible block titled "Network Links":

```perl
if ($open_menus->{'19'}) {
    $html .= qq~
       <tr>
        <td valign='middle' align='left' width='100%'>
        <font class='item'>
        <br><span style='color:red'>&gt;</span> <a href='http://members.ikonboard.com/admin_guide/' target='BODY'>iB AdminCP Help</a>
        <br><span style='color:red'>&gt;</span> <a href="http://help.ikonboard.com/popup/faq.php" target='BODY'>ikonboard FAQ</a>
        <br><span style='color:red'>&gt;</span> <a href='http://forums.ikonboard.com' target='BODY'>iB Support Forums</a>
<br><span style='color:red'>&gt;</span> <a href='http://members.ikonboard.com' target='BODY'>iB Member Center</a>
    
<br>
<br><span style='color:red'>&gt;</span> <a href='http://hosting.jarvisgroup.net' target='_blank'>Jarvis Hosting</a>

    <br><span style='color:red'>&gt;</span> <a href='http://ibskins.ikonboard.com' target='_blank'>iBSkins</a>
    <br><span style='color:red'>&gt;</span> <a href='http://www.ibhackers.com' target='_blank'>iBHackers</a>
    <br><span style='color:red'>&gt;</span> <a href='http://www.myikonboard.com' target='_blank'>myIkonboard</a>

</font>
        </td>
       </tr>
    ~;
}
```

The indentation tells the story without any need for interpretation. The file
is uniformly tab-indented; the first three links keep that indentation, and
then it collapses. `iB Member Center` starts at column zero. `Jarvis Hosting`
starts at column zero, preceded by a stray blank `<br>` and a line of trailing
whitespace. `iBSkins`, `iBHackers` and `myIkonboard` are indented with four
spaces instead of tabs. Someone opened the file in an editor, pasted links in
by hand, and saved.

So the final change made to Ikonboard 3.1.1 was not a bug fix, a security
patch, or a feature. It was updating the cross-promotion links in the admin
panel: the company's hosting business, a skins site, a hacks site, and a
members' portal. The product had stopped being developed and was still being
merchandised.

That is an inference about intent, and it should be labeled as one -- the file's
contents and timestamp are facts, the reading of why is not. But the edit is
confined to a promotional link list, and nothing else in the tree moved.

---

### 9.4 The skin shipped out of sync with its own templates

Ikonboard 3 stores each skin twice. `Skin/Default/TopicView.cfg` is the
template an administrator edits; `Skin/Default/TopicView.pm` is the compiled
Perl the board actually loads. The admin control panel writes both. They are
supposed to agree.

In this distribution, 25 of the 31 views have a `.cfg` newer than its `.pm`:

| View | `.cfg` mtime | `.pm` mtime | Gap |
|---|---|---|---|
| `BoardsView` | 2002-07-12 02:06:18 | 2002-06-27 00:08:10 | 15 days |
| `MenuView` | 2002-07-12 02:06:18 | 2002-06-27 00:08:20 | 15 days |
| `ModCPView` | 2002-07-12 02:06:18 | 2002-06-27 00:20:44 | 15 days |
| `PostView` | 2002-07-12 02:06:18 | 2002-06-27 00:08:30 | 15 days |
| ... | ... | ... | ... |

Every one of those 25 `.cfg` files carries the identical timestamp
**2002-07-12 02:06:18**. Files are not edited 25 at a time to the second. That
is a batch operation -- a script, or a directory copy, that wrote all of them at
once.

The four views that do *not* show the gap are the interesting ones, because
they date the boundary:

```
2002-07-12 01:43:26  ForumView.pm          (edited 23 min BEFORE the batch)
2002-07-12 02:06:18  *.cfg                 (the batch)
2002-07-12 02:08:00  PrintPageView.cfg + .pm  (both, together)
2002-07-12 02:09:58  TopicView.cfg + .pm      (both, together)
2002-07-13 23:35:48  RegisterView.cfg + .pm   (both, together)
```

Views touched *after* the batch have matching pairs, which is exactly what the
admin control panel produces when it saves a template -- it writes the `.cfg`
and recompiles the `.pm` in the same operation. Views touched only by the batch
have a `.cfg` from 07/12 and a `.pm` from 06/27.

A timestamp gap is not by itself a content difference, and the distinction
matters a great deal. If the templates and the compiled views disagreed, the
board would have been serving different HTML than the admin control panel
showed an operator, and re-saving any template -- even without changing it --
would silently have altered the skin. That is a real class of support incident,
and it is exactly the kind of thing nobody could have checked in 2002 without a
running board.

It can be checked now, because the `.cfg` format is structured rather than
free-form. `[=SUB-name]` opens a sub, `#=TOP_LINE` holds its argument
unpacking, and `#=BODY` holds the markup that becomes the returned string:

```
[=SUB-Render_row_form]
#=DESC

#=TOP_LINE
    my ($votes, $id, $answer) = @_;

#=BODY
    <tr>
    <td bgcolor='$iB::SKIN->{'MISCBACK_ONE'}' colspan='3'>...
    </tr>
```

So each template sub can be compared against the corresponding `qq~...~` body
in the compiled `.pm`, whitespace-insensitively, sub by sub. The `ib_skin.py`
report does this across all 31 views.

**Result: all 28 comparable views are identical in content. None differ.** The
timestamp gap is cosmetic. Whatever wrote the 25 `.cfg` files at 02:06:18 wrote
the same markup that was already compiled into the `.pm` files, so the shipped
skin and the shipped templates agree and an operator re-saving a template would
have got back what they already had.

Three views are excluded from the comparison because they are not view
templates: `Menu.cfg` and `gfx_data.cfg` have no compiled counterpart, and
`Styles.pm` has no template. They are the menu definition, the graphics
manifest, and the compiled color table, sharing the extensions for different
purposes.

The negative result took three passes to reach, and the false starts are worth
recording because anyone repeating this will hit them. A first attempt found
five subs apparently missing from the compiled views; all five existed, and the
extraction had been stopping at a `}` that closes a JavaScript function at
column zero inside a Perl string. A second attempt found three subs whose
content appeared to differ; in all three cases the compiled sub held an extra
`<option>` list that the template keeps in `#=TOP_LINE` rather than `#=BODY`.
Both were artifacts of the measuring instrument. A diff tool that has not been
made to survive generated code will manufacture findings.

Three files are unpaired outright: `Menu.cfg` and `gfx_data.cfg` have no `.pm`,
and `Styles.pm` has no `.cfg`. Those are not views; they are the menu
definition, the graphics manifest, and the compiled color table, and they use
the same extensions for different purposes.

---

### 9.5 Copyright stratigraphy

`ib_provenance.py` groups every Perl file by the exact text of its copyright
line. Ten distinct variants survive in one release:

| Files | Header |
|---|---|
| 37 | `# (c)2001 Jarvis Entertainment Group, Inc.` |
| 37 | `# (c)2001-2002 Jarvis Entertainment Group, Inc.` |
| 10 | `#\| (c)2001 Jarvis Entertainment Group, Inc.` |
| 2 | `# (c)2002 Jarvis Entertainment Group, Inc.` |
| 2 | `# Copyright (c) 1995-2001 Paul Marquess. All rights reserved.` |
| 1 | `#\| (c)2002 Jarvis Entertainment Group, Inc.` |
| 1 | `#\| (c)2001 Jarvis Entertainment\tGroup, Inc.` |
| 1 | `# (c)2001-2002 Jarvis Entertainment\tGroup, Inc.` |
| 1 | **`#\| (c)2001 Ikonboard.com <http://www.ikonboard.com>`** |
| 1 | **`#\| (c)2001-2002\tIkonboard.com <http://www.ikonboard.com>`** |

The last two are the find. `Sources/Sessions.pm` and `Sources/UserCP/Menu.pm`
still carry **Ikonboard.com** as the copyright holder, where every other file
in the product says Jarvis Entertainment Group, Inc.

Ikonboard changed hands between version 2 and version 3, and a bulk edit across
the source moved the ownership line. These two escaped it. The obvious reading
-- that they are old files nobody touched -- is wrong, and the timestamps say so:

```
Sources/Sessions.pm      mtime 2002-07-13 21:20:54
   #| Ikonboard [ v3.0 ]
   #| (c)2001 Ikonboard.com <http://www.ikonboard.com>

Sources/UserCP/Menu.pm   mtime 2002-06-24 19:10:18
   #| Ikonboard [ v3.1 ]
   #| (c)2001-2002 Ikonboard.com <http://www.ikonboard.com>
```

`Sessions.pm` is the session and authentication layer, and it was edited two
days before development stopped -- active code, carrying the previous owner's
copyright. `UserCP/Menu.pm` is more pointed still: its version string was
advanced from `v3.0` to `v3.1` and its year from `2001` to `2001-2002`, while
the entity name beside them was left alone. Somebody's edit reached both other
fields on that line and not the one that mattered.

So these are not fossils of untouched files. They are fossils of an incomplete
substitution -- a rule that matched most headers and not these, applied by a
process that nobody checked afterward. That reading is corroborated below.

#### Matt Mecham's byline is still in the product

Version 2 of Ikonboard was Matt Mecham's. Version 3 belongs to a company. But
the attribution never came out of twelve files:

```
installer.cgi:6                       #| Ikonboard by Matthew Mecham [ v3.0 ]
Sources/iDatabase/SQL.pm:7            # Author: Matthew Mecham <matt@ikonboard.com>
Sources/iDatabase/Driver/Base.pm:7    # Author: Matthew Mecham <matt@ikonboard.com>
Sources/iDatabase/Driver/CSV.pm:9     # Author: Matthew Mecham <matt@ikonboard.com>
Sources/iDatabase/Driver/DBM.pm:7     # Driver Author: Matthew Mecham <matt@ikonboard.com>
Sources/iDatabase/Driver/mySQL.pm:7   # Driver Author: Matthew Mecham <matt@ikonboard.com>
Sources/iDatabase/Admin/a_base.pm:7   # Author: Matthew Mecham <matt@ikonboard.com>
Sources/iDatabase/Admin/a_DBM.pm:7    # Author: Matthew Mecham <matt@ikonboard.com>
Sources/iDatabase/Admin/a_mySQL.pm:7  # Author: Matthew Mecham <matt@ikonboard.com>
Sources/iDatabase/Admin/a_Oracle.pm:7 # Author: Matthew Mecham <matt@ikonboard.com>
Sources/iDatabase/Admin/a_pgSQL.pm:7  # Author: Matthew Mecham <matt@ikonboard.com>
Sources/iPerl/mod_perl.pm:3           # by: Matthew Mecham
```

They are not scattered. With the single exception of the installer and the
mod_perl glue, every one of them is in `iDatabase/` -- and the database
abstraction layer is the thing that makes Ikonboard 3 what it is. The feature
the release was built around was written by the previous owner, over an
`@ikonboard.com` address, and shipped a year later under a company byline with
his name still on each file.

`installer.cgi:6` puts the two generations one line apart:

```perl
#| Ikonboard by Matthew Mecham [ v3.0 ]
#|
#| No parts of this script can be used outside Ikonboard
#| without prior consent.
...
#| (c)2001 Jarvis Entertainment Group, Inc.
```

The version strings on those files are their own marker. `iDatabase/Driver/CSV.pm`
is stamped `iDatabase v1.0 (May 2001)` and every driver still reports
`$VERSION = 1.0` -- fourteen months later, in a 3.1.1 release.

Two more variants (`Help.pm`, `Admin/LangControl.pm`, and `UserCP/Menu.pm`
again) have a literal tab character inside the company name -- `Jarvis
Entertainment<TAB>Group` -- which is what a careless search and replace produces
when the replacement string is pasted from a tab-separated source. That is
consistent with the same bulk edit.

#### The replace that landed inside a word

Every module header carries the same contact line. Across the tree it appears
75 times, and 74 of them read:

```
# More information available from <ib-license@jarvisgroup.net>
```

`Sources/Admin/Index.pm:8` reads:

```
# Mor2001-e information available from <ib-license@jarvisgroup.net>
```

A copyright-year edit was applied mechanically, matched inside the word
**More**, and inserted `2001-` between the `r` and the `e`. The line below it
in the same file is `# (c)2002 Jarvis Entertainment Group, Inc.` -- one of only
two files in the tree carrying a bare `2002`, which is consistent with this
being the file where the year edit went wrong and was then partly redone.

This is the single most direct piece of evidence for the bulk search-and-replace
inferred above. It also shows how much attention the headers got afterward:
`Admin/Index.pm` is the admin control panel's entry module, and the corruption
sat in its header through the 3.1.1 release and the November repackaging
without anyone noticing.

The same header block gives one more marker. The closing line appears as
`Please read the license for more information` in 76 files and `Please Read the
licence for more information` -- British spelling, capitalized *Read* -- in 4,
including `ikonboard.cgi` itself. The British form is the older one; Ikonboard
was written in Hampshire. The four that keep it are files whose headers escaped
whatever pass normalized the rest.

86 Perl files carry no copyright header at all. They are the generated ones:
every `Languages/en/*Words.pm`, every `Skin/Default/*View.pm`, and the small
`Data/*.pm` tables. Generated files not getting the header is expected; it also
means the compiled skin the board actually executes is, on its face,
unattributed.

The bundled CPAN modules keep their real authors' notices -- Paul Marquess for
`Compress::Zlib` -- which is correct and worth noting given how much else in the
tree was rewritten.

#### One header predates version 2.1.9 itself

The oldest file in the reconstructed tree is `Data/index.html`, dated
**2001-06-02 16:19:32**. It is one of the 403 blocker stubs, and it reads:

```html
Ikonboard &copy 2001 Jarvis Entertainment Group, Inc.
```

Ikonboard 2.1.9 was released on 06/07/2001, under Ikonboard.com. This stub
therefore carries Jarvis Entertainment Group branding five days *before* the
last major 2.x release shipped under the previous name. Whatever the corporate
arrangement was, it was in place and being written into files by the start of
June 2001, and version 3 was already being built under it.

(The `&copy` there is missing its semicolon, so it renders literally as
`&copy` in most browsers. It is in every one of these stub pages.)

---

### 9.6 The developers left their names in the source

Ikonboard 3.1.1 is a commercial product, and its dispatcher contains this:

```perl
              # Added by KEVaholic00: member notepads
              NotePad   => ['NotePad'            , 'Process'     ],
              # Added by Camil: Newest post
              NW        => ['Newest'             , 'shownewest'  ],
```

That is `ikonboard.cgi:462-465`. Fifty lines earlier, in the same file:

```perl
# ( ADDED HERE BY KEVaholic00 FOR BUG FIX #168, COMMENTED OUT BELOW )
# Lets add on the skin name for ease of use.
my $images_url                   = $iB::INFO->{'IMAGES_URL'};

$iB::INFO->{'IMAGES_URL'}       .= '/' . $iB::SKIN->{'FULL_DIR'};
# ( END ADDITION )
```

and further down, the corresponding removal:

```perl
# ( COMMENTED OUT BY KEVaholic00 FOR BUG FIX #168 AND PASTED ABOVE )
## Now we've read in all the data that needs the 'raw' IMAGES_URL
## Lets add on the skin name for ease of use.
#$iB::INFO->{'IMAGES_URL'}       .= '/' . $iB::SKIN->{'FULL_DIR'};
# ( END COMMENT )
```

Handles, not names. A numbered bug reference. Both the moved code and the
commented-out original, left in place. This is what a codebase looks like when
patches arrive from a community through a public bug tracker and are applied by
hand into the shipping source, without a version control system to hold the
history -- so the history goes into the comments instead.

The two contributions are whole features: `NotePad` (member notepads) and
`Newest` (the new-posts listing) are both first-stage actions in the dispatch
table, reachable as `act=NotePad` and `act=NW`. A volunteer's module shipped as
part of the paid product, credited in a comment.

Bug #168 also gives a floor for how large the bug tracker had grown. Nothing in
the distribution says where it lived; `forums.ikonboard.com` is the support
board named in `Menuadmin.pm` and in the iB2-to-iB3 migration guide.

---

### 9.7 A fingerprint test that did not work

An obvious next step is to ask whether formatting can separate individual
developers' work -- if `Menuadmin.pm` was hand-edited in November, whose hand
was it?

It cannot, and the negative result is worth recording so nobody repeats it.

Testing indentation across all non-generated Perl in the tree: the codebase is
overwhelmingly tab-indented, and near-uniformly so. `Sources/Admin/ForumControl.pm`
is 1195 tab-indented lines and 0 space-indented. `MemberGroups.pm` is 1253 and
0. `SkinControl.pm` is 1027 and 0. Of roughly 55 substantial modules, almost
all are tab-dominant, and the handful that are not (`Happybd.pm` at 102/93,
`NotePad.pm` at 94/47, `Posters.pm` at 33/26) are among the smallest.

The narrower idiom of putting a tab immediately after a keyword --
`package\tAdmin::Menuadmin;`, `sub\tnew`, `use\tstrict;` -- is also widespread
rather than distinctive: `Lib/FUNC.pm` has 63 occurrences, `Post2.pm` 39,
`Admin/Functions.pm` 38, and it appears in more than thirty files.

So formatting here reflects a house style, a shared editor configuration, or a
reformatting pass over the whole tree -- not individual authorship. The two
files with the most divergent internal formatting are `Happybd.pm` and
`NotePad.pm`, and `NotePad.pm` is one of the two modules explicitly credited to
an outside contributor, which is mildly suggestive. Two data points is not a
result, and it is not offered as one.

The identifiable-hand evidence in this tree is the comments, not the whitespace.
The one exception is the November edit in `Menuadmin.pm`, where the formatting
break is confined to the twenty lines that changed and is unambiguous.

---

### 9.8 Loose ends in the shipped configuration

Two defaults in `Upload_Files/cgi-bin/ikonboard.conf`, the settings template
the installer turns into `Boardinfo.cgi`:

```
COPYRIGHT_INFO        = 2001 Ikonboard.com
```

Every operator who installed 3.1.1 and did not edit this field got a board
displaying a copyright notice for the *previous* owner, a year out of date. It
is the same missed search-and-replace as `Sessions.pm` and `UserCP/Menu.pm`,
surviving in the one place where it was visible to the public rather than only
to somebody reading the source.

```
OFFLINE_MESSAGE       = We are currently upgrading to Ikonboard 3, please check back later
```

The default text shown to visitors when an administrator takes the board
offline is a message about upgrading *to* Ikonboard 3 -- written for operators
migrating from 2.x, and left as the shipped default in a release where the
board already is Ikonboard 3. Harmless, and a clear sign the configuration
template was carried forward from the 3.0 beta line without review.

---

### 9.9 What the evidence supports

Stated separately by confidence.

**Facts, from the tree itself:**

- The distribution was never installed. No generated file exists.
- Ikonboard 3.1.1's development ran June through mid-July 2002, concentrated in
  nine weeks: 218 files last modified in June 2002, 71 in July.
- The last substantive change was `Sources/Admin/ModControl.pm` on 07/15/2002.
- Four months later, on 11/24/2002, `Sources.tar`, `Skin.tar` and `non-cgi.tar`
  were rebuilt within fourteen minutes of each other, and the root `readme.txt`
  was written the same morning.
- `Sources/Admin/Menuadmin.pm`, dated 11/25/2002, is the only file inside any
  archive that postdates July 2002. The change is confined to a promotional
  link block, and the indentation shows it was typed in by hand.
- Two modules still carry `(c) Ikonboard.com` where the rest of the product
  says Jarvis Entertainment Group, Inc. Neither is an untouched old file: one
  was edited two days before release, and the other had its version and year
  advanced on the same header whose entity name was left behind.
- Matt Mecham's byline survives in twelve files, eleven of them the
  `iDatabase/` layer, over an `@ikonboard.com` address.
- A copyright-year search and replace corrupted the word "More" into
  `Mor2001-e` in the admin control panel's entry module, and shipped that way.
- A file stub dated 06/02/2001 already carries Jarvis Entertainment Group
  branding -- five days before Ikonboard 2.1.9 shipped under the old name.
- Two features in the shipped product are credited in comments to community
  handles, `KEVaholic00` and `Camil`, with a bug-tracker reference as high as
  #168.
- 25 of 31 skin templates carry an identical batch timestamp newer than their
  compiled counterparts -- and yet all 28 comparable views are identical in
  content, so the gap is cosmetic and the shipped skin matches its templates.

**Reasonable inferences, labeled as such:**

- The November session was a repackaging of the distribution for redistribution,
  not a development event: three of six archives rebuilt, one promotional file
  changed, no code touched.
- The `Ikonboard.com` survivals and the tab-corrupted company names are residue
  of a single bulk search-and-replace across the source at the point of the
  ownership change.
- The `.cfg` files were regenerated or recopied as a batch at 02:06:18 on
  07/12/2002 without the `.pm` files being rewritten. Since the content matches,
  this was a packaging step rather than an edit; which direction it ran in
  cannot be settled from the files alone, and does not matter, because the two
  representations agree.

**Not determinable from this distribution:**

- Who made the November edit.
- Where the bug tracker was hosted, or what bug #168 was.
- Why `Languages.tar` holds a file 29 days newer than the archive's own
  timestamp.

---

## 10. How to reproduce this

Nothing here depends on private information. Everything came from the
distribution zip, a Python interpreter, and reading the source. This section is
the method, in the order it was actually carried out, so that the findings can
be checked and so the same approach can be pointed at another dead application.

### 10.1 Reconstruct the tree the operator actually had

This is the step that is specific to Ikonboard 3 and the easiest to skip.

The zip does not contain the software in a readable form. `Upload_Files/cgi-bin/`
holds six `.tar` archives, and the install guide tells the operator to unpack
them *into that same directory* before uploading. So the tree anybody ever ran
is the zip with those archives exploded in place, and any analysis run against
the zip as downloaded sees six opaque blobs and about thirty loose files.

`ib_unpack.py` builds it: copy `Upload_Files/` to `board/`, unpack each archive
into `board/cgi-bin/`, and move `non-cgi/` out to the web root the way the
install guide does. Two details matter.

**Preserve the tar timestamps.** They are the primary evidence for the
archaeology chapter -- the vendor's own build clock, in epoch seconds, which
survives transfer where a zip's timestamps do not.

**Extract member by member rather than with `extractall`.** The zip ships stub
`index.html` files in the destination directories, and `Database.tar` declares
some of those same paths as *directory* members, because the flat-file drivers
keep one directory per table and each carries its own blocker. `extractall`
hits the collision and aborts the whole archive.

`board/` is derived data. It is rebuilt from scratch on each run and nothing is
ever edited there.

### 10.2 Read the entry point before anything else

`ikonboard.cgi` is 588 lines and it answers most of the structural questions on
its own: how configuration is loaded, how input is filtered, how the database
connection is made, how skins and sessions are attached, and how requests are
routed. Every later question -- what an endpoint can reach, what an attacker
controls, what a module can assume -- resolves back to it.

For a one-entry-point application, this is cheap and it is the difference
between describing the software and guessing at it.

### 10.3 Write the analysis as scripts, not as notes

Every quantitative claim in this document is produced by one of the nine
scripts in section 11, each of which takes the tree root as an argument, uses
only the Python standard library, and prints a plain-text report. The reports
are checked in beside the scripts.

The reason is not tidiness. It is that a claim like "25 of 31 skin templates
are newer than their compiled form" is only worth making if it can be
re-derived, and a hand-built list drifts the moment anything changes. Scripts
also make negative results cheap, and several of the most useful findings here
are negative.

### 10.4 Ask questions the timestamps can answer

The productive questions in this teardown all had the same shape: *what
ordering of events would produce the file times we can see?*

- Was the tree ever installed? Check for the files installation generates. All
  absent, so every timestamp records the vendor rather than an operator, and
  everything downstream is evidence about how the release was made.
- When was it built? Aggregate mtimes by month. Nine weeks in mid-2002.
- Was it built all at once? Compare each archive's own timestamp against the
  newest file inside it. Three archives were rebuilt five months later.
- What changed in that second session? Exactly one file, and its indentation
  shows the change was typed by hand.

### 10.5 Test the rewrite claim by content, not by name

"Version 3 is a rewrite" is the kind of statement that gets repeated without
ever being checked. It is checkable: normalize every source line, drop comments
and anything under 24 characters, and intersect the two corpora.

Seventeen lines out of 3,499. Every one of them boilerplate -- a JavaScript
clock, `print "Content-type: text/html\n\n";`, an array of month names. That is
a stronger and more interesting statement than any amount of prose about
architectural differences, and it took about forty lines of Python.

### 10.6 Distrust the measuring instrument

Three findings in this teardown were wrong on the first pass, and all three
were artifacts of the tooling rather than facts about Ikonboard.

The install detector reported the tree as a deployed board because
`Database/Temp/` contained a file -- a one-byte placeholder that ships in the
tarball. The skin comparator reported five subs missing from the compiled views
because it looked for the end of a Perl sub by matching a `}` at column zero,
and these templates emit JavaScript whose closing braces sit at column zero
inside the Perl string. Having fixed that, it reported three subs whose content
differed, because a compiled sub can hold several quoted blocks and only one of
them corresponds to the template's `#=BODY`.

Every one of those would have made it into the document as a finding. The
pattern is worth stating generally: when a tool reports something surprising
about generated code, the tool is the first suspect. Check the specific claim by
hand -- open the file, look at the subroutine -- before writing it down.

The same discipline applies to identifiers that come from outside the tree, and
there the failure is easier to miss because the technical work is right. The
remote code execution described in the security chapter was correctly located
in the source -- the right file, the right line, the right mechanism -- and then
attached to the wrong CVE, one that turned out to describe an unrelated
cross-site scripting bug in an earlier version. Nothing internal to the
analysis could have caught that. The tell was arithmetic: a 2002-numbered CVE
is normally assigned early in 2002, and this software was released in July,
which is reason enough to go and check. It also corrected the history, since
the advisory that does match carries names and dates the tree does not have.
Verify external identifiers against the external record, separately from
verifying the finding they are attached to.

### 10.7 State confidence explicitly

The archaeology chapter closes by sorting its conclusions into facts,
inferences and open questions, and the security chapter reports what it
disproved alongside what it found. That structure is not decoration. For
software this old there is no one left to ask, the vendor is gone, and a
confident sentence in a document like this one tends to get cited as though it
were established. Marking the difference is the only honesty available.

---

## 11. The toolchain

Eleven scripts, standard library only, each taking a tree root as its argument.
They are embedded here in full so the document cannot drift from the code that
produced its numbers.

| Script | What it answers |
|---|---|
| `ib_unpack.py` | Rebuilds the operator's tree from the six tarballs |
| `ib_manifest.py` | What is in it, by subsystem, with Perl line counts |
| `ib_provenance.py` | Was it installed, when was it built, who owned it |
| `ib_actions.py` | Every reachable endpoint, all three dispatch stages |
| `ib_records.py` | The 29-table schema, and whether the backends agree |
| `ib_subs.py` | Package and sub census; the mod_perl state hazard |
| `ib_fileio.py` | Every filesystem and database call site; locking |
| `ib_taint.py` | Where request data reaches a dangerous sink |
| `ib_skin.py` | Template versus compiled view, by content |
| `ib_delta.py` | Ikonboard 2.1.9 against 3.1.1, measured |
| `ib_verify.py` | Whether this document's own citations resolve |

The last one is not about Ikonboard. A teardown that cites `Sources/Post.pm:855`
several hundred times is making several hundred checkable claims, and a wrong
line number reads exactly like a right one. `ib_verify.py` resolves every
citation against the tree, confirms every quoted Perl block actually appears in
the source, and exits non-zero if anything fails, so the document cannot be
rebuilt with a broken reference in it.

Run them against the reconstructed tree:

```
python3 ib_unpack.py
python3 ib_manifest.py   > out_manifest.txt
python3 ib_provenance.py > out_provenance.txt
python3 ib_actions.py    > out_actions.txt
python3 ib_records.py    > out_records.txt
python3 ib_subs.py       > out_subs.txt
python3 ib_fileio.py     > out_fileio.txt
python3 ib_taint.py      > out_taint.txt
python3 ib_skin.py       > out_skin.txt
python3 ib_delta.py      > out_delta.txt      # needs an ib219 tree too
```

Then check the document's citations and rebuild it:

```
python3 ib_verify.py                          # non-zero exit if any fail
python3 build_readme.py IKONBOARD-3.1.1-teardown.md --txt
```

### 11.1 ib_unpack.py

```python
"""Reconstruct the board tree an operator would have had after uploading.

The Ikonboard 3.1.1 distribution does not ship its source in the open. The
Perl lives inside six tarballs under `Upload_Files/cgi-bin/`, which the install
guide tells you to untar *into that same directory* before uploading. So the
tree everybody actually ran is not the tree in the zip -- it is the zip with
those six archives exploded in place.

Every other script here analyzes that reconstructed tree, because that is what
the software is. This one builds it:

    Upload_Files/            ->  teardown/board/
      cgi-bin/
        Sources.tar          ->  cgi-bin/Sources/...
        Skin.tar             ->  cgi-bin/Skin/...
        Database.tar         ->  cgi-bin/Database/...
        Languages.tar        ->  cgi-bin/Languages/...
        Data.tar             ->  cgi-bin/Data/...
        non-cgi.tar          ->  cgi-bin/non-cgi/...   (moved to iB_html/ on upload)

Modification times are preserved from the tar headers, since those timestamps
are the primary evidence in the archaeology chapter -- they are the vendor's
own build clock and the only dating we have that the zip's own timestamps
cannot supply.

`board/` is derived data and is rebuilt from scratch on every run. Nothing
should ever be edited there.

Usage: python3 ib_unpack.py [dist_root] [--keep-tars]
"""
import os
import shutil
import sys
import tarfile

HERE = os.path.dirname(os.path.abspath(__file__))
DIST = os.path.dirname(HERE)
BOARD = os.path.join(HERE, 'board')


def main():
    args = [a for a in sys.argv[1:] if not a.startswith('--')]
    dist = args[0] if args else DIST
    src = os.path.join(dist, 'Upload_Files')
    if not os.path.isdir(src):
        sys.exit('no Upload_Files/ under %s' % dist)

    if os.path.isdir(BOARD):
        shutil.rmtree(BOARD)
    shutil.copytree(src, BOARD)

    cgi = os.path.join(BOARD, 'cgi-bin')
    tars = sorted(f for f in os.listdir(cgi) if f.endswith('.tar'))
    total = 0
    for name in tars:
        path = os.path.join(cgi, name)
        with tarfile.open(path) as tf:
            members = tf.getmembers()
            # The tar's own top-level directory is the destination directory
            # name, so it unpacks straight into cgi-bin/ with no stripping.
            #
            # Not extractall(): the zip already ships stub `index.html` files
            # in the destination directories, and Database.tar declares some of
            # those same paths as *directory* members -- the CSV/DBM drivers
            # keep one directory per table, each with its own index.html
            # blocker, and the packaging flattened the distinction. Extracting
            # member by member lets the file win over the empty directory
            # instead of aborting the whole archive.
            for m in members:
                dest = os.path.join(cgi, m.name.replace('/', os.sep))
                if m.isdir():
                    if not os.path.exists(dest):
                        os.makedirs(dest)
                    continue
                if not m.isfile():
                    continue
                os.makedirs(os.path.dirname(dest), exist_ok=True)
                if os.path.isdir(dest):
                    os.rmdir(dest)
                with tf.extractfile(m) as fh:
                    open(dest, 'wb').write(fh.read())
                os.utime(dest, (m.mtime, m.mtime))
        files = [m for m in members if m.isfile()]
        total += len(files)
        print('%-16s %4d files, %4d dirs -> cgi-bin/%s/'
              % (name, len(files), len(members) - len(files), name[:-4]))
        if '--keep-tars' not in sys.argv:
            os.remove(path)

    # The install guide has non-cgi/ moved out of cgi-bin and into the web
    # root. Mirror that, so paths in the reconstructed tree match the paths
    # the running board actually used.
    stray = os.path.join(cgi, 'non-cgi')
    if os.path.isdir(stray):
        dest = os.path.join(BOARD, 'iB_html', 'non-cgi')
        shutil.move(stray, dest)
        print('moved cgi-bin/non-cgi/ -> iB_html/non-cgi/ (per install guide)')

    count = sum(len(f) for _, _, f in os.walk(BOARD))
    print('\nboard/ rebuilt: %d files from %d tarballs + %d loose'
          % (count, len(tars), count - total))


if __name__ == '__main__':
    main()
```

### 11.2 ib_manifest.py

```python
"""Inventory the reconstructed board tree.

Ikonboard 3 is not a directory of CGI scripts the way 2.x was -- it is one
dispatcher plus a library, and the library is stratified: front-end controllers,
an admin control panel, a database abstraction layer with five drivers, a
compiled skin, and translated word lists. Counting files alone hides that, so
this classifies every file into the subsystem it belongs to and reports the
Perl line counts per stratum.

Sections:

  1. Per-subsystem totals -- where the code actually is.
  2. The full file table, sorted by size, with line counts for text.
  3. Largest modules, since in this codebase size tracks responsibility very
     closely and the top of that list is the tour of what the software does.
  4. Packaging: what shipped as a tarball versus loose, and the mtime spread
     inside each, which is what dates the build.

Usage: python3 ib_manifest.py [board_root]
"""
import os
import sys
from datetime import datetime, timezone

HERE = os.path.dirname(os.path.abspath(__file__))
DEFAULT = os.path.join(HERE, 'board')

TEXT_EXT = {'.pm', '.pl', '.cgi', '.cfg', '.conf', '.dat', '.txt', '.html',
            '.css', '.js', '.htaccess', ''}

# Longest prefix wins, so the specific paths are listed before their parents.
STRATA = [
    ('cgi-bin/Sources/Admin/',       'Admin control panel'),
    ('cgi-bin/Sources/iDatabase/',   'Database abstraction layer'),
    ('cgi-bin/Sources/Search/',      'Search subsystem'),
    ('cgi-bin/Sources/UserCP/',      'User control panel + messenger'),
    ('cgi-bin/Sources/Misc/',        'Small feature modules'),
    ('cgi-bin/Sources/Lib/',         'Core library'),
    ('cgi-bin/Sources/Mail/',        'Mail'),
    ('cgi-bin/Sources/SSI/',         'Server-side includes'),
    ('cgi-bin/Sources/iPerl/',       'mod_perl glue'),
    ('cgi-bin/Sources/Archive/',     'Bundled CPAN (Archive::Tar)'),
    ('cgi-bin/Sources/Compress/',    'Bundled CPAN (Compress::Zlib)'),
    ('cgi-bin/Sources/MIME/',        'Bundled CPAN (MIME::*)'),
    ('cgi-bin/Sources/',             'Front-end controllers'),
    ('cgi-bin/Skin/',                'Skin (compiled views + templates)'),
    ('cgi-bin/Languages/',           'Language packs'),
    ('cgi-bin/Database/',            'Database (table defs + data dirs)'),
    ('cgi-bin/Data/',                'Runtime data'),
    ('cgi-bin/install_modules/',     'Installer'),
    ('cgi-bin/INSTALL_DATA/',        'Installer seed data'),
    ('iB_html/',                     'Web root (images, skins, uploads)'),
    ('cgi-bin/',                     'Board root'),
]


def stratum(rel):
    for prefix, label in STRATA:
        if rel.startswith(prefix):
            return label
    return 'Other'


def is_text(path):
    ext = os.path.splitext(path)[1].lower()
    if ext not in TEXT_EXT:
        return False
    try:
        with open(path, 'rb') as fh:
            return b'\0' not in fh.read(8192)
    except OSError:
        return False


def main():
    root = sys.argv[1] if len(sys.argv) > 1 else DEFAULT
    rows = []
    for dirpath, dirnames, filenames in os.walk(root):
        dirnames.sort()
        for name in sorted(filenames):
            full = os.path.join(dirpath, name)
            rel = os.path.relpath(full, root).replace('\\', '/')
            st = os.stat(full)
            lines = None
            if is_text(full):
                lines = sum(1 for _ in open(full, encoding='latin-1'))
            rows.append({
                'rel': rel, 'size': st.st_size, 'lines': lines,
                'mtime': st.st_mtime, 'stratum': stratum(rel),
                'ext': os.path.splitext(name)[1].lower(),
            })

    def when(ts):
        return datetime.fromtimestamp(ts, timezone.utc).strftime('%Y-%m-%d')

    print('=' * 78)
    print('1. SUBSYSTEMS -- where the code is')
    print('=' * 78)
    print('%-38s %6s %6s %10s  %s' % ('subsystem', 'files', 'perl', 'bytes',
                                      'perl lines'))
    print('-' * 78)
    order = [label for _, label in STRATA]
    seen, tot_f, tot_b, tot_l = {}, 0, 0, 0
    for r in rows:
        s = seen.setdefault(r['stratum'], {'f': 0, 'b': 0, 'l': 0, 'p': 0})
        s['f'] += 1
        s['b'] += r['size']
        if r['ext'] in ('.pm', '.pl', '.cgi'):
            s['p'] += 1
            s['l'] += r['lines'] or 0
    for label in order + sorted(set(seen) - set(order)):
        if label not in seen:
            continue
        s = seen.pop(label)
        tot_f += s['f']
        tot_b += s['b']
        tot_l += s['l']
        print('%-38s %6d %6d %10d  %d' % (label, s['f'], s['p'], s['b'], s['l']))
    print('-' * 78)
    print('%-38s %6d %6s %10d  %d' % ('TOTAL', tot_f, '', tot_b, tot_l))

    print()
    print('=' * 78)
    print('2. LARGEST MODULES -- the shape of the responsibility split')
    print('=' * 78)
    perl = [r for r in rows if r['ext'] in ('.pm', '.pl', '.cgi')]
    perl.sort(key=lambda r: -(r['lines'] or 0))
    print('%6s %8s  %-46s %s' % ('lines', 'bytes', 'file', 'date'))
    print('-' * 78)
    for r in perl[:40]:
        print('%6d %8d  %-46s %s'
              % (r['lines'], r['size'], r['rel'], when(r['mtime'])))
    print('\n%d Perl files, %d lines total' % (len(perl), tot_l))

    print()
    print('=' * 78)
    print('3. BY EXTENSION')
    print('=' * 78)
    ext = {}
    for r in rows:
        e = ext.setdefault(r['ext'] or '(none)', {'n': 0, 'b': 0, 'l': 0})
        e['n'] += 1
        e['b'] += r['size']
        e['l'] += r['lines'] or 0
    for name, e in sorted(ext.items(), key=lambda kv: -kv[1]['n']):
        print('  %-10s %4d files %10d bytes %8d lines'
              % (name, e['n'], e['b'], e['l']))

    print()
    print('=' * 78)
    print('4. FULL MANIFEST')
    print('=' * 78)
    print('%10s %6s  %-10s  %s' % ('bytes', 'lines', 'mtime', 'path'))
    print('-' * 78)
    for r in sorted(rows, key=lambda r: r['rel']):
        print('%10d %6s  %s  %s'
              % (r['size'], r['lines'] if r['lines'] is not None else '-',
                 when(r['mtime']), r['rel']))


if __name__ == '__main__':
    main()
```

### 11.3 ib_provenance.py

```python
"""Provenance and archaeology on the Ikonboard 3.1.1 distribution.

Ikonboard 3 ships its source inside six tarballs, and a tar header carries the
modification time of every file it holds. That makes this distribution far more
informative than a plain zip: it preserves the vendor's own build clock, file by
file, from a machine nobody has access to any more. The questions below are all
answerable from those timestamps.

  0. Was this tree ever installed? Ikonboard 3 writes a specific set of files
     the first time it is set up. If none exist, the timestamps record the
     vendor's activity, not an operator's -- which is what makes every date
     below evidence about how the software was actually maintained.

  1. Tar build order. Each archive's own filesystem mtime versus the newest
     file inside it. A file newer than the archive containing it is impossible
     under normal packaging, so any such case is a fact that needs explaining.

  2. Release-window bracket, per tarball and overall. Where the mtimes cluster
     is where the work happened; the stragglers are the late fixes.

  3. Version strings, copyright headers and attribution. Ikonboard changed
     hands between 2.x and 3.x, and the code carries several generations of
     copyright line at once. Grouping files by their exact header shows which
     generation each module was last touched in.

  4. Inline developer commentary -- named credits, bug numbers, TODO and
     apology comments left in shipped code.

Usage: python3 ib_provenance.py [board_root] [dist_root]
"""
import os
import re
import sys
import tarfile
from datetime import datetime, timezone

HERE = os.path.dirname(os.path.abspath(__file__))
DEFAULT_BOARD = os.path.join(HERE, 'board')
DEFAULT_DIST = os.path.dirname(HERE)

EMAIL = re.compile(r'[\w.\-]+@[\w.\-]+\.\w{2,}')
URL = re.compile(r'https?://[^\s"\'<>)~,]+')
VERSION = re.compile(r'\$iB::VERSION\s*=\s*[\'"]([^\'"]+)')
COPYRIGHT = re.compile(r'^#.*(?:\(c\)|copyright)\s*\d{4}.*$', re.M | re.I)
# Named credits the developers left in the source: "Added by KEVaholic00",
# "FOR BUG FIX #168", "Fix by Camil".
CREDIT = re.compile(
    r'#[^\n]*?\b(?:Added|Written|Fixed?|Modified|Changed|Patched|Coded|Hacked)'
    r'\s+(?:in\s+)?by[: ]+([A-Za-z][\w .\-]{1,28})', re.I)
BUGREF = re.compile(r'BUG\s*(?:FIX\s*)?#\s*(\d+)', re.I)
# Comments that admit something is unfinished or wrong.
CONFESSION = re.compile(
    r'^[ \t]*#[ \t]*(.*(?:\bTODO\b|\bFIXME\b|\bXXX\b|\bHACK\b|\bkludge\b|'
    r"\bfor now\b|\bnot sure\b|\bwe should\b|\bugly\b|\bhorrible\b|\bnasty\b|"
    r"\bcheat\b|\bwork ?around\b|\btemporar).*)$", re.I | re.M)

TEXT_EXT = {'.pm', '.pl', '.cgi', '.cfg', '.conf', '.dat', '.txt', '.html'}
CODE_EXT = {'.pm', '.pl', '.cgi'}

# Files Ikonboard 3 creates on install or first request. None ship in the zip.
GENERATED = [
    ('cgi-bin/Boardinfo.cgi', 'installer step 5',
     'require()d by ikonboard.cgi line 123 -- nothing runs without it'),
    ('cgi-bin/install.lock', 'installer, on completion',
     'its absence makes ikonboard.cgi refuse to start if installer.cgi exists'),
    ('cgi-bin/Data/*.pwd', 'ikonboard.cgi, first request',
     'the ARC4 key file for the stored database password'),
    ('cgi-bin/Database/active_sessions/*', 'every request',
     'session rows -- written for guests too'),
    ('cgi-bin/Database/forum_posts/*', 'Post.pm',
     'the posts themselves'),
    ('cgi-bin/Database/member_profiles/*', 'Register.pm',
     'the member table'),
    ('cgi-bin/Data/timeout_log', 'ikonboard.cgi write_report',
     'slow-process log'),
    ('cgi-bin/Database/Temp/*', 'CGI.pm upload staging',
     'attachment temp files'),
]

# Placeholder files that ship inside the tarballs. They live in directories
# that otherwise only ever hold runtime data, so they look like evidence of a
# live board until you notice they are in the distribution itself.
SHIPPED_STUBS = {'index.html', '.htaccess', 'Searches'}


def when(ts, fmt='%Y-%m-%d %H:%M:%S'):
    return datetime.fromtimestamp(ts, timezone.utc).strftime(fmt)


def text_files(root):
    for dirpath, dirnames, filenames in os.walk(root):
        for name in sorted(filenames):
            if os.path.splitext(name)[1].lower() in TEXT_EXT:
                full = os.path.join(dirpath, name)
                yield os.path.relpath(full, root).replace('\\', '/'), full


def section(n, title):
    print()
    print('=' * 78)
    print('%d. %s' % (n, title))
    print('=' * 78)


def main():
    board = sys.argv[1] if len(sys.argv) > 1 else DEFAULT_BOARD
    dist = sys.argv[2] if len(sys.argv) > 2 else DEFAULT_DIST

    files = list(text_files(board))

    section(0, 'WAS THIS TREE EVER INSTALLED?')
    found = []
    for rel, maker, why in GENERATED:
        if rel.endswith('*'):
            d = os.path.join(board, os.path.dirname(rel).replace('/', os.sep))
            names = [n for n in (os.listdir(d) if os.path.isdir(d) else [])
                     if n not in SHIPPED_STUBS]
            hit = bool(names)
        else:
            hit = os.path.exists(os.path.join(board, rel.replace('/', os.sep)))
        if hit:
            found.append(rel)
        print('  %-8s %-34s by %-26s %s'
              % ('PRESENT' if hit else 'absent', rel, maker, why))
    print('\n  VERDICT: %s' % (
        'deployed board -- mtimes record an operator' if found else
        'unpacked distribution, NEVER INSTALLED -- mtimes record the vendor'))

    section(1, 'TAR BUILD ORDER -- archive mtime vs newest file inside')
    print('Ikonboard 3 ships its Perl inside tarballs. Each archive has its own')
    print('filesystem timestamp, and carries a timestamp for every file it')
    print('holds. Packaging normally makes the archive the newest thing in the')
    print('picture. Where it is not, something was repacked.\n')
    tardir = os.path.join(dist, 'Upload_Files', 'cgi-bin')
    tars = sorted(f for f in (os.listdir(tardir) if os.path.isdir(tardir) else [])
                  if f.endswith('.tar'))
    print('%-16s %-21s %-21s %-21s %s'
          % ('archive', 'archive mtime', 'oldest inside', 'newest inside',
             'anomaly'))
    print('-' * 110)
    for name in tars:
        path = os.path.join(tardir, name)
        with tarfile.open(path) as tf:
            mt = [m.mtime for m in tf.getmembers() if m.isfile()]
            newest_name = max((m for m in tf.getmembers() if m.isfile()),
                              key=lambda m: m.mtime).name
        own = os.stat(path).st_mtime
        flag = ''
        if max(mt) > own:
            flag = 'CONTENT NEWER THAN ARCHIVE by %s (%s)' % (
                fmt_delta(max(mt) - own), newest_name)
        print('%-16s %-21s %-21s %-21s %s'
              % (name, when(own), when(min(mt)), when(max(mt)), flag))

    section(2, 'DATE BRACKET')
    dated = sorted((os.stat(f).st_mtime, r) for r, f in files)
    print('earliest file: %s  %s' % (when(dated[0][0]), dated[0][1]))
    print('latest file:   %s  %s' % (when(dated[-1][0]), dated[-1][1]))
    span = (dated[-1][0] - dated[0][0]) / 86400.0
    print('span:          %.0f days' % span)

    # Where the mass of the work sits, by month.
    print('\nfiles by month:')
    months = {}
    for ts, _ in dated:
        months[when(ts, '%Y-%m')] = months.get(when(ts, '%Y-%m'), 0) + 1
    top = max(months.values())
    for m in sorted(months):
        bar = '#' * max(1, int(40 * months[m] / top))
        print('  %s  %4d  %s' % (m, months[m], bar))

    # The tail: the last things anybody touched before the release shipped.
    print('\nlast 25 files modified -- the closing days of development:')
    for ts, rel in dated[-25:]:
        print('  %s  %s' % (when(ts), rel))

    section(3, 'VERSION, COPYRIGHT AND OWNERSHIP')
    versions, headers, emails, urls = set(), {}, set(), set()
    for rel, full in files:
        text = open(full, encoding='latin-1').read()
        versions |= set(VERSION.findall(text))
        emails |= set(EMAIL.findall(text))
        urls |= set(URL.findall(text))
        if os.path.splitext(rel)[1].lower() in CODE_EXT:
            hs = tuple(h.strip() for h in COPYRIGHT.findall(text))
            if hs:
                headers[rel] = hs

    print('version strings in code: %s' % (', '.join(sorted(versions)) or '(none)'))

    print('\ncopyright header variants (%d distinct):' % len(set(headers.values())))
    byheader = {}
    for rel, hs in headers.items():
        byheader.setdefault(hs, []).append(rel)
    for hs, rels in sorted(byheader.items(), key=lambda kv: -len(kv[1])):
        print('\n  %d file(s):' % len(rels))
        for h in hs:
            print('     %s' % h)
        if len(rels) <= 10:
            print('     -> %s' % ', '.join(sorted(rels)))
    noheader = sorted(r for r, f in files
                      if os.path.splitext(r)[1].lower() in CODE_EXT
                      and r not in headers)
    print('\n  NO copyright header (%d):' % len(noheader))
    for r in noheader:
        print('     %s' % r)

    print('\nemail addresses (%d):' % len(emails))
    for e in sorted(emails):
        print('   %s' % e)
    print('\nhosts referenced (%d):' % len({u.split('/')[2] for u in urls}))
    for h in sorted({u.split('/')[2] for u in urls}):
        print('   %s' % h)

    section(4, 'DEVELOPER COMMENTARY LEFT IN SHIPPED CODE')
    credits, bugs, confessions = {}, {}, []
    for rel, full in files:
        if os.path.splitext(rel)[1].lower() not in CODE_EXT:
            continue
        text = open(full, encoding='latin-1').read()
        for c in CREDIT.findall(text):
            credits.setdefault(c.strip(' .:)-'), set()).add(rel)
        for b in BUGREF.findall(text):
            bugs.setdefault(b, set()).add(rel)
        for line, in [(m.group(1),) for m in CONFESSION.finditer(text)]:
            confessions.append((rel, line.strip()))

    print('named credits in comments (%d):' % len(credits))
    for name, rels in sorted(credits.items(), key=lambda kv: -len(kv[1])):
        print('   %-24s %d file(s): %s'
              % (name, len(rels), ', '.join(sorted(rels)[:4])))

    print('\nbug-tracker references (%d distinct):' % len(bugs))
    for b, rels in sorted(bugs.items(), key=lambda kv: int(kv[0])):
        print('   #%-6s %s' % (b, ', '.join(sorted(rels))))

    print('\nunfinished-work comments (%d):' % len(confessions))
    for rel, line in confessions[:60]:
        print('   %-42s %s' % (rel, line[:110]))


def fmt_delta(sec):
    d, rem = divmod(int(sec), 86400)
    h, rem = divmod(rem, 3600)
    m, s = divmod(rem, 60)
    return '%dd %02dh %02dm %02ds' % (d, h, m, s)


if __name__ == '__main__':
    main()
```

### 11.4 ib_actions.py

```python
"""Map every reachable endpoint in the board.

Ikonboard 3 has exactly one CGI entry point. Everything is selected by query
parameters, in two stages:

    ikonboard.cgi?act=Post&CODE=09
                      |         |
                      |         +-- second stage: the module's own %Mode hash
                      +------------ first stage: %Mode in ikonboard.cgi, which
                                    names a module and a method

The first stage is a literal hash in ikonboard.cgi and is easy to read. The
second stage is per-module and written several different ways -- `%Mode` with
code refs, `%Mode` with strings, if/elsif chains on `$iB::IN{'CODE'}`. This
walks all of them so the complete URL surface can be stated rather than
guessed, which matters because the security chapter needs to know what an
unauthenticated request can reach.

The admin control panel is a third stage again: `AD=1`/`CP=1` diverts to
Admin::Functions, which dispatches on its own parameter.

Usage: python3 ib_actions.py [board_root]
"""
import os
import re
import sys

HERE = os.path.dirname(os.path.abspath(__file__))
DEFAULT = os.path.join(HERE, 'board')

# %Mode = ( ST => ['Topic', 'ShowTopic'], ... ) -- the dispatcher's own table.
TOP_ENTRY = re.compile(
    r"""^\s*['"]?([\w]+)['"]?\s*=>\s*\[\s*['"]([\w:]+)['"]\s*,"""
    r"""\s*['"](\w+)['"]\s*\]""", re.M)

# Per-module tables. Both shapes appear:
#   'emoticons' => \&emoticons,
#   '01'        => 'ShowForm',
MODE_ENTRY = re.compile(
    r"""^\s*['"]?([\w \-]+?)['"]?\s*=>\s*(?:\\&(\w+)|['"](\w+)['"])\s*,?\s*$""",
    re.M)

CODE_IF = re.compile(
    r"""\$iB::IN\{\s*['"]?CODE['"]?\s*\}\s*(?:eq|==)\s*['"]([\w\-]+)['"]""")

SUB = re.compile(r'^\s*sub\s+(\w+)', re.M)
PACKAGE = re.compile(r'^\s*package\s+([\w:]+)\s*;', re.M)
# Permission gates a request has to clear before the handler runs.
GATE = re.compile(
    r'\$iB::MEMBER_GROUP->\{\s*[\'"]?(\w+)[\'"]?\s*\}'
    r'|\$iB::MEMBER->\{\s*[\'"]?(MEMBER_ID|G_ACCESS|ADMIN)[\'"]?\s*\}')


def mode_blocks(text):
    """Yield the body of each `%Mode = ( ... );` literal in a file."""
    for m in re.finditer(r'%\s*Mode\s*=\s*\(', text):
        depth, i = 1, m.end()
        while i < len(text) and depth:
            if text[i] == '(':
                depth += 1
            elif text[i] == ')':
                depth -= 1
            i += 1
        yield text[m.end():i - 1]


def main():
    root = sys.argv[1] if len(sys.argv) > 1 else DEFAULT
    cgi = os.path.join(root, 'cgi-bin')
    src = os.path.join(cgi, 'Sources')

    entry = open(os.path.join(cgi, 'ikonboard.cgi'), encoding='latin-1').read()

    print('=' * 78)
    print('1. FIRST STAGE -- ikonboard.cgi %Mode: act= -> module->method')
    print('=' * 78)
    top = []
    for body in mode_blocks(entry):
        top.extend(TOP_ENTRY.findall(body))
    print('%-12s %-24s %-14s %s' % ('act=', 'module', 'method', 'source file'))
    print('-' * 78)
    for act, mod, meth in top:
        path = os.path.join(src, mod.replace('::', os.sep) + '.pm')
        print('%-12s %-24s %-14s %s'
              % (act, mod, meth,
                 'Sources/%s.pm' % mod.replace('::', '/')
                 if os.path.exists(path) else '*** MISSING ***'))
    print('\n%d actions' % len(top))

    # The dispatcher builds Perl source text and eval()s it. Show the template
    # verbatim -- it is the single most important five lines in the program.
    m = re.search(r"my \$code = 'require.*?;\n(?:.*?\n)*?\s*eval \$code;", entry)
    if m:
        print('\nthe dispatch itself (ikonboard.cgi):')
        for line in m.group(0).split('\n'):
            print('    %s' % line.strip())

    print()
    print('=' * 78)
    print('2. SECOND STAGE -- per-module CODE= tables')
    print('=' * 78)
    modules = {mod for _, mod, _ in top}
    total = 0
    for mod in sorted(modules):
        path = os.path.join(src, mod.replace('::', os.sep) + '.pm')
        if not os.path.exists(path):
            continue
        text = open(path, encoding='latin-1').read()
        subs = set(SUB.findall(text))
        codes = []
        for body in mode_blocks(text):
            for key, ref, name in MODE_ENTRY.findall(body):
                codes.append((key, ref or name))
        ifs = sorted(set(CODE_IF.findall(text)))
        if not codes and not ifs:
            continue
        print('\n%-24s (Sources/%s.pm)' % (mod, mod.replace('::', '/')))
        for key, handler in codes:
            mark = '' if handler in subs else '   <- handler not defined here'
            print('    CODE=%-16s -> %s%s' % (key, handler, mark))
            total += 1
        if ifs:
            print('    if/elsif on CODE: %s' % ', '.join(ifs))
            total += len(ifs)
    print('\n%d second-stage endpoints' % total)

    print()
    print('=' * 78)
    print('3. ADMIN CONTROL PANEL -- AD=1 / CP=1')
    print('=' * 78)
    fn = os.path.join(src, 'Admin', 'Functions.pm')
    if os.path.exists(fn):
        text = open(fn, encoding='latin-1').read()
        print('entry: Admin::Functions->process($db), reached from')
        print('       ikonboard.cgi when $iB::IN{AD} or $iB::IN{CP} is set')
        print('       (AD is aliased to CP at line 180 -- see notes)\n')
        gates = sorted({g for tup in GATE.findall(text) for g in tup if g})
        print('permission fields consulted in Admin/Functions.pm: %s'
              % (', '.join(gates) or '(none)'))
        for body in mode_blocks(text):
            for key, ref, name in MODE_ENTRY.findall(body):
                print('    %-20s -> %s' % (key, ref or name))
    admin = os.path.join(src, 'Admin')
    mods = sorted(f for f in os.listdir(admin) if f.endswith('.pm'))
    print('\n%d admin modules:' % len(mods))
    for f in mods:
        text = open(os.path.join(admin, f), encoding='latin-1').read()
        pk = PACKAGE.findall(text)
        subs = SUB.findall(text)
        print('    %-24s package %-24s %3d subs %6d lines'
              % (f, pk[0] if pk else '?', len(subs), text.count('\n') + 1))

    print()
    print('=' * 78)
    print('4. MODULES NEVER NAMED BY THE DISPATCHER')
    print('=' * 78)
    print('Reachable only by require() from another module, or not at all.\n')
    named = {os.path.join(src, m.replace('::', os.sep) + '.pm')
             for _, m, _ in top}
    for dirpath, dirnames, filenames in os.walk(src):
        dirnames.sort()
        if os.path.basename(dirpath) == 'Admin':
            dirnames[:] = []
            continue
        for f in sorted(filenames):
            if not f.endswith('.pm'):
                continue
            full = os.path.join(dirpath, f)
            if full in named:
                continue
            rel = os.path.relpath(full, src).replace('\\', '/')
            text = open(full, encoding='latin-1').read()
            print('    %-40s %3d subs %6d lines'
                  % (rel, len(SUB.findall(text)), text.count('\n') + 1))


if __name__ == '__main__':
    main()
```

### 11.5 ib_records.py

```python
"""Extract the complete data model, and check the five backends agree.

Ikonboard 3's headline feature is that the same board runs on Berkeley DBM,
MySQL, PostgreSQL or Oracle. (A fifth driver, `Driver/CSV.pm`, ships but cannot
load and is not offered by the installer -- see `ib_manifest.py` and the
architecture chapter.) That works because the schema is declared once, in Perl,
in `Database/config/<table>.cfg`:

    $STRING = { TABLE => 'forum_topics', P_KEY => 'TOPIC_ID', ... };
    %{ $COLS } = ( TOPIC_TITLE => [1, 'string', 70, 1], ... );
                                    |     |      |   |
                       column order +     |      |   +- required
                              declared type      +----- width

The DBM driver reads those declarations directly, so for it this *is* the
schema. The three SQL backends instead ship hand-written DDL in
`INSTALL_DATA/{mysql,postgres,oracle}_schema.txt`. Two hand-maintained copies
of one data model is exactly the kind of thing that drifts, so this script
extracts both and diffs them: tables in one and not the other, columns in one
and not the other. Any mismatch is a portability bug that only bites operators
on the backend that was not being tested.

Usage: python3 ib_records.py [board_root]
"""
import os
import re
import sys

HERE = os.path.dirname(os.path.abspath(__file__))
DEFAULT = os.path.join(HERE, 'board')

STRING = re.compile(r'\$STRING\s*=\s*\{(.*?)\}\s*;', re.S)
KV = re.compile(r"""['"]?(\w+)['"]?\s*=>\s*['"]?([^'",}\s]*)['"]?""")
# These declarations are hand-written Perl and vary in four ways that all have
# to be tolerated. Widths appear bare (`10`), quoted (`'20'`) and negative
# (`-1`, an unbounded text column); names appear quoted and bare; the required
# flag is optional; and several entries carry a trailing comma before the
# closing bracket (`[4, 'string', 32, ]`).
#
# An earlier version of this pattern accepted only the tidiest form and
# silently dropped the rest -- among them `forum_posts.POST`, the post body
# itself, every column of `ssi_templates` and `templates`, and
# `mod_posts.ATTACH_ID`. A schema extractor that loses a table's payload while
# still reporting a plausible column count is far worse than one that fails
# loudly, so every shape the tree actually uses is spelled out here.
COL = re.compile(
    r"""['"]?(\w+)['"]?\s*=>\s*\[\s*(\d+)\s*,\s*['"](\w+)['"]\s*"""
    r"""(?:,\s*['"]?(-?\d+)['"]?\s*)?"""
    r"""(?:,\s*['"]?(-?\d+)['"]?\s*)?"""
    r""",?\s*\]""")

CREATE = re.compile(
    r'CREATE\s+TABLE\s+[`"]?(\w+)[`"]?\s*\((.*?)\)\s*(?:TYPE|;|$)',
    re.S | re.I)


def parse_cfg(path):
    text = open(path, encoding='latin-1').read()
    meta = {}
    m = STRING.search(text)
    if m:
        meta = dict(KV.findall(m.group(1)))
    cols = {}
    for name, idx, typ, width, req in COL.findall(text):
        cols[name] = {'idx': int(idx), 'type': typ,
                      # -1 is the declared width for an unbounded text column.
                      'width': int(width) if width else None,
                      'required': bool(req)}
    return meta, cols


def parse_sql(path):
    """Table -> {column: declared type} from a CREATE TABLE script."""
    text = open(path, encoding='latin-1').read()
    text = re.sub(r'--[^\n]*', '', text)
    out = {}
    for table, body in CREATE.findall(text):
        cols = {}
        depth, cur = 0, []
        for ch in body + ',':
            if ch == '(':
                depth += 1
            elif ch == ')':
                depth -= 1
            if ch == ',' and depth == 0:
                frag = ''.join(cur).strip()
                cur = []
                if not frag:
                    continue
                first = frag.split()[0].strip('`"')
                if first.upper() in ('PRIMARY', 'KEY', 'UNIQUE', 'INDEX',
                                     'CONSTRAINT', 'FULLTEXT'):
                    continue
                rest = ' '.join(frag.split()[1:])
                cols[first] = rest
                continue
            cur.append(ch)
        out[table] = cols
    return out


def common_prefix(sql_tables, declared):
    """The table prefix the SQL schema uses, if any.

    Returns the shortest prefix that makes the largest number of SQL table
    names match a declared one. Empty string when the names already agree.
    """
    if set(sql_tables) & set(declared):
        return ''
    best, best_n = '', 0
    for name in sql_tables:
        for cut in range(1, len(name)):
            pre = name[:cut]
            n = sum(1 for t in sql_tables
                    if t.startswith(pre) and t[cut:] in declared)
            if n > best_n:
                best, best_n = pre, n
    return best


def main():
    root = sys.argv[1] if len(sys.argv) > 1 else DEFAULT
    cfgdir = os.path.join(root, 'cgi-bin', 'Database', 'config')
    idata = os.path.join(root, 'cgi-bin', 'INSTALL_DATA')

    tables = {}
    for f in sorted(os.listdir(cfgdir)):
        if not f.endswith('.cfg'):
            continue
        meta, cols = parse_cfg(os.path.join(cfgdir, f))
        tables[meta.get('TABLE', f[:-4])] = (meta, cols, f)

    print('=' * 78)
    print('1. THE DECLARED SCHEMA -- Database/config/*.cfg')
    print('=' * 78)
    print('These declarations *are* the schema for the DBM driver.\n')
    print('%-24s %-18s %-9s %-7s %s'
          % ('table', 'primary key', 'method', 'cols', 'update key'))
    print('-' * 78)
    for name in sorted(tables):
        meta, cols, _ = tables[name]
        print('%-24s %-18s %-9s %-7d %s'
              % (name, meta.get('P_KEY', '-'), meta.get('MTD', '-'),
                 len(cols), meta.get('ID', '-')))
    print('\n%d tables, %d columns total'
          % (len(tables), sum(len(c) for _, c, _ in tables.values())))

    print()
    print('=' * 78)
    print('2. COLUMN DEFINITIONS')
    print('=' * 78)
    for name in sorted(tables):
        meta, cols, fname = tables[name]
        print('\n%s   (config/%s)' % (name, fname))
        print('  %-4s %-26s %-8s %-6s %s'
              % ('#', 'column', 'type', 'width', 'required'))
        for col, d in sorted(cols.items(), key=lambda kv: kv[1]['idx']):
            print('  %-4d %-26s %-8s %-6s %s'
                  % (d['idx'], col, d['type'],
                     d['width'] if d['width'] is not None else '-',
                     'yes' if d['required'] else ''))
        # A gap or a repeat in the ordinals corrupts a CSV row, since the
        # driver writes fields positionally.
        idx = sorted(d['idx'] for d in cols.values())
        if idx != list(range(len(idx))):
            missing = sorted(set(range(max(idx) + 1)) - set(idx))
            dupes = sorted({i for i in idx if idx.count(i) > 1})
            print('  *** ORDINALS NOT CONTIGUOUS: missing %s, duplicated %s'
                  % (missing or 'none', dupes or 'none'))

    print()
    print('=' * 78)
    print('3. SQL BACKENDS vs THE DECLARED SCHEMA')
    print('=' * 78)
    print('The SQL DDL is maintained by hand, separately from the .cfg files')
    print('the CSV/DBM drivers read. Divergence means the same board behaves')
    print('differently depending on which backend the operator chose.\n')
    for label, fname in [('MySQL', 'mysql_schema.txt'),
                         ('PostgreSQL', 'postgres_schema.txt'),
                         ('Oracle', 'oracle_schema.txt')]:
        path = os.path.join(idata, fname)
        if not os.path.exists(path):
            print('%-12s (not shipped)' % label)
            continue
        sql = parse_sql(path)
        # The DDL carries the installer's table prefix (`ib_forum_posts`)
        # while the declarations do not. Comparing the two raw makes every
        # table look unmatched on both sides and the diff silently reports
        # nothing in common -- which reads as "no drift" rather than as the
        # failure it is. Strip whatever prefix the schema actually uses.
        prefix = common_prefix(sql, tables)
        if prefix:
            sql = {(k[len(prefix):] if k.startswith(prefix) else k): v
                   for k, v in sql.items()}
        print('%s -- %s: %d tables%s'
              % (label, fname, len(sql),
                 ' (table prefix %r stripped)' % prefix if prefix else ''))
        only_sql = sorted(set(sql) - set(tables))
        only_cfg = sorted(set(tables) - set(sql))
        if only_sql:
            print('   tables in SQL but NOT declared in config/: %s'
                  % ', '.join(only_sql))
        if only_cfg:
            print('   tables declared in config/ but MISSING from SQL: %s'
                  % ', '.join(only_cfg))
        drift = 0
        for t in sorted(set(sql) & set(tables)):
            scols = {c.upper() for c in sql[t]}
            ccols = {c.upper() for c in tables[t][1]}
            miss = sorted(ccols - scols)
            extra = sorted(scols - ccols)
            if miss or extra:
                drift += 1
                print('   %s:' % t)
                if miss:
                    print('      declared but not in SQL: %s' % ', '.join(miss))
                if extra:
                    print('      in SQL but not declared: %s' % ', '.join(extra))
        print('   %d of %d shared tables differ\n'
              % (drift, len(set(sql) & set(tables))))

    print('=' * 78)
    print('4. ON-DISK LAYOUT FOR THE FLAT-FILE DRIVER')
    print('=' * 78)
    dbdir = os.path.join(root, 'cgi-bin', 'Database')
    dirs = sorted(d for d in os.listdir(dbdir)
                  if os.path.isdir(os.path.join(dbdir, d)))
    print('Database/ ships one directory per table, pre-created and each')
    print('blocked with its own index.html and .htaccess:\n')
    for d in dirs:
        inside = sorted(os.listdir(os.path.join(dbdir, d)))
        known = ' (declared)' if d in tables else ''
        print('   %-24s %s%s' % (d + '/', ', '.join(inside) or '(empty)', known))
    undeclared = sorted(set(dirs) - set(tables) - {'config', 'Temp'})
    if undeclared:
        print('\n   directories with no .cfg declaration: %s'
              % ', '.join(undeclared))


if __name__ == '__main__':
    main()
```

### 11.6 ib_subs.py

```python
"""Package and subroutine census across the whole library.

Ikonboard 3 is nominally object-oriented -- modules bless a hash and are called
as `Module->new()->Process($db)`. How deep that goes is worth measuring rather
than assuming, because it decides how much of the 2.x design actually changed.
Three things this counts:

  * Every package and its subs, so the library can be described by shape rather
    than by file listing.
  * Which subs are called as methods (`$obj->name`) versus as plain functions
    (`Package::name(...)`). Heavy use of the second means the object is
    decoration and the module is really a namespace.
  * Package-scoped `my` variables at file scope. Under mod_perl a module is
    compiled once and reused across requests, so a file-scoped lexical holding
    per-request state is shared between users -- the classic mod_perl bug, and
    Ikonboard 3 advertises mod_perl support.

Usage: python3 ib_subs.py [board_root]
"""
import os
import re
import sys

HERE = os.path.dirname(os.path.abspath(__file__))
DEFAULT = os.path.join(HERE, 'board')

PACKAGE = re.compile(r'^\s*package\s+([\w:]+)\s*;', re.M)
SUB = re.compile(r'^\s*sub\s+(\w+)', re.M)
METHOD_CALL = re.compile(r'->\s*(\w+)\s*\(')
FUNC_CALL = re.compile(r'(?<![\w:>])([A-Z][\w:]*)::(\w+)\s*\(')
# `my $x = ...` at column zero or one -- file scope, outside any sub.
FILE_LEXICAL = re.compile(r'^(?:my|our)\s+([\$@%]\w+)\s*=', re.M)
NEW = re.compile(r'^\s*sub\s+new\b', re.M)
BLESS = re.compile(r'\bbless\b')
STRICT = re.compile(r'^\s*use\s+strict\s*;', re.M)


def sub_spans(text):
    """(start, end) of every sub body, by brace matching."""
    spans = []
    for m in re.finditer(r'^\s*sub\s+\w+[^\{]*\{', text, re.M):
        depth, i = 1, m.end()
        while i < len(text) and depth:
            c = text[i]
            if c == '{':
                depth += 1
            elif c == '}':
                depth -= 1
            i += 1
        spans.append((m.start(), i))
    return spans


def outside_subs(text):
    """The file with every sub body blanked, leaving only file-scope code."""
    spans = sub_spans(text)
    out, prev = [], 0
    for a, b in spans:
        out.append(text[prev:a])
        out.append('\n' * text.count('\n', a, b))
        prev = b
    out.append(text[prev:])
    return ''.join(out)


def perl_files(root):
    for dirpath, dirnames, filenames in os.walk(root):
        dirnames.sort()
        for name in sorted(filenames):
            if name.endswith(('.pm', '.pl', '.cgi')):
                full = os.path.join(dirpath, name)
                yield os.path.relpath(full, root).replace('\\', '/'), full


def main():
    root = sys.argv[1] if len(sys.argv) > 1 else DEFAULT
    cgi = os.path.join(root, 'cgi-bin')

    files = list(perl_files(cgi))
    info = {}
    for rel, full in files:
        text = open(full, encoding='latin-1').read()
        info[rel] = {
            'text': text,
            'packages': PACKAGE.findall(text),
            'subs': SUB.findall(text),
            'lexicals': FILE_LEXICAL.findall(outside_subs(text)),
            'strict': bool(STRICT.search(text)),
            'oo': bool(NEW.search(text) and BLESS.search(text)),
            'lines': text.count('\n') + 1,
        }

    print('=' * 78)
    print('1. PACKAGE CENSUS')
    print('=' * 78)
    print('%-46s %-5s %-5s %-6s %s'
          % ('file', 'pkgs', 'subs', 'lines', 'notes'))
    print('-' * 78)
    tot_pkg, tot_sub = set(), 0
    for rel in sorted(info):
        d = info[rel]
        tot_pkg |= set(d['packages'])
        tot_sub += len(d['subs'])
        notes = []
        if d['oo']:
            notes.append('OO')
        if not d['strict']:
            notes.append('NO use strict')
        if len(d['packages']) > 1:
            notes.append('%d packages' % len(d['packages']))
        print('%-46s %-5d %-5d %-6d %s'
              % (rel, len(d['packages']), len(d['subs']), d['lines'],
                 ', '.join(notes)))
    print('\n%d files, %d distinct packages, %d subs'
          % (len(info), len(tot_pkg), tot_sub))

    print()
    print('=' * 78)
    print('2. FILES WITHOUT `use strict`')
    print('=' * 78)
    loose = sorted(r for r in info if not info[r]['strict'])
    print('Under `use strict` a mistyped variable name is a compile error;')
    print('without it, the typo silently reads as undef and the bug surfaces')
    print('later as a blank page or a missing record.\n')
    print('%d of %d Perl files:\n' % (len(loose), len(info)))
    for r in loose:
        print('   %-46s %d lines' % (r, info[r]['lines']))

    print()
    print('=' * 78)
    print('3. FILE-SCOPE LEXICALS -- the mod_perl hazard')
    print('=' * 78)
    print('Ikonboard 3 ships mod_perl support (Sources/iPerl/mod_perl.pm, and')
    print('ikonboard.cgi resets its globals explicitly for it). Under mod_perl')
    print('a module is compiled once per child process and reused for every')
    print('later request, so a `my` variable at file scope outlives the request')
    print('that set it and is visible to the next visitor served by that child.')
    print('ikonboard.cgi clears the $iB::* globals for exactly this reason --')
    print('but a file-scoped lexical inside a module is not reachable from')
    print('there and is not cleared.\n')
    risky = [(r, d['lexicals']) for r, d in sorted(info.items())
             if d['lexicals']]
    for rel, lex in risky:
        print('   %-46s %s' % (rel, ', '.join(sorted(set(lex)))))
    print('\n%d files hold state at file scope' % len(risky))

    print()
    print('=' * 78)
    print('4. CALL STYLE -- how object-oriented is it really?')
    print('=' * 78)
    meth, func = {}, {}
    for rel, d in info.items():
        for name in METHOD_CALL.findall(d['text']):
            meth[name] = meth.get(name, 0) + 1
        for pkg, name in FUNC_CALL.findall(d['text']):
            func['%s::%s' % (pkg, name)] = func.get('%s::%s' % (pkg, name), 0) + 1
    print('method calls  ($obj->name):     %d call sites, %d distinct names'
          % (sum(meth.values()), len(meth)))
    print('function calls (Pkg::name()):   %d call sites, %d distinct names'
          % (sum(func.values()), len(func)))
    print('\nmost-called methods:')
    for name, n in sorted(meth.items(), key=lambda kv: -kv[1])[:25]:
        print('   %-30s %d' % (name, n))
    print('\nmost-called package functions:')
    for name, n in sorted(func.items(), key=lambda kv: -kv[1])[:25]:
        print('   %-40s %d' % (name, n))

    print()
    print('=' * 78)
    print('5. SUBROUTINE INDEX')
    print('=' * 78)
    for rel in sorted(info):
        d = info[rel]
        if not d['subs']:
            continue
        print('\n%s   [%s]' % (rel, ', '.join(d['packages']) or 'no package'))
        line = '   '
        for s in d['subs']:
            if len(line) + len(s) > 74:
                print(line)
                line = '   '
            line += s + '  '
        if line.strip():
            print(line)


if __name__ == '__main__':
    main()
```

### 11.7 ib_fileio.py

```python
"""Every place the board touches the filesystem or the database.

Two questions this answers.

First, locking. Ikonboard 2 was a flat-file board and its locking was famously
broken. Ikonboard 3 still ships flat-file drivers (CSV and DBM) as the default
for operators with no database, so the same class of bug can still exist -- but
now it lives in one place, the driver, instead of being sprinkled through 32
scripts. This lists every open()/flock() so that claim can be checked rather
than assumed.

Second, path construction. Any open() whose filename is built from a variable
is a candidate for directory traversal if that variable can carry request data.
This prints the expression, not just the fact of the call, so the ones built
from `$iB::IN{...}` stand out.

It also inventories the database call surface -- which driver methods exist,
and which modules issue raw SQL rather than going through the abstraction.

Usage: python3 ib_fileio.py [board_root]
"""
import os
import re
import sys

HERE = os.path.dirname(os.path.abspath(__file__))
DEFAULT = os.path.join(HERE, 'board')

OPEN = re.compile(r'\bopen\s*\(?\s*([\w:$]+)\s*,\s*([^;]+?)\)?\s*(?:or|\|\||;)')
OPENDIR = re.compile(r'\bopendir\s*\(?\s*(\w+)\s*,\s*([^;]+?)\)?\s*(?:or|\|\||;)')
FLOCK = re.compile(r'\bflock\s*\(?\s*([\w:$]+)\s*,\s*(\d+|LOCK_\w+)')
UNLINK = re.compile(r'\bunlink\s*\(?([^;]{0,90})')
MKDIR = re.compile(r'\bmkdir\s*\(?([^;]{0,90})')
CHMOD = re.compile(r'\bchmod\s*\(?\s*(0?\d{3,4})')
RENAME = re.compile(r'\brename\s*\(?([^;]{0,90})')

# Database surface
DB_METHOD = re.compile(r'\$db\s*->\s*(\w+)\s*\(')
RAW_SQL = re.compile(
    r'(?i)\b(SELECT\s+.{0,60}?\s+FROM|INSERT\s+INTO|UPDATE\s+\w+\s+SET|'
    r'DELETE\s+FROM|CREATE\s+TABLE|DROP\s+TABLE|ALTER\s+TABLE)')

# Anything sourced from the request.
TAINTED = re.compile(r'\$iB::IN\{|\$iB::CGI->param|\$ENV\{|\$iB::COOKIES')


def perl_files(root):
    for dirpath, dirnames, filenames in os.walk(root):
        dirnames.sort()
        for name in sorted(filenames):
            if name.endswith(('.pm', '.pl', '.cgi')):
                full = os.path.join(dirpath, name)
                yield os.path.relpath(full, root).replace('\\', '/'), full


def squeeze(s, n=64):
    s = re.sub(r'\s+', ' ', s).strip()
    return s if len(s) <= n else s[:n - 3] + '...'


def main():
    root = sys.argv[1] if len(sys.argv) > 1 else DEFAULT
    cgi = os.path.join(root, 'cgi-bin')
    files = list(perl_files(cgi))

    opens, dirs, locks, dangerous = [], [], [], []
    for rel, full in files:
        text = open(full, encoding='latin-1').read()
        lines = text.split('\n')
        for i, line in enumerate(lines, 1):
            for m in OPEN.finditer(line):
                handle, expr = m.group(1), squeeze(m.group(2), 70)
                mode = ('append' if '>>' in expr else
                        'write' if re.search(r'[\'"]?\s*>', expr) else 'read')
                opens.append((rel, i, handle, mode, expr))
                if TAINTED.search(expr):
                    dangerous.append((rel, i, 'open', expr))
            for m in OPENDIR.finditer(line):
                dirs.append((rel, i, squeeze(m.group(2), 70)))
                if TAINTED.search(m.group(2)):
                    dangerous.append((rel, i, 'opendir', squeeze(m.group(2), 70)))
            for m in FLOCK.finditer(line):
                locks.append((rel, i, m.group(1), m.group(2)))
            for pat, kind in ((UNLINK, 'unlink'), (MKDIR, 'mkdir'),
                              (RENAME, 'rename')):
                for m in pat.finditer(line):
                    if TAINTED.search(m.group(1)):
                        dangerous.append((rel, i, kind, squeeze(m.group(1), 70)))

    print('=' * 78)
    print('1. FILE OPENS -- %d call sites in %d files'
          % (len(opens), len({o[0] for o in opens})))
    print('=' * 78)
    print('%-40s %5s %-8s %s' % ('file', 'line', 'mode', 'target'))
    print('-' * 78)
    for rel, i, handle, mode, expr in opens:
        print('%-40s %5d %-8s %s' % (rel, i, mode, expr))

    print()
    print('=' * 78)
    print('2. LOCKING')
    print('=' * 78)
    print('%d flock() calls against %d open() calls.\n' % (len(locks), len(opens)))
    if locks:
        print('%-40s %5s %-14s %s' % ('file', 'line', 'handle', 'mode'))
        print('-' * 78)
        for rel, i, handle, mode in locks:
            print('%-40s %5d %-14s %s' % (rel, i, handle, mode))
    locked_files = {l[0] for l in locks}
    writers = {o[0] for o in opens if o[3] in ('write', 'append')}
    print('\nfiles that write but never flock (%d):'
          % len(writers - locked_files))
    for r in sorted(writers - locked_files):
        n = len([o for o in opens if o[0] == r and o[3] in ('write', 'append')])
        print('   %-46s %d write/append open(s)' % (r, n))

    print()
    print('=' * 78)
    print('3. DIRECTORY READS')
    print('=' * 78)
    for rel, i, expr in dirs:
        print('%-40s %5d  %s' % (rel, i, expr))

    print()
    print('=' * 78)
    print('4. PATHS BUILT FROM REQUEST DATA -- traversal candidates')
    print('=' * 78)
    print('A filesystem call whose target is assembled from $iB::IN, $ENV or a')
    print('cookie. Not all are exploitable -- most are validated somewhere --')
    print('but every traversal bug is on this list.\n')
    if not dangerous:
        print('   (none found)')
    for rel, i, kind, expr in dangerous:
        print('%-40s %5d %-9s %s' % (rel, i, kind, expr))

    print()
    print('=' * 78)
    print('5. PERMISSIONS SET BY THE CODE')
    print('=' * 78)
    for rel, full in files:
        text = open(full, encoding='latin-1').read()
        for i, line in enumerate(text.split('\n'), 1):
            for m in CHMOD.finditer(line):
                print('%-40s %5d  chmod %s   %s'
                      % (rel, i, m.group(1), squeeze(line, 50)))

    print()
    print('=' * 78)
    print('6. DATABASE SURFACE')
    print('=' * 78)
    methods, raw = {}, {}
    for rel, full in files:
        text = open(full, encoding='latin-1').read()
        for name in DB_METHOD.findall(text):
            methods[name] = methods.get(name, 0) + 1
        hits = RAW_SQL.findall(text)
        if hits:
            raw[rel] = len(hits)
    print('$db-> methods called across the codebase:\n')
    for name, n in sorted(methods.items(), key=lambda kv: -kv[1]):
        print('   %-24s %d call sites' % (name, n))
    print('\nfiles containing literal SQL keywords (%d):' % len(raw))
    print('The abstraction layer is supposed to be the only place that knows')
    print('SQL. Anything outside Sources/iDatabase/ is either a leak in the')
    print('abstraction or a deliberate backend-specific path.\n')
    for rel, n in sorted(raw.items(), key=lambda kv: -kv[1]):
        inside = rel.startswith('Sources/iDatabase/') or \
            rel.startswith('Sources/Search/API/')
        print('   %-52s %3d  %s'
              % (rel, n, '' if inside else '<- outside the abstraction'))


if __name__ == '__main__':
    main()
```

### 11.8 ib_taint.py

```python
"""Measure the attack surface: where request data reaches something dangerous.

Ikonboard 3 does one thing Ikonboard 2 did not: it escapes every incoming
parameter at the front door. `ikonboard.cgi::_clean_value` HTML-encodes angle
brackets, quotes, backslashes and dollar signs on every value in `%iB::IN`
before any module sees it. That is a real, meaningful improvement and it closes
most of 2.x's reflected-XSS surface in one move.

It also creates three new problems, and this script is built to find all three.

  1. What the filter does not cover. It escapes for HTML. It does not escape
     for SQL, for the shell, for a filesystem path, or for Perl's own eval.
     Anything reaching those sinks is still live.

  2. Where data re-enters unescaped. Values that come from the database, from
     cookies, or from %ENV never pass through _clean_value at all. Stored
     content is written escaped -- but anything the board un-escapes on the way
     out, or any field written by a path that bypasses the filter, is back to
     square one.

  3. What the filter breaks. Escaping at the door means every module now
     handles pre-mangled data: a password containing an apostrophe is stored
     as `&#39;`, a search for `a > b` never matches. The regexes below find the
     places that try to undo the damage, because each of those is a hole in the
     one defense the board has.

Sinks are ranked: eval and backticks first, then SQL and filesystem.

Usage: python3 ib_taint.py [board_root]
"""
import os
import re
import sys

HERE = os.path.dirname(os.path.abspath(__file__))
DEFAULT = os.path.join(HERE, 'board')

SOURCES = [
    ('request', re.compile(r'\$iB::IN\{\s*[\'"]?(\w+)')),
    ('cgi',     re.compile(r'\$iB::CGI\s*->\s*param\s*\(')),
    ('cookie',  re.compile(r'\$iB::COOKIES\s*->\s*\{|\bcookie\s*\(')),
    ('env',     re.compile(r'\$ENV\{\s*[\'"]?(\w+)')),
    ('db',      re.compile(r'\$(?:row|member|data|topic|post|forum)\s*->\s*\{')),
]

SINKS = [
    ('EVAL-STRING', 3, re.compile(r'\beval\s+(?!\{)\S')),
    ('BACKTICK',    3, re.compile(r'`[^`\n]+`')),
    ('SYSTEM',      3, re.compile(r'\b(?:system|exec)\s*\(')),
    ('OPEN-PIPE',   3, re.compile(r'\bopen\s*\(?[^;]*[|]')),
    ('REQUIRE-VAR', 3, re.compile(r'\brequire\s+[\$"\']?[^;\n]*\$')),
    ('DO-FILE',     3, re.compile(r'\bdo\s+[\$"\']\S')),
    ('SQL',         2, re.compile(
        r'(?i)\b(?:SELECT|INSERT|UPDATE|DELETE)\b[^;\n]{0,120}'
        r'(?:\$\w|\.\s*\$)')),
    ('FILE-OPEN',   2, re.compile(r'\bopen\s*\(?\s*[\w:$]+\s*,')),
    ('UNLINK',      2, re.compile(r'\bunlink\b')),
    ('REGEX-VAR',   1, re.compile(r'=~\s*[ms]?[/!|]\S*\$\w')),
    ('PRINT-RAW',   1, re.compile(r'\bprint\s+[^;\n]*\$iB::IN')),
]

# Places that reverse the front-door escaping. Each one hands a module back the
# raw characters _clean_value was there to remove.
UNESCAPE = re.compile(
    r's[!/|#]&(?:amp|lt|gt|quot|#0?39|#36|#124|#92|#33);?[!/|#]'
    r'|HTML::Entities|decode_entities|unescapeHTML'
    r'|s[!/|#]&\#(\d+);[!/|#]')

DECL = re.compile(r'^\s*sub\s+(\w+)', re.M)


def perl_files(root):
    for dirpath, dirnames, filenames in os.walk(root):
        dirnames.sort()
        for name in sorted(filenames):
            if name.endswith(('.pm', '.pl', '.cgi')):
                full = os.path.join(dirpath, name)
                yield os.path.relpath(full, root).replace('\\', '/'), full


def squeeze(s, n=88):
    s = re.sub(r'\s+', ' ', s).strip()
    return s if len(s) <= n else s[:n - 3] + '...'


def main():
    root = sys.argv[1] if len(sys.argv) > 1 else DEFAULT
    cgi = os.path.join(root, 'cgi-bin')
    files = list(perl_files(cgi))

    print('=' * 78)
    print('1. THE FRONT-DOOR FILTER')
    print('=' * 78)
    entry = open(os.path.join(cgi, 'ikonboard.cgi'), encoding='latin-1').read()
    for name in ('_clean_key', '_clean_value'):
        m = re.search(r'sub\s+%s\s*\{(.*?)\n\}' % name, entry, re.S)
        if m:
            print('\nikonboard.cgi::%s' % name)
            for line in m.group(1).strip('\n').split('\n'):
                print('    %s' % line.rstrip())
    print('\nEvery value in %iB::IN passes through _clean_value before any')
    print('module runs. Note what is on the list and what is not: no quoting')
    print('for SQL, no path normalization beyond a literal "..", no shell')
    print('quoting. The filter is an HTML filter.')

    print()
    print('=' * 78)
    print('2. SINKS, RANKED')
    print('=' * 78)
    findings = []
    for rel, full in files:
        text = open(full, encoding='latin-1').read()
        lines = text.split('\n')
        for i, line in enumerate(lines, 1):
            if re.match(r'\s*#', line):
                continue
            for label, sev, pat in SINKS:
                if pat.search(line):
                    tainted = [s for s, p in SOURCES if p.search(line)]
                    findings.append((sev, label, rel, i, tainted,
                                     squeeze(line)))
    findings.sort(key=lambda f: (-f[0], f[1], f[2], f[3]))

    for sev, label in [(3, 'CODE EXECUTION'), (2, 'INJECTION'),
                       (1, 'LOWER RISK')]:
        group = [f for f in findings if f[0] == sev]
        print('\n--- severity %d: %s (%d sites) ---\n' % (sev, label, len(group)))
        for _, lab, rel, i, tainted, line in group:
            mark = ('  <== ' + '+'.join(tainted)) if tainted else ''
            print('%-12s %-40s %5d%s' % (lab, rel, i, mark))
            print('             %s' % line)

    print()
    print('=' * 78)
    print('3. THE DISPATCHER eval -- the most important one')
    print('=' * 78)
    print('ikonboard.cgi builds Perl source as a string and eval()s it. The')
    print('module and method names come from a lookup in a fixed hash, and the')
    print('key is rejected unless it is already a key of that hash:\n')
    for pat in (r"\$iB::IN\{'act'\} = 'BoardIdx' unless exists.*",
                r"my \$code = 'require'.*", r"\s*eval \$code;"):
        for m in re.finditer(pat, entry):
            print('    %s' % m.group(0).strip())
    print('\nSo the eval is guarded -- an unknown act falls back to BoardIdx.')
    print('Worth stating plainly, because a string eval driven by a query')
    print('parameter looks like remote code execution until you check.')

    print()
    print('=' * 78)
    print('4. WHERE THE ESCAPING IS UNDONE')
    print('=' * 78)
    print('_clean_value is the board\'s single defense. Every site below hands')
    print('some module the raw characters back.\n')
    n = 0
    for rel, full in files:
        text = open(full, encoding='latin-1').read()
        for i, line in enumerate(text.split('\n'), 1):
            if re.match(r'\s*#', line):
                continue
            if UNESCAPE.search(line):
                print('%-46s %5d  %s' % (rel, i, squeeze(line, 70)))
                n += 1
    print('\n%d un-escaping sites' % n)

    print()
    print('=' * 78)
    print('5. PARAMETER NAMES THE CODE READS')
    print('=' * 78)
    params = {}
    for rel, full in files:
        text = open(full, encoding='latin-1').read()
        for name in SOURCES[0][1].findall(text):
            params.setdefault(name, set()).add(rel)
    print('%d distinct request parameters.\n' % len(params))
    for name, rels in sorted(params.items(), key=lambda kv: -len(kv[1]))[:60]:
        print('   %-22s %3d file(s)  %s'
              % (name, len(rels), ', '.join(sorted(rels)[:3])))

    print()
    print('=' * 78)
    print('6. AUTHENTICATION AND AUTHORIZATION CHECKS')
    print('=' * 78)
    gate = re.compile(
        r'\$iB::MEMBER(?:_GROUP)?\s*->\s*\{\s*[\'"]?(\w+)')
    counts = {}
    for rel, full in files:
        text = open(full, encoding='latin-1').read()
        for name in gate.findall(text):
            counts.setdefault(name, set()).add(rel)
    print('permission fields consulted, by breadth of use:\n')
    for name, rels in sorted(counts.items(), key=lambda kv: -len(kv[1])):
        print('   %-24s %3d file(s)' % (name, len(rels)))


if __name__ == '__main__':
    main()
```

### 11.9 ib_skin.py

```python
"""Take apart the skin engine.

This is the piece of Ikonboard 3 with no counterpart at all in 2.x, where
markup was simply written inline in the CGI scripts. Version 3 splits it in
two:

    Skin/Default/TopicView.cfg   <- the template. What the admin CP edits.
    Skin/Default/TopicView.pm    <- generated Perl. What the board require()s.

Each `.cfg` is a template holding HTML; the admin control panel compiles it
into a `.pm` full of subs that `return qq~ ... ~`, and the board loads only the
`.pm`. So the shipped tree contains the same skin twice, in two representations,
and they are supposed to be equivalent.

That gives a check nobody could run in 2002 without the admin CP: compile
status. If a `.cfg` is newer than its `.pm`, the shipped compiled skin is stale
relative to the template the admin CP will show -- and an operator who opens
that template and saves it gets different HTML than the board was serving.

This script pairs them up, dates them, extracts the sub inventory of each view,
and reports which skin variables the templates reference.

Usage: python3 ib_skin.py [board_root]
"""
import os
import re
import sys
from datetime import datetime, timezone

HERE = os.path.dirname(os.path.abspath(__file__))
DEFAULT = os.path.join(HERE, 'board')

SUB = re.compile(r'^\s*sub\s+(\w+)', re.M)
SKINVAR = re.compile(r'\$iB::SKIN\s*->\s*\{\s*[\'"]?(\w+)')
INFOVAR = re.compile(r'\$iB::INFO\s*->\s*\{\s*[\'"]?(\w+)')
LANGVAR = re.compile(r'\$(\w+)::lang\s*->\s*\{\s*[\'"]?([\w\-]+)')
QQ = re.compile(r'return\s+qq([~!|])')
# Inline event handlers and script blocks in template HTML.
INLINE_JS = re.compile(r'(?i)\bon(?:click|load|submit|change|mouseover)\s*=')


def when(ts):
    return datetime.fromtimestamp(ts, timezone.utc).strftime('%Y-%m-%d %H:%M')


def main():
    root = sys.argv[1] if len(sys.argv) > 1 else DEFAULT
    skindir = os.path.join(root, 'cgi-bin', 'Skin', 'Default')
    if not os.path.isdir(skindir):
        sys.exit('no Skin/Default under %s' % root)

    names = sorted({os.path.splitext(f)[0] for f in os.listdir(skindir)
                    if f.endswith(('.pm', '.cfg'))})

    print('=' * 78)
    print('1. TEMPLATE / COMPILED PAIRS')
    print('=' * 78)
    print('%-18s %-6s %-17s %-6s %-17s %s'
          % ('view', 'cfg KB', 'cfg mtime', 'pm KB', 'pm mtime', 'state'))
    print('-' * 78)
    stale, unpaired = [], []
    for name in names:
        cfg = os.path.join(skindir, name + '.cfg')
        pm = os.path.join(skindir, name + '.pm')
        hc, hp = os.path.exists(cfg), os.path.exists(pm)
        if not (hc and hp):
            unpaired.append((name, 'cfg only' if hc else 'pm only'))
            print('%-18s %-6s %-17s %-6s %-17s %s'
                  % (name,
                     '%.1f' % (os.path.getsize(cfg) / 1024) if hc else '-',
                     when(os.stat(cfg).st_mtime) if hc else '-',
                     '%.1f' % (os.path.getsize(pm) / 1024) if hp else '-',
                     when(os.stat(pm).st_mtime) if hp else '-',
                     'UNPAIRED (%s)' % ('cfg only' if hc else 'pm only')))
            continue
        tc, tp = os.stat(cfg).st_mtime, os.stat(pm).st_mtime
        state = ''
        if tc > tp:
            state = 'cfg NEWER by %s' % fmt(tc - tp)
            stale.append((name, tc - tp))
        print('%-18s %-6.1f %-17s %-6.1f %-17s %s'
              % (name, os.path.getsize(cfg) / 1024, when(tc),
                 os.path.getsize(pm) / 1024, when(tp), state))

    print('\n%d views. %d have a template newer than its compiled form.'
          % (len(names), len(stale)))
    if unpaired:
        print('%d unpaired: %s'
              % (len(unpaired), ', '.join('%s (%s)' % u for u in unpaired)))

    print()
    print('=' * 78)
    print('2. WHAT EACH VIEW PROVIDES')
    print('=' * 78)
    allsubs = 0
    for name in names:
        pm = os.path.join(skindir, name + '.pm')
        if not os.path.exists(pm):
            continue
        text = open(pm, encoding='latin-1').read()
        subs = SUB.findall(text)
        allsubs += len(subs)
        print('\n%-18s %3d subs, %5d lines' % (name, len(subs),
                                               text.count('\n') + 1))
        line = '    '
        for s in subs:
            if len(line) + len(s) > 74:
                print(line)
                line = '    '
            line += s + '  '
        if line.strip():
            print(line)
    print('\n%d template subs across the skin' % allsubs)

    print()
    print('=' * 78)
    print('3. SKIN VARIABLES REFERENCED BY TEMPLATES')
    print('=' * 78)
    skin, info, lang = {}, {}, {}
    for name in names:
        pm = os.path.join(skindir, name + '.pm')
        if not os.path.exists(pm):
            continue
        text = open(pm, encoding='latin-1').read()
        for v in SKINVAR.findall(text):
            skin.setdefault(v, set()).add(name)
        for v in INFOVAR.findall(text):
            info.setdefault(v, set()).add(name)
        for pkg, key in LANGVAR.findall(text):
            lang.setdefault(pkg, set()).add(key)
    print('$iB::SKIN keys (%d) -- the styling surface an admin can change:\n'
          % len(skin))
    for v, views in sorted(skin.items(), key=lambda kv: -len(kv[1])):
        print('   %-24s %3d views' % (v, len(views)))
    print('\n$iB::INFO keys used in markup (%d):\n' % len(info))
    for v, views in sorted(info.items(), key=lambda kv: -len(kv[1]))[:40]:
        print('   %-24s %3d views' % (v, len(views)))
    print('\nlanguage namespaces referenced (%d):' % len(lang))
    for pkg, keys in sorted(lang.items(), key=lambda kv: -len(kv[1])):
        print('   %-24s %3d keys' % (pkg, len(keys)))

    print()
    print('=' * 78)
    print('4. TEMPLATE QUOTING -- what terminates a template body')
    print('=' * 78)
    print('Every template sub returns a qq-quoted string. The delimiter decides')
    print('which character an admin can never put in a template unescaped.\n')
    delim = {}
    for name in names:
        pm = os.path.join(skindir, name + '.pm')
        if not os.path.exists(pm):
            continue
        text = open(pm, encoding='latin-1').read()
        for d in QQ.findall(text):
            delim[d] = delim.get(d, 0) + 1
    for d, n in sorted(delim.items(), key=lambda kv: -kv[1]):
        print('   qq%s ... %s      %d template bodies' % (d, d, n))
    print('\nInside qq the string is interpolated, so a template that contains')
    print('a literal $ or @ from admin-entered content is evaluated as a Perl')
    print('variable, not printed. That is why ikonboard.cgi::_clean_value')
    print('escapes $ to &#036; on the way in.')

    print()
    print('=' * 78)
    print('5. INLINE JAVASCRIPT IN TEMPLATES')
    print('=' * 78)
    total = 0
    for name in names:
        pm = os.path.join(skindir, name + '.pm')
        if not os.path.exists(pm):
            continue
        text = open(pm, encoding='latin-1').read()
        n = len(INLINE_JS.findall(text))
        if n:
            total += n
            print('   %-20s %3d inline handlers' % (name, n))
    print('\n%d total' % total)

    print()
    print('=' * 78)
    print('6. DO THE TEMPLATES AND THE COMPILED VIEWS ACTUALLY AGREE?')
    print('=' * 78)
    print('Section 1 shows 25 templates carrying a timestamp newer than the')
    print('compiled view beside them. A timestamp gap is not by itself a')
    print('content difference, and the question matters: if they differ, the')
    print('board was serving different HTML than the admin CP would show an')
    print('operator, and re-saving any template silently changed the skin.')
    print()
    print('The .cfg format is structured -- `[=SUB-name]` introduces a sub,')
    print('`#=TOP_LINE` its argument unpacking and `#=BODY` its markup -- so')
    print('the template can be compared against the compiled form directly,')
    print('sub by sub, rather than inferred from mtimes.\n')

    differ, same, missing = [], [], []
    for name in names:
        cfg = os.path.join(skindir, name + '.cfg')
        pm = os.path.join(skindir, name + '.pm')
        if not (os.path.exists(cfg) and os.path.exists(pm)):
            continue
        tpl = parse_cfg(open(cfg, encoding='latin-1').read())
        com = parse_pm(open(pm, encoding='latin-1').read())
        only_cfg = sorted(set(tpl) - set(com))
        only_pm = sorted(set(com) - set(tpl))
        # A compiled sub may hold several qq blocks: the markup it returns,
        # plus fragments the Perl above it assembles (option lists and the
        # like), which in the template live in `#=TOP_LINE` rather than
        # `#=BODY`. Comparing the body against the concatenation of all of
        # them therefore reports a difference where none exists. A body
        # matches if it equals any one block, or the whole concatenation.
        diffs = [s for s in sorted(set(tpl) & set(com))
                 if squash(tpl[s]) not in
                 {squash(b) for b in com[s]} | {squash('\n'.join(com[s]))}]
        if only_cfg or only_pm or diffs:
            differ.append((name, only_cfg, only_pm, diffs, len(tpl)))
        else:
            same.append((name, len(tpl)))
        if not tpl:
            missing.append(name)

    print('%-18s %-6s %s' % ('view', 'subs', 'result'))
    print('-' * 78)
    for name, n in same:
        print('%-18s %-6d identical' % (name, n))
    for name, only_cfg, only_pm, diffs, n in differ:
        bits = []
        if diffs:
            bits.append('%d sub(s) DIFFER: %s' % (len(diffs),
                                                  ', '.join(diffs[:4])))
        if only_cfg:
            bits.append('only in template: %s' % ', '.join(only_cfg[:4]))
        if only_pm:
            bits.append('only in compiled: %s' % ', '.join(only_pm[:4]))
        print('%-18s %-6d %s' % (name, n, '; '.join(bits)))

    print('\n%d views identical, %d differ.' % (len(same), len(differ)))
    if missing:
        print('(%s parsed to zero subs -- not a view template)'
              % ', '.join(missing))

    print()
    print('=' * 78)
    print('7. THE SKIN REGISTRY')
    print('=' * 78)
    for rel in ('Data/SkinList.cfg', 'Skin/Default/gfx_data.cfg',
                'Skin/Default/Styles.pm'):
        path = os.path.join(root, 'cgi-bin', rel.replace('/', os.sep))
        if not os.path.exists(path):
            continue
        text = open(path, encoding='latin-1').read()
        print('\n--- %s (%d bytes, %d lines)'
              % (rel, len(text), text.count('\n') + 1))
        for line in text.split('\n')[:30]:
            print('    %s' % line.rstrip()[:96])


def parse_cfg(text):
    """Template file -> {sub name: markup body}.

    The .cfg format is line-oriented: `[=SUB-name]` opens a sub, `#=DESC` and
    `#=TOP_LINE` carry the description and the argument unpacking, and `#=BODY`
    introduces the markup that becomes the qq-quoted return value. Only the
    body is compared -- the top line is Perl that the compiler emits above the
    return, not part of the template's output.
    """
    subs, cur, mode = {}, None, None
    for line in text.split('\n'):
        m = re.match(r'\[=SUB-(\w+)\]', line)
        if m:
            cur, mode = m.group(1), None
            subs[cur] = []
            continue
        if line.startswith('[='):          # [=HEADER] and friends
            cur, mode = None, None
            continue
        if line.startswith('#='):
            mode = line[2:].strip().upper()
            continue
        if cur and mode == 'BODY':
            subs[cur].append(line)
    return {k: '\n'.join(v) for k, v in subs.items()}


def parse_pm(text):
    """Compiled view -> {sub name: concatenated markup of its qq blocks}.

    Deliberately not brace-matched. These templates emit JavaScript, and a
    `}` closing a JS function sits at column zero inside the Perl string --
    so any attempt to find the end of the sub by counting braces stops early
    and loses the markup after it. Segmenting on the `sub` declarations
    themselves is immune to that, since a `sub` keyword never appears at the
    start of a line inside a generated template body.

    A sub may build its output from several qq blocks with Perl between them;
    all of them are collected, because the template's `#=BODY` is the whole
    markup the sub emits.
    """
    starts = [(m.group(1), m.start())
              for m in re.finditer(r'^sub\s+(\w+)', text, re.M)]
    subs = {}
    for i, (name, pos) in enumerate(starts):
        end = starts[i + 1][1] if i + 1 < len(starts) else len(text)
        seg = text[pos:end]
        blocks = [b for _, b in re.findall(r'qq([~!|])(.*?)\1', seg, re.S)]
        if blocks:
            subs[name] = blocks
    return subs


def squash(s):
    """Whitespace-insensitive form, for comparing markup across formats."""
    return re.sub(r'\s+', ' ', s).strip()


def fmt(sec):
    d, rem = divmod(int(sec), 86400)
    h, m = divmod(rem // 60, 60)
    return ('%dd %02dh %02dm' % (d, h, m)) if d else ('%dh %02dm' % (h, m))


if __name__ == '__main__':
    main()
```

### 11.10 ib_delta.py

```python
"""Compare Ikonboard 2.1.9 with Ikonboard 3.1.1.

Version 3 is not an evolution of version 2. It is a rewrite: different
architecture, different data model, different ownership, different license,
and no shared source. That claim is easy to make and worth actually
demonstrating, so this measures the two trees against each other rather than
describing them separately.

  1. Bulk: files, Perl lines, and how the line count is distributed. A rewrite
     that grows fourfold is a different kind of project from the one it
     replaced.

  2. Shared source. If version 3 really is a rewrite, essentially no lines
     should survive from 2.1.9. This checks by content, not by filename --
     normalized non-trivial lines, intersected. Any large overlap would
     disprove the rewrite claim, so it is the strongest single test here.

  3. Capability mapping. Every 2.1.9 script matched to whatever handles the
     same job in 3.1.1, plus the features that exist in only one of them.

  4. The migration path the vendor actually shipped: the iB2 to iB3 converter
     in Admin/Convert_ib.pm, which reads 2.x flat files directly. What it
     carries across, and what it silently drops, is the practical answer to
     "what did upgrading cost you".

Usage: python3 ib_delta.py [ib311_board_root] [ib219_root]
"""
import os
import re
import sys

HERE = os.path.dirname(os.path.abspath(__file__))
DEFAULT_NEW = os.path.join(HERE, 'board')
DEFAULT_OLD = os.path.join(os.path.dirname(os.path.dirname(HERE)), 'ib219')

SUB = re.compile(r'^\s*sub\s+(\w+)', re.M)
STRICT = re.compile(r'^\s*use\s+strict', re.M)
CRYPT = re.compile(r'\bcrypt\s*\(|\bMD5\b|\bmd5_hex\b|\bARC4\b|\bBase64\b')

# 2.1.9 script -> what does that job in 3.1.1.
CAPABILITY = [
    ('ikonboard.cgi',       'ikonboard.cgi + Sources/Boards.pm',
     'board index; 3.x turns the script into a pure dispatcher'),
    ('forums.cgi',          'Sources/Forum.pm',                'forum view'),
    ('topic.cgi',           'Sources/Topic.pm',                'topic view'),
    ('post.cgi',            'Sources/Post.pm + Post2.pm',      'posting'),
    ('postings.cgi',        'Sources/Post2.pm',                'post editing'),
    ('register.cgi',        'Sources/Register.pm',             'registration'),
    ('profile.cgi',         'Sources/Profile.pm',              'member profile'),
    ('loginout.cgi',        'Sources/LogInOut.pm + Sessions.pm',
     'login; 3.x adds real server-side sessions'),
    ('search.cgi',          'Sources/Search/api.pm + Search/API/*',
     'search; 3.x has a per-backend search API'),
    ('messenger.cgi',       'Sources/UserCP/Messenger.pm, Messsend.pm, Messview.pm',
     'private messages'),
    ('newposts.cgi',        'Sources/Newest.pm',               'new-post list'),
    ('printpage.cgi',       'Sources/PrintPage.pm',            'printable view'),
    ('whosonline.cgi',      'Sources/Online.pm',               'who is online'),
    ('help.cgi',            'Sources/Help.pm',                 'help pages'),
    ('misc.cgi',            'Sources/Misc/*',                  'odds and ends'),
    ('ikonfriend.cgi',      'Sources/Misc/Invite.pm',          'invite a friend'),
    ('viewip.cgi',          'Sources/ModCP.pm',                'IP lookup'),
    ('checkboard.cgi',      'Sources/Admin/Tools.pm',          'integrity check'),
    ('checklog.cgi',        'Sources/Admin/Adminlogs.pm',      'admin log'),
    ('privacy.cgi',         '(DROPPED)',
     'privacy statement; "privacy" in 3.1.1 means invisible mode instead'),
    ('announcements.cgi',   '(DROPPED)',
     'announcements; Moderate.pm:1401 dispatches to an undefined sub'),
    ('admincenter.cgi',     'Sources/Admin/Index.pm + Functions.pm',
     'admin entry point'),
    ('setforums.cgi',       'Sources/Admin/ForumControl.pm + Category.pm',
     'forum administration'),
    ('setmembers.cgi',      'Sources/Admin/MemberControl.pm',  'member admin'),
    ('setmembertitles.cgi', 'Sources/Admin/MemberControl.pm',
     'post-count titles; richer in 3.x (PIPS, auto-promote), data not carried'),
    ('setstyles.cgi',       'Sources/Admin/SkinControl.pm + SKIN.pm',
     'styling; 3.x compiles skins to Perl'),
    ('settemplate.cgi',     'Sources/Admin/Templates.pm + BoardTemplates.pm',
     'templates'),
    ('setvariables.cgi',    'Sources/Admin/Options.pm',        'board settings'),
    ('setbadwords.cgi',     'Sources/Admin/Options.pm',        'word filter'),
    ('forumoptions.cgi',    'Sources/Admin/ForumControl.pm',   'per-forum options'),
    ('install.cgi',         'installer.cgi + install_modules/*',
     'installer; 3.x is a multi-step wizard'),
    ('ikon.lib',            'Sources/Lib/FUNC.pm',             'core library'),
    ('ikonadmin.lib',       'Sources/Lib/ADMIN.pm',            'admin library'),
    ('ikonmail.lib',        'Sources/Mail/Sendmail.pm',        'mail'),
]

# Present in 3.1.1 with no 2.1.9 counterpart at all.
NEW_IN_3 = [
    ('Sources/iDatabase/',      'SQL abstraction: DBM, MySQL, PostgreSQL, Oracle'),
    ('Sources/Sessions.pm',     'server-side sessions'),
    ('Sources/Lib/Crypt.pm',    'password hashing'),
    ('Sources/Lib/MD5.pm',      'pure-Perl MD5'),
    ('Sources/ARC4.pm',         'ARC4 stream cipher for the stored DB password'),
    ('Sources/Calendar.pm',     'event calendar'),
    ('Sources/Happybd.pm',      'birthday announcements'),
    ('Sources/NotePad.pm',      'member notepads'),
    ('Sources/Warn.pm',         'member warning levels'),
    ('Sources/ModCP.pm',        'moderator control panel'),
    ('Sources/ModSet.pm',       'moderator permission sets'),
    ('Sources/Massmsend.pm',    'mass private messaging'),
    ('Sources/Misc/Attachments.pm', 'file attachments (2.1.9 had none at all)'),
    ('Sources/Misc/Track.pm',   'topic subscriptions'),
    ('Sources/Misc/Report.pm',  'report a post to moderators'),
    ('Sources/SSI/Parser.pm',   'server-side includes for the host site'),
    ('Sources/iPerl/mod_perl.pm', 'mod_perl support'),
    ('Sources/Admin/MemberGroups.pm', 'member groups with permission masks'),
    ('Sources/iPoll.pm',        'polls (2.1.9 had none at all)'),
    ('Sources/Admin/Authorise.pm', 'registration authorization queue'),
    ('Sources/Admin/Backup.pm', 'database backup'),
    ('Sources/Admin/LangControl.pm', 'language packs'),
    ('Sources/Admin/Filemanager.pm', 'in-browser file manager'),
    ('Sources/Admin/Import.pm', 'restore half of Backup.pm (cross-backend)'),
    ('Sources/Admin/Convert_ib.pm', 'the Ikonboard 2 converter'),
    ('Languages/',              'translatable strings, extracted from code'),
    ('Skin/*.cfg + *.pm',       'templates compiled to Perl'),
]


def perl_files(root):
    for dirpath, dirnames, filenames in os.walk(root):
        dirnames.sort()
        if 'teardown' in dirnames:
            dirnames.remove('teardown')
        for name in sorted(filenames):
            if name.endswith(('.pm', '.pl', '.cgi', '.lib')):
                if name.endswith('.bak'):
                    continue
                full = os.path.join(dirpath, name)
                yield os.path.relpath(full, root).replace('\\', '/'), full


def norm_lines(path):
    """Non-trivial normalized lines, for content comparison."""
    out = set()
    for line in open(path, encoding='latin-1'):
        s = re.sub(r'\s+', ' ', line).strip()
        if len(s) < 24 or s.startswith('#'):
            continue
        out.add(s)
    return out


def survey(root):
    files = list(perl_files(root))
    lines, subs, strict, corpus = 0, 0, 0, set()
    for rel, full in files:
        text = open(full, encoding='latin-1').read()
        # Physical lines, matching ib_manifest.py. Counting newlines and
        # adding one instead would credit every file that does not end in a
        # newline with a phantom last line, which across 179 files is enough
        # to make two totals in the same document disagree.
        lines += text.count('\n') + (1 if text and not text.endswith('\n')
                                     else 0)
        subs += len(SUB.findall(text))
        strict += 1 if STRICT.search(text) else 0
        corpus |= norm_lines(full)
    return {'files': files, 'lines': lines, 'subs': subs,
            'strict': strict, 'corpus': corpus}


def main():
    new_root = sys.argv[1] if len(sys.argv) > 1 else DEFAULT_NEW
    old_root = sys.argv[2] if len(sys.argv) > 2 else DEFAULT_OLD
    if not os.path.isdir(old_root):
        sys.exit('Ikonboard 2.1.9 tree not found at %s -- pass it as argv[2]'
                 % old_root)

    new = survey(os.path.join(new_root, 'cgi-bin'))
    old = survey(os.path.join(old_root, 'cgi-bin'))

    print('=' * 78)
    print('1. BULK')
    print('=' * 78)
    print('%-34s %14s %14s %s' % ('', '2.1.9', '3.1.1', 'change'))
    print('-' * 78)
    for label, key in [('Perl files', 'files'), ('Perl lines', 'lines'),
                       ('subroutines', 'subs')]:
        a = len(old[key]) if key == 'files' else old[key]
        b = len(new[key]) if key == 'files' else new[key]
        print('%-34s %14d %14d %+.1fx' % (label, a, b, b / a if a else 0))
    print('%-34s %13d%% %13d%%'
          % ('files with `use strict`',
             100 * old['strict'] / len(old['files']),
             100 * new['strict'] / len(new['files'])))

    print()
    print('=' * 78)
    print('2. IS IT A REWRITE? -- shared source lines')
    print('=' * 78)
    print('Normalized lines of 24+ characters, comments excluded, intersected')
    print('by content rather than by filename.\n')
    shared = old['corpus'] & new['corpus']
    print('  2.1.9 distinct lines : %d' % len(old['corpus']))
    print('  3.1.1 distinct lines : %d' % len(new['corpus']))
    print('  shared               : %d' % len(shared))
    print('  as %% of 2.1.9        : %.2f%%'
          % (100.0 * len(shared) / max(1, len(old['corpus']))))
    print('  as %% of 3.1.1        : %.2f%%'
          % (100.0 * len(shared) / max(1, len(new['corpus']))))
    if shared:
        print('\n  the shared lines:')
        for s in sorted(shared)[:60]:
            print('     %s' % s[:104])

    print()
    print('=' * 78)
    print('3. CAPABILITY MAP -- what replaced what')
    print('=' * 78)
    print('%-22s %-44s %s' % ('2.1.9', '3.1.1', 'notes'))
    print('-' * 78)
    newnames = {r for r, _ in new['files']}
    oldnames = {r for r, _ in old['files']}
    for src, dst, why in CAPABILITY:
        mark = ''
        if src not in oldnames:
            mark = ' [2.1.9 file not found]'
        print('%-22s %-44s %s%s' % (src, dst, why, mark))
    unmapped = sorted(oldnames - {c[0] for c in CAPABILITY})
    if unmapped:
        print('\n2.1.9 files not in the map: %s' % ', '.join(unmapped))

    print()
    print('=' * 78)
    print('4. NEW IN 3.1.1 -- no 2.1.9 counterpart')
    print('=' * 78)
    for path, why in NEW_IN_3:
        exists = any(r.startswith(path.split('*')[0].rstrip('/'))
                     for r in newnames) or '*' in path or path.endswith('/')
        print('  %-34s %s%s' % (path, why, '' if exists else '  [NOT FOUND]'))

    print()
    print('=' * 78)
    print('5. CRYPTOGRAPHY -- the single biggest security change')
    print('=' * 78)
    for label, s in (('2.1.9', old), ('3.1.1', new)):
        hits = {}
        for rel, full in s['files']:
            text = open(full, encoding='latin-1').read()
            for m in set(CRYPT.findall(text)):
                hits.setdefault(rel, set()).add(m.strip('( '))
        print('\n%s -- %d files reference a crypto primitive:' % (label, len(hits)))
        for rel in sorted(hits):
            print('   %-46s %s' % (rel, ', '.join(sorted(hits[rel]))))

    print()
    print('=' * 78)
    print('6. THE SHIPPED CONVERTER -- Admin/Convert_ib.pm')
    print('=' * 78)
    conv = os.path.join(new_root, 'cgi-bin', 'Sources', 'Admin',
                        'Convert_ib.pm')
    if not os.path.exists(conv):
        print('   not present')
        return
    text = open(conv, encoding='latin-1').read()
    print('%d lines, %d subs.\n' % (text.count('\n') + 1,
                                    len(SUB.findall(text))))
    print('subs:')
    line = '   '
    for s in SUB.findall(text):
        if len(line) + len(s) > 74:
            print(line)
            line = '   '
        line += s + '  '
    print(line)

    # What 2.x files it reads tells you exactly what it can carry over.
    print('\n2.x files and directories the converter reads:')
    for m in sorted({m for m in re.findall(
            r'[\'"]([\w./]*\b(?:members?|forum\d*|messages?|data|topics?)'
            r'[\w./]*\.(?:cgi|dat|thd|pl|txt))[\'"]', text)}):
        print('   %s' % m)
    for m in sorted({m.strip() for m in re.findall(
            r'opendir\s*\(?\s*\w+\s*,\s*([^;]{0,70})', text)}):
        print('   opendir %s' % re.sub(r'\s+', ' ', m))

    print('\n2.x field names the converter knows about:')
    fields = sorted({f for f in re.findall(r'\$ib2\w*\{[\'"]?(\w+)', text)}
                    | {f for f in re.findall(r'IB2_(\w+)', text)})
    for f in fields[:60]:
        print('   %s' % f)


if __name__ == '__main__':
    main()
```

### 11.11 ib_verify.py

````python
"""Check this teardown's citations against the source tree.

The document cites the source constantly, in the form `Sources/Post.pm:855` or
`ikonboard.cgi:485-489`, and it quotes code in fenced blocks. Both are easy to
get wrong and almost impossible to proofread by hand across eight chapters, and
a teardown whose line numbers do not resolve is worse than useless -- it reads
as authoritative while sending the reader to the wrong place.

Three checks:

  1. Every `path:line` citation names a file that exists, at a line that
     exists. A citation into a 200-line module at line 1400 is a fabrication or
     a stale edit, and either way it has to go.

  2. Every fenced `perl` block that looks like it was quoted from the tree is
     searched for in the tree. Blocks are matched on their longest distinctive
     line, whitespace-normalized, because quoting usually reflows indentation
     and often elides a middle section with `...`.

  3. Filenames mentioned in prose resolve to something in the tree, catching
     modules that were renamed or never existed.

Three trees are indexed, not one, because the document legitimately cites all
three: the reconstructed board, the rest of the distribution (the guides, the
toolbox, the upgrade kits, which never get uploaded), and Ikonboard 2.1.9,
which the history and upgrade chapters quote for comparison. Indexing only the
board produces confident false positives against every one of those.

Exit status is non-zero if anything fails, so this can gate a rebuild.

Usage: python3 ib_verify.py [board_root] [dist_root] [ib219_root]
"""
import os
import re
import sys
import glob

HERE = os.path.dirname(os.path.abspath(__file__))
DEFAULT_BOARD = os.path.join(HERE, 'board')
DEFAULT_DIST = os.path.dirname(HERE)
DEFAULT_OLD = os.path.join(os.path.dirname(os.path.dirname(HERE)), 'ib219')

# `Sources/Lib/FUNC.pm:165`, `ikonboard.cgi:485-489`, `Skin/Default/X.pm:12`
CITE = re.compile(
    r'`?\b((?:[\w.\-]+/)*[\w.\-]+\.(?:pm|cgi|pl|cfg|conf|txt|html|dat|lib))'
    r':(\d+)(?:\s*[--]\s*(\d+))?')
# A bare filename in prose or backticks.
FILENAME = re.compile(r'`([\w.\-]+\.(?:pm|cgi|pl|cfg|conf|tar))`')
FENCE = re.compile(r'(?m)^```(\w*)\n(.*?)^```', re.S)

SKIP_FILES = {
    # Named in the document but genuinely absent from every tree: these are
    # generated at install time, and this distribution was never installed.
    'Boardinfo.cgi', 'install.lock', 'boardinfo.cgi',
    # Referenced in Ikonboard's own comments but never shipped: ikonboard.cgi
    # says it will use CGI.pm "until iCGI.pm is mod_perl compatible", and
    # iCGI.pm is not in the distribution.
    'iCGI.pm',
    # CPAN, assumed present on the host rather than shipped.
    'CGI.pm', 'DBI.pm',
    # Files Ikonboard 2.1.9 generates at install time. Named in the upgrade
    # chapter because the converter reads them; absent because that tree was
    # never installed either.
    'progs.cgi', 'styles.cgi', 'allforums.cgi', 'list.cgi', '_out.cgi',
    # Named in the 3.1.0-to-3.1.1 upgrade readme, which misspells several of
    # the files it tells the operator to copy: the real names are
    # ForumView.pm, PrintPageView.pm, api.pm and Filemanager.pm. The document
    # quotes that list verbatim, because the typos are the evidence that the
    # patch instructions were written by hand and never checked.
    'Forumview.pm', 'PrintView.pm', 'Api.pm', 'FileManager.pm',
    # Correctly reported by the document as NOT existing.
    'api_CSV.pm',
    # Written at runtime, never shipped: FUNC.pm:1604 and Tempfiles.pm:170
    # build it under DB_DIR.
    'Email-log.cgi',
    # The Ikonboard 3.0.x config file, named in the 3.0-to-3.1.1 upgrade
    # document (`upgrade_info_mySQL.txt:11`, "/Data/Boardinfo.pm"). Version
    # 3.1.1 renamed it Boardinfo.cgi, which the same document uses for the
    # new install at line 171. Neither exists here -- both are generated.
    'Boardinfo.pm',
    # Not part of Ikonboard.
    'httpd.conf',
    # Named in the shipped 2002 documentation but absent from the download,
    # and quoted verbatim by the document for that reason:
    #   Installer_Guide.html:71 tells the operator to run `iBtest.cgi`, which
    #   does not exist -- the shipped tester is Tools/HELP/perl_test.cgi.
    #   Install_Guide.html:389 hedges between `installer.pl` and
    #   `installer.cgi`; only the .cgi ships.
    'iBtest.cgi', 'installer.pl',
    # Placeholders in prose, not real files: the chapters use `file.pm:123` to
    # show the citation convention, and `file.cgi` / `file-N.cgi` /
    # `<Name>Words.pm` to describe naming patterns.
    'file.pm', 'file.cgi', 'file-N.cgi', 'Words.pm',
    # Named from external sources (vendor patches, advisories) rather than
    # from this distribution.
    'ibfix.cgi',
}


# Figures that appear in more than one chapter, with the value the analysis
# scripts produce. Written as (label, pattern capturing the number, value).
# The patterns are deliberately tight: a loose one matches unrelated prose and
# turns this check into noise nobody reads.
CANON = [
    ('3.1.1 Perl lines',
     r'(?:179 Perl files, |line count from 15,586 to )([\d,]+)(?= lines|,)',
     72805),
    ('2.1.9 Perl lines',
     r'(?:43 files and |line count from )([\d,]+)(?: lines| to)', 15586),
    ('3.1.1 Perl files', r'([\d,]+) Perl files, 72,805', 179),
    ('subroutines', r'72,805 lines, ([\d,]+) subroutines', 1624),
    ('declared tables', r'(?:all |the )([\d,]+) tables field by field', 29),
    # Deliberately anchored to the dispatcher's own table. Several modules
    # have a `%Mode` of their own -- Admin/Options.pm's is 32 entries -- so a
    # bare "N-entry %Mode" pattern reports those as disagreements.
    ('dispatcher actions',
     r'([\d,]+)-entry `%Mode` hash mapping the `act`', 44),
    ('shared source lines',
     r'(?:they share |share )(seventeen|[\d,]+)(?: lines| normalized)', 17),
]

WORDS = {'seventeen': '17', 'twenty-nine': '29', 'forty-four': '44',
         'four': '4', 'five': '5', 'six': '6'}


def index_tree(root, by_name=None, lines=None, skip=()):
    """basename -> [relative paths], and relative path -> {line counts}.

    A set of counts, not a count, because the same relative path exists in more
    than one of the indexed trees -- `cgi-bin/ikonboard.cgi` is 588 lines in
    Ikonboard 3.1.1 and 418 in 2.1.9. Keeping a single number lets whichever
    tree is walked last silently overwrite the others, which turns every
    citation past the shorter file's length into a false failure.
    """
    by_name = {} if by_name is None else by_name
    lines = {} if lines is None else lines
    if not os.path.isdir(root):
        return by_name, lines
    for dirpath, dirnames, filenames in os.walk(root):
        dirnames[:] = sorted(d for d in dirnames if d not in skip)
        for name in filenames:
            full = os.path.join(dirpath, name)
            rel = os.path.relpath(full, root).replace('\\', '/')
            by_name.setdefault(name, []).append(rel)
            if os.path.splitext(name)[1].lower() in (
                    '.pm', '.cgi', '.pl', '.cfg', '.conf', '.txt', '.html',
                    '.dat', '.lib'):
                try:
                    with open(full, encoding='latin-1') as fh:
                        lines.setdefault(rel, set()).add(sum(1 for _ in fh))
                except OSError:
                    pass
    return by_name, lines


def resolve(cited, by_name):
    """All real paths a citation could mean, best match first.

    A list rather than a single path: several trees are indexed and names
    repeat across them, sometimes for quite different files. `topic.cgi` is
    both a 700-line Ikonboard 2.1.9 script and a 42-line redirect stub in the
    migration toolbox. Returning the first match makes a valid citation into
    the large file look like an overrun of the small one, so the caller is
    given every candidate and accepts the citation if any of them fits.
    """
    cited = cited.lstrip('./')
    base = cited.split('/')[-1]
    hits = by_name.get(base, [])
    exact = [r for r in hits
             if r == cited or r.endswith('/' + cited) or r.endswith(cited)]
    return exact or hits


def norm(s):
    return re.sub(r'\s+', ' ', s).strip()


def main():
    args = [a for a in sys.argv[1:] if not a.startswith('--')]
    board = args[0] if len(args) > 0 else DEFAULT_BOARD
    dist = args[1] if len(args) > 1 else DEFAULT_DIST
    old = args[2] if len(args) > 2 else DEFAULT_OLD
    chapters = sorted(
        glob.glob(os.path.join(HERE, '[0-9][0-9]-*.md')) +
        glob.glob(os.path.join(HERE, '_closing.md')))

    # The board first, so a name present in several trees resolves there.
    # `teardown` is excluded from the distribution walk: it holds this
    # analysis, including drafts of the very chapters being checked, and
    # indexing it would let a quote validate against itself.
    roots = [board]
    by_name, linecounts = index_tree(board)
    for extra in (dist, old):
        if os.path.isdir(extra) and os.path.abspath(extra) != os.path.abspath(board):
            index_tree(extra, by_name, linecounts, skip={'teardown', 'board'})
            roots.append(extra)

    corpus = []
    for root in roots:
        for rel in linecounts:
            full = os.path.join(root, rel.replace('/', os.sep))
            if os.path.isfile(full):
                try:
                    corpus.append(norm(open(full, encoding='latin-1').read()))
                except OSError:
                    pass
    haystack = '\n'.join(corpus)
    print('indexed %d files across %d trees\n' % (len(linecounts), len(roots)))

    bad_cites, bad_names, bad_quotes, ok_cites = [], [], [], 0

    print('=' * 78)
    print('1. LINE CITATIONS')
    print('=' * 78)
    for path in chapters:
        name = os.path.basename(path)
        text = open(path, encoding='utf-8').read()
        for m in CITE.finditer(text):
            cited, start, end = m.group(1), int(m.group(2)), m.group(3)
            base = cited.split('/')[-1]
            if base in SKIP_FILES:
                continue
            cands = resolve(cited, by_name)
            line_no = text.count('\n', 0, m.start()) + 1
            if not cands:
                bad_cites.append((name, line_no, m.group(0), 'no such file'))
                continue
            counts = {c for rel in cands for c in linecounts.get(rel, ())}
            if not counts:
                continue
            hi = int(end) if end else start
            # Valid if it fits any candidate file, in any indexed tree.
            if hi > max(counts) or start < 1:
                bad_cites.append((name, line_no, m.group(0),
                                  'longest match %s has %d lines'
                                  % (cands[0], max(counts))))
            else:
                ok_cites += 1
    print('%d citations resolve; %d do not.\n' % (ok_cites, len(bad_cites)))
    for name, line_no, cite, why in bad_cites:
        print('  %-30s line %-5d %-40s %s' % (name, line_no, cite, why))

    print()
    print('=' * 78)
    print('2. FILENAMES MENTIONED IN PROSE')
    print('=' * 78)
    seen = set()
    for path in chapters:
        name = os.path.basename(path)
        text = open(path, encoding='utf-8').read()
        for m in FILENAME.finditer(text):
            fn = m.group(1)
            if fn in SKIP_FILES or fn in seen:
                continue
            seen.add(fn)
            if fn not in by_name and not fn.endswith('.tar'):
                bad_names.append((name, fn))
    print('%d distinct filenames; %d unresolved.\n'
          % (len(seen), len(bad_names)))
    for name, fn in bad_names:
        print('  %-30s %s' % (name, fn))

    print()
    print('=' * 78)
    print('3. QUOTED PERL')
    print('=' * 78)
    print('Each fenced perl block is matched on its longest distinctive line,')
    print('whitespace-normalized. A block that cannot be found may be quoted')
    print('loosely, elided, reformatted, or invented -- it needs a human look.\n')
    checked = 0
    for path in chapters:
        name = os.path.basename(path)
        text = open(path, encoding='utf-8').read()
        for m in FENCE.finditer(text):
            lang, body = m.group(1), m.group(2)
            if lang not in ('perl', 'cgi'):
                continue
            # Some chapters present code as a numbered listing, with the
            # source line number in the left margin. Strip it, or every line
            # of such a block fails to match the source it was copied from.
            cands = [norm(re.sub(r'^\s*\d{1,4}[:\s]\s?', '', l))
                     for l in body.split('\n')]
            cands = [c for c in cands
                     if len(c) >= 30 and not c.startswith('#')
                     and '...' not in c]
            if not cands:
                continue
            checked += 1
            probe = max(cands, key=len)
            if probe not in haystack:
                line_no = text.count('\n', 0, m.start()) + 1
                bad_quotes.append((name, line_no, probe[:96]))
    print('%d perl blocks checked; %d not found in the tree.\n'
          % (checked, len(bad_quotes)))
    for name, line_no, probe in bad_quotes:
        print('  %-30s line %-5d %s' % (name, line_no, probe))

    print()
    print('=' * 78)
    print('4. HEADLINE FIGURES, ACROSS CHAPTERS')
    print('=' * 78)
    print('The same statistic gets quoted in several chapters written')
    print('separately. Each figure below is the value the analysis scripts')
    print('actually produce; any other number next to the same phrase is a')
    print('chapter that drifted.\n')
    bad_figures = []
    for label, pat, want in CANON:
        for path in chapters:
            name = os.path.basename(path)
            text = open(path, encoding='utf-8').read()
            for m in re.finditer(pat, text):
                # Prose spells small counts out; both forms are correct.
                got = WORDS.get(m.group(1).lower(),
                                m.group(1).replace(',', ''))
                if got != str(want):
                    line_no = text.count('\n', 0, m.start()) + 1
                    bad_figures.append((name, line_no, label, got, want))
    for label, _, want in CANON:
        hits = [f for f in bad_figures if f[2] == label]
        print('  %-34s expected %-8s %s'
              % (label, format(want, ','),
                 'OK' if not hits else '%d disagreement(s)' % len(hits)))
    if bad_figures:
        print()
        for name, line_no, label, got, want in bad_figures:
            print('  %-30s line %-5d %s: says %s, should be %s'
                  % (name, line_no, label, got, format(int(want), ',')))

    print()
    print('=' * 78)
    total = (len(bad_cites) + len(bad_names) + len(bad_quotes)
             + len(bad_figures))
    print('SUMMARY: %d citation failures, %d unknown filenames, %d unmatched '
          'quotes' % (len(bad_cites), len(bad_names), len(bad_quotes)))
    print('=' * 78)
    return 1 if total else 0


if __name__ == '__main__':
    sys.exit(main())
````

### 11.12 build_readme.py

The assembler for this document.

```python
"""Assemble the standalone Ikonboard 3.1.1 teardown document.

`readme_src.md` is the skeleton. Two markers pull everything else in:

    <!--CHAPTER:n:file.md:Title-->
                               include a chapter as `## n. Title`, with every
                               heading below it demoted one level and
                               renumbered `n.1`, `n.2`, ... The title is
                               optional; without it the chapter file's own
                               level-1 heading is used, cleaned up.
    <!--CODE:name.py-->        embed a file verbatim in a fenced block
    <!--INCLUDE:file.md-->     drop a file in unchanged

Embedding the analysis scripts rather than describing them means the published
document can never drift from the code that produced its findings.

Usage: python3 build_readme.py [output.md] [--txt]
"""
import os
import re
import sys

HERE = os.path.dirname(os.path.abspath(__file__))
SRC = os.path.join(HERE, 'readme_src.md')
DEFAULT_OUT = os.path.join(HERE, 'IKONBOARD-3.1.1-teardown.md')

# Spellings to normalize on the way out, in prose only. See americanize().
US = [
    ('colour', 'color'), ('Colour', 'Color'),
    ('behaviour', 'behavior'), ('Behaviour', 'Behavior'),
    ('artefact', 'artifact'), ('Artefact', 'Artifact'),
    ('organised', 'organized'), ('recognised', 'recognized'),
    ('centre', 'center'), ('Centre', 'Center'),
    ('labelled', 'labeled'), ('Labelled', 'Labeled'),
    ('modelled', 'modeled'), ('cancelled', 'canceled'),
    ('analyse', 'analyze'), ('catalogue', 'catalog'),
    ('honour', 'honor'), ('favour', 'favor'), ('defence', 'defense'),
    ('whilst', 'while'), ('amongst', 'among'),
    ('grey', 'gray'), ('Grey', 'Gray'),
    ('practise', 'practice'), ('travelled', 'traveled'),
    ('signalled', 'signaled'), ('summarise', 'summarize'),
    ('licence', 'license'),
    ('initialise', 'initialize'), ('serialise', 'serialize'),
    ('normalise', 'normalize'), ('optimise', 'optimize'),
]

# Primary-source strings that appear in PROSE rather than in a code block, and
# whose original spelling is the evidence. Anything inside a code fence or a
# backtick span is already protected structurally and does not belong here.
#
# Ikonboard 3 is British-authored and full of British spellings that are also
# identifiers -- the database table is `authorisation`, the module is
# `Authorise.pm`, the config key is `AUTHORISE_GROUP`. None of those appear in
# the US list above, so they survive on their own; do not add entries for them.
KEEP = [
    'Please Read the licence for more information',
    'Please read the licence',
    'licence for more information',
    'colours.gif',
    'colours.html',
    'sys-img/colours',
]

# The document is served as a raw file. Web servers have no mime mapping for
# .md, so it goes out with no charset and browsers fall back to windows-1252,
# which turns every UTF-8 em dash into "a-". Rather than depend on every
# surface being configured correctly, keep the output pure ASCII -- it then
# renders identically no matter what charset the reader guesses.
ASCII_FOLD = [
    ('--', '--'),    # em dash
    ('-', '-'),     # en dash
    ('-', '-'),     # box drawings light horizontal
    ('|', '|'),     # box drawings light vertical
    ('+', '+'), ('+', '+'), ('+', '+'), ('+', '+'),
    ('+', '+'), ('+', '+'), ('+', '+'), ('+', '+'),
    ('+', '+'),
    ('>', '>'), ('->', '->'), ('<-', '<-'),
    ('<', '<'), ('...', '...'), ('*', '*'),
    ('section ', 'section '), ('(c)', '(c)'), ('GBP ', 'GBP '),
    (''', "'"), (''', "'"),
    ('"', '"'), ('"', '"'),
    ('x', 'x'), (' ', ' '),
    ('yes', 'yes'), ('no', 'no'),
    ('*', '*'), ('v', 'v'), ('>', '>'),
    ('>=', '>='), ('<=', '<='), ('!=', '!='), ('~', '~'),
    ('-', '-'), ('>>', '>>'), ('<<', '<<'),
    ('+/-', '+/-'), ('*', '*'), ('**', '**'), (' degrees', ' degrees'),
    ('-', '-'), ('(tm)', '(tm)'), ('(r)', '(r)'), ('1/2', '1/2'),
]

# The fence character, never written here as a literal run of three -- not even
# in a comment. This file embeds its own source in the document, so a literal
# run would land inside a fenced block and unbalance the document's backtick
# count. Line-oriented highlighters use that count to decide where code stops,
# so an odd total renders everything after it as one long code block.
TICK = chr(96)

# Other distribution trees cited alongside this one, scrubbed to bare relative
# paths on the way out the same way the local root is.
SIBLING_ROOTS = ['ib219']


# Files pulled in by expand(), checked for survival at the end. A later stage
# that silently drops text is the failure mode this catches: the markers are
# gone by then, so nothing else would notice.
EMBEDDED = {}


def expand(text):
    """Replace <!--CODE:name--> with the current contents of that file.

    The fence is widened past the longest backtick run in the embedded file.
    CommonMark only lets a fence be closed by one at least as long as the
    opener, so a script containing triple backticks stays safely enclosed.
    """
    def sub(m):
        name = m.group(1).strip()
        EMBEDDED[name] = None
        body = open(os.path.join(HERE, name), encoding='utf-8').read().rstrip('\n')
        # A distinctive line to look for later: the module docstring's first
        # line is unique per script and survives every downstream rewrite.
        EMBEDDED[name] = next(
            (l.strip('" ') for l in body.split('\n')[:2] if l.strip(' "')), '')
        ext = os.path.splitext(name)[1]
        lang = {'.py': 'python', '.pl': 'perl', '.cgi': 'perl',
                '.pm': 'perl'}.get(ext, '')
        runs = re.findall(TICK + '+', body)
        width = max([3] + [len(r) + 1 for r in runs])
        fence = TICK * width
        return '%s%s\n%s\n%s' % (fence, lang, body, fence)
    return re.sub(r'<!--\s*CODE:([^>]+?)-->', sub, text)


# Populated by include_chapters(): normalized pre-renumber anchor -> final
# anchor. Consulted by fix_anchors() for links a chapter wrote against its own
# original heading numbers.
RENUMBERED = {}


def slug(text):
    """GitHub-style heading anchor."""
    text = re.sub(TICK + '([^' + TICK + ']*)' + TICK, r'\1', text)
    text = re.sub(r'[*_]', '', text)
    text = re.sub(r'[^\w\s-]', '', text.lower())
    return re.sub(r'\s+', '-', text.strip())


def fence_spans(lines):
    """Line indices that sit inside a fenced code block."""
    inside, out, fence = False, set(), None
    for i, line in enumerate(lines):
        m = re.match(r'\s*(' + TICK + '{3,}|~{3,})', line)
        if m:
            if not inside:
                inside, fence = True, m.group(1)[0]
            elif m.group(1)[0] == fence:
                inside = False
        if inside:
            out.add(i)
    return out


# Chapter drafts were written as standalone documents, so each opens by
# restating what the subject is, where the source tree lives, what the version
# string is and why the document exists. Assembled into one teardown that
# becomes the same paragraph eight times over. These are dropped from a
# chapter's preamble (the region before its first sub-heading) only.
FRONTMATTER = re.compile(
    r'^(?:'
    r'\*\*(?:Subject|Source tree|Source tree analyzed|Version string|Purpose|'
    r'Build dates|Nature of this document|Document\s+\d+|Scope|Chapter)\b'
    r'|Source tree\s*:'
    r'|\*\*Ikonboard\s+v?3\.1\.1[^*]*\*\*\s*[----]'
    r'|All line citations refer to'
    r'|This chapter documents all'
    r'|\*(?:Chapter\s+\d|Source\s*:|Everything in this chapter)'
    r')', re.I)


def strip_frontmatter(body):
    """Remove standalone-document boilerplate from a chapter preamble."""
    lines = body.split('\n')
    if not lines:
        return body
    head, rest = [lines[0]], lines[1:]

    end = next((i for i, l in enumerate(rest) if l.startswith('#')), len(rest))
    pre, tail = rest[:end], rest[end:]

    # A dropped bold label often wraps onto following lines; drop those too,
    # up to the next blank line, or the label's tail is left stranded.
    kept, skipping = [], False
    for l in pre:
        if FRONTMATTER.match(l.strip()):
            skipping = True
            continue
        if skipping:
            if not l.strip():
                skipping = False
            continue
        kept.append(l)
    pre = kept

    while pre and (not pre[0].strip() or pre[0].strip() == '---'):
        pre.pop(0)
    while pre and not pre[-1].strip():
        pre.pop()

    out, blank = [], 0
    for l in pre:
        blank = blank + 1 if not l.strip() else 0
        if blank < 2:
            out.append(l)

    return '\n'.join(head + [''] + out + ([''] if out else []) + tail)


def include_chapters(text):
    """Replace <!--CHAPTER:n:file.md--> with that file, renumbered."""
    def sub(m):
        num, name = m.group(1).strip(), m.group(2).strip()
        # An explicit title in the marker wins. Chapters were drafted as
        # standalone documents and title themselves at whatever length suited
        # that: "History and Authorship of Ikonboard 3.1.1", "- On-Disk Data
        # Formats". Those make an incoherent table of contents and break the
        # links in it, and the skeleton is the right place to decide the
        # assembled document's outline.
        forced = (m.group(3) or '').strip()
        body = open(os.path.join(HERE, name), encoding='utf-8').read()
        lines = body.split('\n')
        fenced = fence_spans(lines)
        out, seen_title = [], False
        remap = {}
        renumbered_here = set()

        for i, line in enumerate(lines):
            if i in fenced or not line.startswith('#'):
                out.append(line)
                continue
            hm = re.match(r'(#+)\s+(.*)$', line)
            if not hm:
                out.append(line)
                continue
            level, title = len(hm.group(1)), hm.group(2).strip()

            if level == 1 and not seen_title:
                seen_title = True
                if forced:
                    out.append('## %s. %s' % (num, forced))
                    continue
                # Chapter files title themselves variously: "04 - Module
                # Reference", "Ikonboard 3.1.1 - History". Strip the
                # bookkeeping prefix and any trailing gloss.
                title = re.sub(r'^\d{2}\s*[----:]\s*', '', title)
                title = re.sub(
                    r'^Ikonboard\s+v?3\.1\.1\s*[----:]\s*', '', title)
                title = re.split(r'\s+[----]{1,2}\s+', title)[0].strip()
                title = title.strip('-: ')
                out.append('## %s. %s' % (num, title))
                continue

            new_level = 3 if level == 1 else min(level + 1, 6)

            nm = re.match(r'(\d+)\.\s+(.*)$', title)
            if nm and new_level <= 3:
                new_title = '%s.%s %s' % (num, nm.group(1), nm.group(2))
                renumbered_here.add(nm.group(1))
                remap[slug(title)] = slug(new_title)
                RENUMBERED[re.sub(r'-+', '-', slug(title)).strip('-')] = \
                    slug(new_title)
                title = new_title

            out.append('%s %s' % ('#' * new_level, title))

        body = strip_frontmatter('\n'.join(out).strip('\n'))
        if remap:
            body = re.sub(
                r'\]\(#([\w-]+)\)',
                lambda m: '](#%s)' % remap.get(m.group(1), m.group(1)),
                body)
        # A chapter's own prose says "see section 14", meaning its section 14.
        # Once its headings are renumbered to 6.14 that reference points at a
        # chapter of the assembled document instead -- often one that does not
        # exist. Rewrite these the same way the anchors were rewritten, but
        # only for numbers that really were headings in this chapter.
        if renumbered_here:
            def fix_ref(mm):
                n = mm.group(2)
                return ('%s%s.%s' % (mm.group(1), num, n)
                        if n in renumbered_here else mm.group(0))
            body = re.sub(r'(\bsections?\s+)(\d+)\b', fix_ref, body)
        return body

    text = re.sub(r'<!--\s*CHAPTER:(\d+):([^>:]+?)(?::([^>]*))?-->', sub, text)

    def raw(m):
        name = m.group(1).strip()
        return open(os.path.join(HERE, name), encoding='utf-8').read().strip('\n')

    return re.sub(r'<!--\s*INCLUDE:([^>]+?)-->', raw, text)


def scrub_paths(text):
    """Rewrite local tree roots to neutral relative paths.

    Both trees get scrubbed. Chapters cite Ikonboard 2.1.9 for comparison as
    often as they cite 3.1.1, and its root sits beside this one, so scrubbing
    only the local root leaves the sibling's absolute path in the published
    document.
    """
    root = os.path.basename(os.path.dirname(HERE))       # "ib311"
    for sibling in SIBLING_ROOTS:
        text = re.sub(r'(?<![A-Za-z0-9])[A-Za-z]:\\(?:[^\s`"\'<>|]+?\\)*?'
                      + re.escape(sibling) + r'\\?', sibling + '/', text)
        text = re.sub(r'\b' + re.escape(sibling) + r'/([\w.\-]+(?:\\[\w.\-]*)+)',
                      lambda m: sibling + '/' + m.group(1).replace('\\', '/'),
                      text)
    # Only backslash-separated paths are rewritten. Matching forward slashes
    # too would eat URLs -- in "https://archive.org/details/ib311" the "s:"
    # reads as a drive letter.
    text = re.sub(r'(?<![A-Za-z0-9])[A-Za-z]:\\(?:[^\s`"\'<>|]+?\\)*?'
                  + re.escape(root) + r'\\?', root + '/', text)
    text = re.sub(r'\b' + re.escape(root) + r'/([\w.\-]+(?:\\[\w.\-]*)+)',
                  lambda m: root + '/' + m.group(1).replace('\\', '/'), text)
    text = re.sub(r'\b' + re.escape(root) + r'/((?:[\w.\-]+/)*)\\',
                  lambda m: root + '/' + m.group(1), text)
    # Chapters cite the reconstructed tree as `ib311/cgi-bin/`.
    # In the published document that is just the board, so shorten it.
    return text.replace(root + '/teardown/board/', root + '/')


def code_spans(text):
    """Character ranges that are code: fenced blocks and backtick spans.

    Everything quoted from the 2002 distribution lands in one of these, which
    is what makes it safe to respell the prose without a hand-maintained list
    of exceptions.

    The returned spans are merged and non-overlapping. That is not tidiness:
    americanize() substitutes them out of the text back-to-front using these
    offsets, so an inline span sitting inside a fenced block would be replaced
    first, shifting every later offset and making the enclosing block's end
    index point somewhere else entirely -- which silently deletes whatever
    falls between. Fenced regions are therefore taken line-wise (reusing the
    same scanner the rest of the builder trusts), and inline spans are only
    collected from outside them.
    """
    lines = text.split('\n')
    fenced_lines = fence_spans(lines)

    # Line index -> character offset of that line's start.
    offsets, pos = [], 0
    for line in lines:
        offsets.append(pos)
        pos += len(line) + 1

    spans, i = [], 0
    while i < len(lines):
        if i in fenced_lines:
            start = offsets[i]
            while i < len(lines) and i in fenced_lines:
                i += 1
            # fence_spans marks the opening fence through the last content
            # line; take the closing fence line with it when there is one.
            if i < len(lines) and re.match(r'\s*(' + TICK + '{3,}|~{3,})',
                                           lines[i]):
                i += 1
            end = offsets[i] if i < len(lines) else len(text)
            spans.append((start, end))
        else:
            i += 1

    covered = set()
    for a, b in spans:
        covered.update(range(a, b))
    for m in re.finditer(TICK + r'+[^\n' + TICK + r']+' + TICK + r'+', text):
        if m.start() not in covered:
            spans.append((m.start(), m.end()))

    # Merge anything that still touches, so the substitution sees a clean
    # partition of the text.
    spans.sort()
    merged = []
    for a, b in spans:
        if merged and a <= merged[-1][1]:
            merged[-1] = (merged[-1][0], max(merged[-1][1], b))
        else:
            merged.append((a, b))
    return merged


def americanize(text):
    """Normalize spellings in prose, never inside code.

    Ikonboard is British software: its identifiers, filenames, config keys and
    comments are full of spellings that must survive verbatim, because in a
    teardown the exact string is the evidence and half of them are things you
    would have to type to use the software. Rather than enumerate them,
    protect every code region structurally and respell only what is left.
    """
    spans = code_spans(text)
    holes = {}
    # Protect code regions and the prose exceptions, respell, then restore.
    # code_spans() guarantees these do not overlap, which is what makes
    # back-to-front substitution by original offset safe.
    for i, (a, b) in enumerate(sorted(spans, reverse=True)):
        token = '\x00CODE%d\x00' % i
        holes[token] = text[a:b]
        text = text[:a] + token + text[b:]
    for i, k in enumerate(KEEP):
        if k in text:
            token = '\x00KEEP%d\x00' % i
            holes[token] = k
            text = text.replace(k, token)
    for a, b in US:
        text = text.replace(a, b)
    for token, original in holes.items():
        text = text.replace(token, original)
    return text


def to_ascii(text):
    for a, b in ASCII_FOLD:
        text = text.replace(a, b)
    bad = sorted({ch for ch in text if ord(ch) > 126})
    if bad:
        # Report by codepoint -- printing the characters themselves would
        # itself fail on a cp1252 console, which is the whole reason the
        # output is folded to ASCII in the first place.
        print('WARNING: non-ASCII left in output: %s' % ', '.join(
            'U+%04X' % ord(ch) for ch in bad))
    return text


def fix_anchors(text):
    """Reconcile internal links against the final heading set.

    Anchors are computed from heading text, and heading text is rewritten
    several times on the way through this builder (chapter renumbering, then
    the ASCII fold, which turns an em dash into two hyphens). Rather than keep
    every intermediate form in step, resolve links once at the end, matching
    on a hyphen-run-collapsed form that is stable across all of them.
    """
    def norm(a):
        return re.sub(r'-+', '-', a).strip('-')

    lines = text.split('\n')
    fenced = fence_spans(lines)
    index = {}
    for i, line in enumerate(lines):
        if i in fenced:
            continue
        m = re.match(r'#{2,6}\s+(.*)$', line)
        if m:
            a = slug(m.group(1).strip())
            index.setdefault(norm(a), a)

    unresolved = []

    def sub(m):
        want = m.group(1)
        if norm(want) in index:
            return '](#%s)' % index[norm(want)]
        target = RENUMBERED.get(norm(want))
        if target and norm(target) in index:
            return '](#%s)' % index[norm(target)]
        unresolved.append(want)
        return m.group(0)

    text = re.sub(r'\]\(#([\w-]+)\)', sub, text)
    for a in sorted(set(unresolved)):
        print('CHECK: unresolved internal link -> #%s' % a)
    return text


def check(text):
    """Guard against leaking local paths or unexpanded markers.

    Fenced code is exempt for the path and marker checks: this builder's own
    source is embedded in the document, and it necessarily contains both the
    marker syntax and the patterns used to detect it.
    """
    lines = text.split('\n')
    fenced = fence_spans(lines)
    problems = []
    for pat, why in [
        (r'[A-Za-z]:\\\\?(?:Mike|Users|_git)', 'local Windows path'),
        (r'CLAUDE_JOB_DIR', 'job temp path'),
        (r'\.claude[\\/]jobs', 'job temp path'),
        (r'<!--\s*CODE:', 'unexpanded CODE marker'),
        (r'<!--\s*CHAPTER:', 'unexpanded CHAPTER marker'),
        (r'<!--\s*INCLUDE:', 'unexpanded INCLUDE marker'),
    ]:
        for m in re.finditer(pat, text):
            line = text.count('\n', 0, m.start()) + 1
            if (line - 1) in fenced:
                continue
            problems.append('line %d: %s (%s)' % (line, why, m.group(0)))

    # Every fence must close. A stray run inside an embedded script would
    # otherwise silently turn the rest of the document into a code block in
    # any line-oriented highlighter.
    depth, opened = 0, None
    for i, l in enumerate(lines, 1):
        if re.match(r'\s*' + TICK + '{3,}', l):
            if depth == 0:
                depth, opened = 1, i
            else:
                depth = 0
    if depth:
        problems.append('line %d: code fence opened and never closed' % opened)

    if text.count(TICK * 3) % 2:
        problems.append('odd number of %s runs (%d) -- naive highlighters '
                        'will mis-render the tail'
                        % (TICK * 3, text.count(TICK * 3)))

    # Every embedded script must still be present. An earlier version of this
    # builder deleted several of them between expansion and output -- the
    # markers were already consumed, the fences still balanced, and nothing
    # else in this function noticed.
    for name, probe in sorted(EMBEDDED.items()):
        if probe and probe not in text:
            problems.append('embedded file %s was expanded but its contents '
                            'are not in the output' % name)

    # Prose cross-references ("see section 8") are written by hand against a
    # numbering this builder assigns, so they go stale whenever chapters are
    # reordered. Check every one against the headings that actually exist.
    sections = {m.group(1) for m in re.finditer(r'(?m)^##\s+(\d+)\.\s', text)}
    for i, l in enumerate(lines, 1):
        if i - 1 in fenced:
            continue
        # "ib_skin.py section 6" and "out_records.txt section 3" refer to a
        # script's own report, not to a section of this document. The script
        # name can land on the previous line when the paragraph wraps, so
        # look at both.
        context = (lines[i - 2] if i >= 2 else '') + ' ' + l
        if re.search(r'\b(?:ib_\w+\.py|out_\w+\.txt)\b', context):
            continue
        for m in re.finditer(r'\bsections?\s+(\d+)', l, re.I):
            if m.group(1) not in sections:
                problems.append('line %d: cross-reference to section %s, '
                                'which does not exist (have %s)'
                                % (i, m.group(1),
                                   ','.join(sorted(sections, key=int))))
    return problems


def main():
    args = [a for a in sys.argv[1:] if not a.startswith('--')]
    out = args[0] if args else DEFAULT_OUT
    text = open(SRC, encoding='utf-8').read()
    # Chapters first: they may themselves contain CODE markers.
    text = to_ascii(americanize(scrub_paths(expand(include_chapters(text)))))
    text = fix_anchors(text)

    for p in check(text):
        print('CHECK: %s' % p)

    lines = text.count('\n') + 1
    targets = [out]
    # `--txt` also writes a byte-identical .txt twin. Web servers have no mime
    # mapping for .md, so it is served as application/octet-stream and browsers
    # download it instead of displaying it; .txt is text/plain everywhere,
    # which is what makes it viewable inline with no server configuration.
    if '--txt' in sys.argv:
        targets.append(os.path.splitext(out)[0] + '.txt')
    for path in targets:
        open(path, 'w', encoding='utf-8').write(text)
        print('%s: %d lines, %.0f KB' % (
            path, lines, len(text.encode()) / 1024))


if __name__ == '__main__':
    main()
```

---

## 12. About this teardown

### 12.1 What was examined

One copy of the Ikonboard 3.1.1 distribution zip: 97 files, 4.1 MB, containing
six tar archives which unpack to 550 files. For comparison, a copy of the
Ikonboard 2.1.9 distribution, examined previously and documented separately.

The 3.1.1 tree was never installed. There is no board, no posts, no members and
no configuration file anywhere in it -- the software is present, but nothing it
ever produced is. Every statement here about runtime behavior is therefore read
from the source rather than observed, and the text tries to say so wherever it
matters.

### 12.2 What is added work, and what is not

The distribution is the 2002 release, unmodified. Everything under `teardown/`
-- this document, the nine analysis scripts, their captured output, and the
reconstructed `board/` tree -- is analysis added in 2026. It is not part of the
download and was never shipped by anyone.

Where this document shows a directory tree, that distinction is marked. It
matters because the point of archiving the release is that it is the release.

### 12.3 Known uncertainties

Carried forward from the chapters, in one place:

- **Who made the 11/25/2002 edit** to `Sources/Admin/Menuadmin.pm` is unknown.
  The file's contents and timestamp are facts; the reading that the November
  session was a repackaging rather than development is an inference from the
  fact that only a promotional link block changed.
- **`Languages.tar` holds a file 29 days newer than the archive's own
  timestamp.** Timezone handling in the zip explains a few hours, not four
  weeks. No explanation is offered.
- **The direction of the 07/12/2002 skin batch** -- whether the templates were
  regenerated from the compiled views or copied in from elsewhere -- cannot be
  settled from the files. It does not affect any conclusion, because the two
  representations were checked and agree.
- **Bug #168**, referenced in `ikonboard.cgi`, implies a public bug tracker.
  Nothing in the distribution says where it was, and no archived copy was
  located.
- **Formatting does not identify individual authors** in this codebase. The
  test was run and did not work; see the archaeology chapter. The named-credit
  comments are the only authorship evidence internal to the tree.

External history -- corporate ownership, dates of departure, what became of the
various sites named in the admin panel -- is drawn from sources outside the
distribution and is marked as such in the history chapter, with anything
unconfirmed flagged there rather than smoothed over.

### 12.4 Corrections

If something here is wrong, it is worth fixing: this document is likely to
outlive most other writing about Ikonboard 3, which is exactly the situation in
which an unchallenged error becomes the record.

The claims most worth checking are the quantitative ones, because they are the
easiest to verify and the easiest to have got subtly wrong: the line counts,
the shared-source-line test, the schema comparison across the five backends,
and the skin template comparison. All of them are reproducible by running the
scripts in section 11 against a fresh copy of the distribution.

Anyone holding material that would settle an open question above -- an archived
copy of the Ikonboard support forums or bug tracker, a 3.1.2 distribution, or
records of the Ikonboard.com to Jarvis Entertainment Group transition -- would
be closing gaps that no amount of re-reading this tree can close.

### 12.5 A board that ran it

**ffoncrack.com** ran Ikonboard 3.1.1. That is not inference from a layout or a
footer -- the board's own data survives in a personal backup, and it identifies
the release and the storage engine unambiguously.

The surviving `member_profiles.db` is a Berkeley DB hash file. Reading its
header:

```
offset 12:  00061561   Berkeley DB hash magic
offset 16:  00000007   hash format version 7
offset 20:  00001000   4096-byte pages
```

Hash version 7 is the Berkeley DB 3.3-4.x on-disk format, which tells you what
the 2002 host had installed. Ikonboard reaches it through `AnyDBM_File`:

```perl
BEGIN { @AnyDBM_File::ISA = qw(DB_File GDBM_File NDBM_File SDBM_File) }
```

First match wins, so `DB_File` being present on that server is the reason the
board got real Berkeley DB rather than one of the three fallbacks. Had it
fallen through to SDBM the same data would be sitting in `.dir`/`.pag` pairs
instead.

Inside the file, the values are the `|^|`-joined records this document
specifies in section 5 -- member ID, name, group, MD5 hash, and on through all
forty declared columns of `member_profiles`. Recovering them means reading the
file as binary, discarding the hash structure, and splitting on the delimiter,
which is exactly the procedure in section 5.9. The column names are not in the
file; they come from `Database/config/member_profiles.cfg`.

Two details worth recording, because a single real board answers questions the
distribution cannot:

- **The DBM format is host-dependent and this is one datum for it.** Section
  5.3 lists the concrete format as undeterminable from the download, and that
  remains true -- nothing ships that decides it. But at least one real 3.1.1
  board resolved to Berkeley DB Hash v7.
- **The file's mtime is 03/12/2003.** That is four months after the
  distribution was repackaged and a month before CVE-2003-0770 was disclosed:
  a live board still taking writes, running a pre-authentication remote code
  execution bug that nobody had published yet.

An archive of the board is readable at <https://doors98.com/ffoncrack/>.

### 12.6 Acknowledgements

Ikonboard 3.1.1 was written by the Ikonboard team at Jarvis Entertainment
Group, Inc., building on Ikonboard 2 by Matt Mecham -- whose byline is still on
twelve files in this release, eleven of them the `iDatabase` layer the whole
version was built around. Contributors are credited by handle in the shipped
source, among them `KEVaholic00` and `Camil`, and their work -- member notepads
and the new-posts listing -- is still in the dispatch table. The bundled CPAN
modules are the work of their own authors, named in their headers, Paul
Marquess and Gisle Aas among them.

Preserved and published by **FieRcE YeD** at doors98.com, who kept the copy
this was made from. Preservation is mostly just somebody not throwing something
away.

**doors98.com** is a Windows 98 desktop rebuilt in a browser -- a parody, an
archive, and a place to put things that no longer have anywhere to live. It
already hosts recoveries from this same era and scene, including the Final
Fantasy Fanatic and ffoncrack.com forum archives, a teardown of Ikonboard
2.1.9, and several Games Factory and Multimedia Fusion applications rescued
from the same backup.

### 12.7 Where things live

| | |
|---|---|
| The 3.1.1 distribution -- the July 2002 release as downloaded | <https://archive.org/details/ib311> |
| This document, raw | <https://doors98.com/misc/ikonboard-3.1.1-teardown.txt> |
| This document, as Markdown | <https://doors98.com/misc/ikonboard-3.1.1-teardown.md> |
| This document, on the desktop | <https://doors98.com/misc/ikonboard-3.1.1-teardown> |
| ffoncrack.com -- a board that ran it | <https://doors98.com/ffoncrack/> |
| Ikonboard 2.1.9, the predecessor, torn down the same way | <https://doors98.com/misc/ikonboard-2.1.9-teardown> |
| The 2.1.9 distribution | <https://archive.org/details/ib219> |
| Other recovered software from this era | <https://archive.org/details/@fierceyed> |

**This document is not part of the archive.org download.** The item at
`archive.org/details/ib311` is the 2002 distribution as it shipped, unmodified
-- 97 files in a zip. This teardown, the eleven analysis scripts that produced
it, their captured output, and the reconstructed `board/` tree are all analysis
work added in 2026 by running those scripts against that download. Nothing here
was written by Jarvis Entertainment Group, and none of it was ever shipped by
anyone. Wherever this document shows a directory tree, that distinction is
marked.

(The two copies of this document are byte-identical. Web servers have no mime
mapping for `.md`, so that one is sent as `application/octet-stream` and
browsers download it instead of showing it; the `.txt` is `text/plain`
everywhere and renders inline. The document is written in Markdown and kept
pure ASCII so it reads correctly either way. The `.md` is the canonical copy to
link and mirror; the `.txt` is the one to read in a browser.)

A web bulletin board written in Perl and distributed as CGI, released July 2002. Unlike its predecessor it runs on any of four interchangeable storage backends - Berkeley DBM, MySQL, PostgreSQL or Oracle - behind a single abstraction layer, and adds server-side sessions, MD5 password hashing, member groups with permission masks, translatable language packs and a skin engine that compiles templates into Perl. It shares only seventeen lines of source with Ikonboard 2.1.9 and is a rewrite rather than an upgrade. ffoncrack.com ran this exact release, on the DBM backend.

A complete teardown of Ikonboard 3.1.1 (Jarvis Entertainment Group, July 2002), the Perl CGI forum software that ran ffoncrack.com: the single-entry dispatcher and its 44 endpoints, all 179 modules, the four storage backends and the 29-table schema field by field, the security findings including CVE-2003-0770, what converting a board from Ikonboard 2.1.9 actually cost, and the file timestamps that date the release and its abandonment. This analysis is not part of the archive.org distribution; it was produced in 2026 by running eleven analysis scripts against it.

Ikonboard 3.1.1 is 72,805 lines of Perl across 179 files, and it is not a new version of Ikonboard 2 - compared line by line, ignoring comments and whitespace, the two share seventeen lines, every one of them boilerplate. This teardown documents the whole program from its source. The distribution ships its Perl inside six tar archives that the installer unpacks server-side, so the tree anybody actually ran has to be reconstructed before it can be read at all. Above that sits one CGI entry point which routes every request through three successive lookups, a database abstraction layer with four working drivers and one that ships broken, and a skin engine that compiles admin-edited templates into Perl subroutines. Every on-disk record is specified field by field, so a dead board can be recovered from its files alone; the flat-file backend stores pipe-delimited records as values inside a Berkeley DB hash file, which is how the surviving ffoncrack.com member table was read. The security chapter leads with what genuinely improved over 2.1.9 - hashed passwords, server-side sessions, input escaping at the front door - and then documents CVE-2003-0770, a pre-authentication remote code execution flaw in which the language cookie reaches a Perl eval, reported to the vendor in January 2003 and still unfixed in the successor release that September. Three suspected vulnerabilities were investigated and disproved rather than published. The archaeology chapter dates the release from its own timestamps: development stopped on 15 July 2002, and four months later three of the six tarballs were rebuilt within fourteen minutes of each other with exactly one file changed inside them - the admin control panel's navigation menu, edited by hand to update a block of promotional links. The last change ever made to Ikonboard 3.1.1 was an advertisement.