Status: canonical. Updated 2026-09-04.
The /engine/router.php entry point is the front door for the
public website (zoidtechnologies.com, bbsengine.org). It maps
clean URLs to handler functions and falls through to the
folder.tmpl template.
| Item | Value |
|---|---|
| Source path | /home/opencode/data/work/bbsengine6/engine/router.php |
| Current live path | /srv/www/vhosts/zoidtechnologies.com/html/engine/router.php |
| Legacy path (now incorrect) | /srv/www/bbsengine6/php/router.php |
| Router URL (web) | https://zoidtechnologies.com/engine/router.php |
DOCROOT |
/srv/www/vhosts/zoidtechnologies.com/html/ |
Note on deployment paths. Two paths appear in the source material:
handbook/ROUTER.md (the older operational guide) lists the
source path as /srv/www/bbsengine6/php/router.php.bbsengine6/router.md (the post-mortem of the WSOD fix) lists
the live path as /srv/www/vhosts/zoidtechnologies.com/html/engine/router.php.The live path under
/srv/www/vhosts/zoidtechnologies.com/html/engine/ is the current
canonical path -- the WSOD fix, the directory-listing hardening,
and the make engine deploy target all serve from there. The other
path is the historical location; both are documented here for
operators digging through older issues.
bbsengine6/
├── Makefile # Root build orchestrator
├── engine/
│ ├── Makefile # Stage + deploy engine files
│ ├── router.php # Source for /engine/router.php
│ ├── login.php logout.php join.php
│ └── …
├── php/ # Library (engine.php, util.php, …)
├── skin/ js/ smarty/
# Stage and deploy engine files (php/skin/js/smarty → /srv/www/bbsengine6/)
cd /home/opencode/data/work/bbsengine6 && make engine
# Deploy the www site (htaccess with router rules → /srv/www/vhosts/…)
cd /home/opencode/data/work/zoid6/sites/www && make prod
The engine target chains three steps:
stage -- copies php/, skin/, js/, smarty/ into the
staging directory /srv/www/bbsengine6/.deploy -- rsyncs staging to production.deploy-router -- copies engine/router.php to the web-accessible
/engine/ directory under the live docroot.https://zoidtechnologies.com/
├── engine/ (web-accessible PHP from bbsengine6)
│ ├── router.php (router entry point)
│ ├── login.php logout.php join.php
├── achilles/ (static site -- has own code)
├── empyre/ (static site -- has own code)
├── murdermotel/ (static site -- has own code)
└── [clean URLs] → /engine/router.php
The static-site prefixes (/achilles/, /empyre/,
/murdermotel/) must be matched by their own directories and
processed before the router rewrite -- they have their own code
and should never reach the router.
www/.htaccess)RewriteEngine On
RewriteBase /
# Router for teos blurbs (clean URLs like /comp/, /ec/, etc.)
# Handler chain: blurb → folder → markdown → error
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^([a-zA-Z0-9_/-]+)$ /engine/router.php?mode=browse&uri=$1 [last,qsappend]
# Teos routes (legacy -- still works for /teos/ URLs)
RewriteRule ^teos/(.+)$ php/teos.php?path=$1 [last,QSA]
The first rule passes any non-static URI to the router with
?mode=browse&uri=<path>. The !d / !f conditions prevent
Apache from re-routing real files and directories.
/engine/router.php?mode=browse&uri=…./ or empty URI (renders TEOSDIR/index.php
or TEOSDIR/index.md); pattern ^/?$.engine.__blurb table (database-backed content);
pattern [A-Za-z0-9_][A-Za-z0-9_./-]* (hierarchical
dot-path shape).TEOSDIR (default /srv/www/zoid6/teos/) for
directories; no pattern (filesystem probe decides).TEOSDIR for .md files; pattern
[A-Za-z0-9_][A-Za-z0-9_./-]* (same shape as blurb, no
trailing slash). Single-pass probe: the HTTP entry-point
strips a trailing .md once at the top of the script
(preg_replace('/\.md$/', '', $path)), so the handler
only needs the bare $reluri probe; the previous
$reluri . '.md' branch was dead code. The pattern
deliberately has no .md suffix because the URI is
already post-strip when the dispatch loop matches it --
see the inline comment at router_gethandlers()
(engine/router.php) for the contrast with the page
handler..tmpl under DOCUMENTROOT/skin/tmpl/
(e.g. /contact-us → contact-us.tmpl); two-pass probe
(<slug>.tmpl then <slug> as-is); pattern
[a-z][a-z0-9-]* (narrowest -- bare lowercase slug).
Two-pass is required here because .tmpl is NOT
stripped at the entry-point (htaccess rewrites bare
slugs into the router with no extension, so the
handler has to append .tmpl itself).pattern (PCRE
regex, anchored with ^...$). The dispatch loop
preg_match()es the URI against the pattern; on miss the
handler is skipped without invocation, avoiding filesystem/DB
probes for obviously non-matching URIs. Patterns match the
post-.md-strip URI (so /contact-us, not
/contact-us.md). A null pattern means "always try".
Compile failure is logged and falls through to handler
invocation rather than silently swallowing the URI.router_handleError($uri), which logs, calls
\bbsengine6\page\error($msg, 404) (which renders styled
chrome via displaypage()), sets http_response_code(404),
and returns ROUTER_RENDERED. error is intentionally NOT
in the registry -- registering it would fire it twice on a
miss. The post-loop fallback is the single point of truth
for 404s.Handlers return either:
ROUTER_NEXT -- the router continues to the next handler.ROUTER_RENDERED -- the handler rendered via displaypage()
and the body is already on stdout; the dispatcher returns an
empty string so the HTTP entry-point emits nothing more.null / false -- the router continues to the next handler
(treated identically to ROUTER_NEXT; preserved for callers
that don't know about the sentinel).Returning null or false is treated as "try the next handler",
not "stop and emit this empty body" (this is the post-fix
contract; see "Recent fixes" below).
ROUTER_STOP is no longer defined (no caller used it; the
empty-string foot-gun that motivated its retirement is replaced
by ROUTER_RENDERED).
When the folder handler renders a TEOS directory (e.g.
/rec/arts/tv/mash/), router_collectDirectoryItems() walks the
on-disk contents with scandir() and turns each entry into an item
record. The result is sorted by filename (case-insensitive) and
then passed through router_dedupeItems() to collapse any
remaining case-variant duplicates before the data is handed to the
folder.tmpl template.
scandir() returns every visible filesystem entry, which includes
leftovers from editors and patch tools. router_isIgnoredEntry()
filters out the following patterns before an entry is added to the
listing:
.-prefixed entries (dotfiles), including .. and .~ (emacs backup; multi-tilde
like .~~).swp, .swo, .swn (case-insensitive).bak, .orig, .rej (patch rejects).tmp, .temp, .save (case-insensitive)#name#This list is centralized in router_isIgnoredEntry() so additional
patterns can be added in one place. The companion test
(/home/opencode/data/work/teos/www/php/test_www_mash_no_duplicates.php)
covers the filter and the regression scenario (real mash directory
plus leftover ~ files yields exactly the 7 real blurbs, not 9).
The /engine/ directory holds web-accessible PHP modules:
engine/
├── router.php # Handler-registry router
├── vhostconfig.php # Vhost config resolver (loaded by router.php; resolves config.php under FPM)
├── login.php # Member authentication
├── logout.php # Member logout
└── join.php # Member registration
All modules use the *_run() functional-style entry point pattern.
__DIR__ is used for require_once paths. Cross-namespace imports
use use statements. \bbsengine6\page\redirect() is the canonical
redirect helper (replaces the deprecated bare header() redirect).
router_safe_path_web() (a
thin wrapper around \bbsengine6\util\safe_path_web() that
no-ops to false if the helper isn't loaded) before
filesystem access. A traversal attempt returns 404.\config\SESSIONCOOKIEDOMAIN instead of
hardcoded values.session_destroy()
(Phase 3 finding 3.11).These were the post-mortems captured in the WSOD fix and the
router_isIgnoredEntry() work. They are kept here as a changelog
so operators diagnosing the same symptoms can match against known
fixes.
/teos/rec/ (and similar)Symptom. https://zoidtechnologies.com/teos/rec/ returned a
WSOD (white screen of death -- empty response body). Other paths
(/teos/rec/rec/arts/) also returned empty bodies.
Root causes. Five independent bugs:
router_displayDirectoryListing skipped subdirectories
(continued on is_file($fullpath)). The rec/ directory
contains only arts/ (a subdirectory), so the listing was empty
-- and an empty string in the response body is a WSOD.router_displayDirectoryListing called safe_path_web() with
an absolute path. safe_path_web() rejects absolute paths in
components (a security feature). The first call inside
router_handleFolder succeeded; the second call inside
router_displayDirectoryListing failed. The function returned
false, the directory listing returned null, and the router
emitted an empty string.router_handleError() returned null when no handler matched.
The HTTP layer echoed nothing -- a 404 must be returned instead.TEOSURL was defined early as "" at the top of router.php,
before the HTTP block tried to redefine it to "/teos". The
if (!defined) guard made the override a no-op. Directory
listing links were rendered as relative paths
(rec/arts/), which the browser resolved against the current
URL, producing duplicated paths like /rec/rec/arts/.null / false as a successful
result. Any handler returning null or false was treated as
the response body.Fixes.
TEOSURL and TEOSDIR define calls that
pinned the constants to empty strings. They are defined only
inside the HTTP execution block.router_get_teosurl() and router_get_teosdir() helpers
that read from TEOSURL / TEOSDIR (constants or env vars) and
fall back to sane defaults. Handlers and templates use these
helpers so they always have a non-empty value.router_displayDirectoryListing now uses realpath($dirpath)
(the caller has already validated the path), includes
subdirectory entries (each with is_dir => true and a trailing
/ rendered next to the link), and always returns a non-empty
string. On scandir failure or realpath failure it returns
the result of router_handleError (a 404 page) rather than
null.router_handleError always returns a 404 HTML string, calls
http_response_code(404), and falls back to a built-in 404 if
bbsengine6\page\error is missing or returns null.ROUTER_NEXT, null, or false, and only returns a non-empty
string. If no handler matches, it calls router_handleError($uri).Throwables and emits a 500
with a clean error message rather than a WSOD, and catches
null / false returns and emits a 500 instead of an empty body.Regression tests (test_zoidtechnologies_comp.py):
test_rec_index, test_rec_arts, test_rec_arts_magic -- rec
blurb directory pages are non-empty (not WSOD).test_nonexistent_404 -- bogus paths (rec/rec/arts,
rec/arts/void, top/banana, rec/rec/rec) return a non-empty
response body.test_rec_all_pages_nonempty -- every rec blurb subdirectory
renders content.router_handleError double-body and the ROUTER_RENDERED sentinel (2026-09-19)Symptom. After the 2026-09 fix (above), a 404 response
contained two <title> tags: the styled chrome from
page\error() and a trailing <html><title>404</title>...
fallback block emitted by the HTTP entry-point when
router_handleError returned null. Operators saw duplicate
chromes and echo $router_result printed an empty 500 instead
of stopping cleanly.
Root cause. The handler contract implicitly used an empty
string '' as "I rendered via displaypage(); emit nothing
more." But the dispatch loop returned any non-empty string as
the response body and treated null/false as "try the next
handler". So router_handleError calling page\error() →
displaypage() (which echoes to stdout) and then returning
null left the dispatcher falling into "null → next handler",
the loop ended with return router_handleError($uri), the
handler returned null, and the HTTP entry-point translated
null to "Router Error (null)" with a 500.
The same foot-gun existed for router_handleBlurb,
router_displayMarkdownFile, and servePage (in
engine/serve-tmpl.php): they each rendered via
displaypage() and returned '', which the dispatcher
treated as "short-circuit, emit empty body". Worked because
the HTTP entry-point's echo '' is a no-op, but the contract
was implicit and easy to break.
Fix. Introduced a ROUTER_RENDERED sentinel constant.
Handlers that render via displaypage() now return
ROUTER_RENDERED; the dispatch loop maps it to '' and
short-circuits the chain. Removed ROUTER_STOP (no caller
returned it). Removed error from the registry -- the
post-loop fallback is the single source of truth for 404s.
Regression tests (php/test_router.php):
Test 2 -- ROUTER_RENDERED constant is defined.Test 3b -- error is not in the registry.Test 9 -- router() returns '' for a non-existent URI;
stdout contains exactly one styled error (one <title>
tag), not the pre-fix double-chrome shape..md probe in router_handleMarkdown (2026-09-19)Symptom. Reading the dispatch chain: router_handleMarkdown
did a two-pass extension probe ($reluri . '.md', then bare
$reluri) mirroring the .tmpl probe in
router_handlePage. The first pass was unreachable: the HTTP
entry-point already strips a trailing .md once at the top of
the script (preg_replace('/\.md$/', '', $path)), so by the
time the dispatch loop walks handlers the URI never carries
a .md suffix.
Root cause. Mirror reflex: the markdown handler was
written before the entry-point .md-strip existed. When the
strip was added, the markdown handler's $uri . '.md' branch
became dead code but stayed in place. The .tmpl branch in
serve-tmpl.php is still legitimate (.tmpl is NOT stripped
at the entry-point; htaccess rewrites bare slugs into the
router with no extension).
Fix. Removed the $reluri . '.md' probe from
router_handleMarkdown. Updated serve-tmpl.php's doc
comments that previously referenced "router_handleMarkdown's
two-pass extension probe" to describe the actual remaining
two-pass handler (page) and explain why the markdown
handler dropped it.
Regression tests (php/test_router.php):
Test 6a -- handler body MUST NOT contain a '$uri . .md'
probe (regression guard against re-introducing the dead
branch).Test 6b -- HTTP entry-point MUST strip trailing .md
before dispatch (the contract that makes the dead-code
removal safe).router_handleIndex referenced retired ROUTER_STOP (2026-09-19)Symptom. After the ROUTER_STOP retirement in the same
release, router_handleIndex still returned ROUTER_STOP
after including TEOSDIR/index.php. In PHP 7 this returned
the literal string "ROUTER_STOP" which the HTTP entry-point
would echo (empty body); in PHP 8 it throws a fatal
"Undefined constant" on every / request that hits an
index.php.
Root cause. The C2 retirement of ROUTER_STOP was driven
by a global define removal but missed the call site at line
~228 of router_handleIndex. The variable-function dispatch
loop's return ROUTER_STOP was an unreachable shape
(ROUTER_STOP was never actually handled in the dispatcher),
but the literal constant name in the source was still valid
syntax until C2 removed the define.
Fix. Replaced with ROUTER_RENDERED. The included
TEOSDIR/index.php is expected to render to stdout (either
via displaypage() or direct echo); the sentinel tells the
dispatcher the body is already on the wire and the HTTP
entry-point should emit nothing more.
Editor backup files (e.g. major-characters.md~) and patch
rejects (.rej) were rendered as duplicates of the real blurbs in
folder listings.
Fix. router_isIgnoredEntry() (see "Skipping backup and junk
files" above) and router_dedupeItems() for case-variant
collapses. Regression test in
/home/opencode/data/work/teos/www/php/test_www_mash_no_duplicates.php.
.md URL dispatch was a dead code path (2026-09-29)zoidtechnologies.com/teos/ec/<slug>.md returned
text/plain "File not found" (rendered as a black-page plain-text
response by the browser). Two independent breaks compounded:
.md URL to
engine/serve-md.php, which since commit eecce7c (2026-09-16,
"switch rewrite contract to ?path= query string") only handles
/handbook/<v>/... URLs. The teos-side .md rewrite had been
silently 404'ing for every URL since that commit..md from the URI before the
dispatch loop (line 759). A .md URL would be coerced to its
bare form, the dispatch loop would never see .md URIs, and
the user would get the chrome-rendered blurb page instead of
raw text/plain.Fix. Three coordinated changes:
engine/router.php HTTP entry-point no longer strips .md.
The dispatch loop sees the full URI (with extension).router_handleRawMarkdown is registered at the
end of router_gethandlers() with pattern /\.md$/. It
reads TEOSDIR via \bbsengine6\util\env("TEOSDIR") and
reuses the canonical library
\bbsengine6\markdown\serveRawMarkdown() (defined at
php/markdown.php, merged from php/serve-md.php on
2026-10-XX), which enforces realpath-based containment
under the supplied base dir, .md-only extension, and
file-only checks. On a hit the handler returns
ROUTER_RENDERED; on a miss it returns ROUTER_NEXT and
the chain falls through to router_handleError for the
styled errormessage.tmpl 404.markdown handler's pattern is tightened by removing
. from the URI character class. The $ end-anchor
naturally restricts matches to URIs that don't contain a
dot, so .md$ URIs no longer reach the chrome-rendered
branch. No negative lookahead required.
router_handleMarkdown adds a defensive
preg_replace('/\.md$/', '', $reluri) before its filesystem
probe as belt-and-suspenders.The handbook vhost's .md URLs are now served by the same path
the teos vhost uses: htaccess-prod's .md branch forwards
/handbook/<v>/<uri>.md to /engine/router.php?uri=$2, and the
router's rawmarkdown handler (registered with pattern /\.md$/)
dispatches via \bbsengine6\markdown\serveRawMarkdown($teosdir, $reluri). The vhost's SetEnv TEOSDIR (/srv/www/vhosts/ www.bbsengine.org/html/handbook/<v>/) makes the resolver probe
the right file under the handbook home -- no separate entry-point
shim needed. The legacy engine/serve-md.php shim and its
handbook_resolve() containment are gone; the deployable file
inventory now drops to router.php, serve-tmpl.php, vhostconfig.php, join.php, login.php, logout.php, direct.php, standalone.php (see
engine/Makefile's canonical-entry-points comment).
Regression tests:
php/test_router.php Tests 6f / 6g / 6h -- pin the tightened
markdown pattern, the rawmarkdown registry entry, and the
handler body shape (env("TEOSDIR"), serveRawMarkdown(),
ROUTER_RENDERED).php/test_router_handleraemarkdown.php (new) -- pins the
canonical library in isolation (hit/miss/traversal/.md-only/
file-only/nested).Engine stays site-agnostic. bbsengine6\util\env("TEOSDIR")
is the canonical polymorphic read for the per-vhost content root;
the new handler introduces no teos-specific branch. See
AGENTS.md "TEOSDIR resolution contract".
ROUTER_RENDERED echo (2026-10-02)Symptom. https://bbsengine.org/handbook/6/ (and any
/handbook/<v>/ with no trailing path) returned the styled 404
chrome (<h1>Error</h1> with "Page not found: handbook/6/") AND
the literal string ROUTER_RENDERED appended after </html>.
Direct investigation: see CHANGELOG.md "[Unreleased]" entry
fix(engine/router): handbook version root no longer 404s.
Two interacting root causes:
HTTP entry-point's handbook prefix-strip was gated on
!isset($_GET['uri']). htaccess's catch-all rewrite
(www/org/htaccess-prod line 103) pre-fills $_GET['uri'] to
the full /handbook/<v>/... URI for any directory-shaped
request, so the gate was FALSE for every handbook-version-root
URL. The strip was skipped, $path carried the
/handbook/<v>/ prefix, every handler pattern rejected it,
and the dispatch fell through to the styled 404.
Dispatch loop post-loop fallback returned
router_handleError()'s value directly. router_handleError()
calls \bbsengine6\page\error() (which echoes via
displaypage()) and returns ROUTER_RENDERED. The HTTP
entry-point's else echo $router_result then echoed the
literal string "ROUTER_RENDERED" after the chrome. The
in-loop mapping (if ($result === ROUTER_RENDERED) return '';)
was correct -- the post-loop fallback simply never applied it.
Fix.
engine/router.php HTTP entry-point: drop the
!isset($_GET['uri']) gate on the handbook prefix-strip.
$_SERVER['REQUEST_URI'] is the canonical source for
"what the client asked for"; $_GET['uri'] is a rewrite
artifact that htaccess may or may not pre-fill. The
handbook vhost shape is a well-known engine contract;
the engine owns it.
engine/router.php dispatch loop: capture
router_handleError() into $err_result and normalize
=== ROUTER_RENDERED to '' (mirror of the in-loop
mapping at line ~796).
www/org/htaccess-prod: explicit
^handbook/(\d+)/?$ /engine/router.php?uri= rule BEFORE
the catch-all. Routes the bare version root with empty
?uri= regardless of htaccess pre-fill behavior.
Belt-and-suspenders alongside the engine fix.
Regression tests (php/test_router.php):
Test 3f -- handbook URI prefix-strip is unconditional
(no !isset($_GET['uri']) gate in code).Test 3g -- dispatch loop post-loop fallback normalizes
ROUTER_RENDERED to ''.Commits:
793e2ec -- fix(engine/router): handbook version root 404 + literal ROUTER_RENDERED echo2389d04 -- test(router): pin handbook prefix-strip + ROUTER_RENDERED normalization7d12ff5 -- fix(www/org/htaccess-prod): explicit bare-handbook-version rule33dba63 -- docs(changelog): capture 2026-10-02 handbook/6/ fix in UnreleasedNEEDINFO:TEOSLABEL.buildbreadcrumbs.100 on every teos rewritten URL (2026-10-03)Symptom. Every <vhost>/<uri>/ page rendered a "You are here"
breadcrumb whose root crumb was the literal string
NEEDINFO:TEOSLABEL.buildbreadcrumbs.100 instead of the vhost's
TEOSLABEL (e.g. teos). Visible on every URL hitting
engine/router.php via the teos htaccess rewrites (clean-URL,
directory, .md). The handbook vhost (bbsengine.org) never showed
the bug. The middle crumbs rendered correctly; only the root label
was wrong. See CHANGELOG.md "[Unreleased]" entry
fix(php/util): env() walks REDIRECT_<key> so vhost .htaccess SetEnv survives the /engine/ rewrite for the full root-cause breakdown and
ROBUSTNESS_REVIEW.md Phase 9 / Finding 9.1 for the
robustness-review angle.
Root cause. mod_rewrite's internal redirect from /<uri> to
/engine/router.php?uri=<uri> re-evaluates per-directory config
for /engine/. The teos vhost's html/teos/.htaccess (where
SetEnv TEOSLABEL teos lives) is not in scope at /engine/,
and /engine/.htaccess does not exist on merlin. The original
TEOSLABEL=teos survives only as REDIRECT_TEOSLABEL=teos (Apache
adds a REDIRECT_<var> copy of each request env var on every
internal redirect). \bbsengine6\util\env() previously called
getenv() directly and saw false for TEOSLABEL, then fell
through to its tier-2 constant / default tier. The
NEEDINFO:TEOSLABEL.buildbreadcrumbs.100 sentinel was the default
land. TEOSURL and TEOSDIR rendered correctly only because
zoid6/php/zoid6config.php defines \config\TEOSURL and
\config\TEOSDIR constants as the tier-2 fallback; TEOSLABEL
had no constant fallback, so it landed on the sentinel. The
handbook vhost never showed the bug because
bbsengine6/www/org/htaccess-prod sits at the docroot with
RewriteBase /, so it governs /engine/ too -- both TEOSLABEL
and REDIRECT_TEOSLABEL arrive in the worker's env.
Fix.
php/util.php: new
\bbsengine6\util\getenv_redirect(string $key, int $maxhops = 3)
helper walks getenv($key) -> getenv('REDIRECT_'.$key) ->
getenv('REDIRECT_REDIRECT_'.$key) up to $maxhops hops,
treating empty strings as missing. env() now calls
getenv_redirect($key) instead of getenv($key). Order
unchanged: env -> constant -> default.php/util.php: vhost_label() now delegates its env tier to
env('TEOSLABEL') (one line) so the
bbsengine6\blurb\getlabel() path and router_buildBreadcrumbs()
read the same var through the same REDIRECT_ walk. This keeps
the two breadcrumb paths in lockstep on every vhost.AGENTS.md TEOSDIR resolution contract: new "SetEnv and the
rewrite boundary" sub-section documents the contract.
bbsengine6/php/test_router.php Test 6e pins the new env()
body shape; new Test 6e.1 pins the getenv_redirect() helper.Out of scope (intentionally not updated in this commit):
teos_dir(), teos_url(), catalog_root(), handbook_home(),
bootstrap()'s VHOSTDOCROOT read, and
smarty/function.teos.php's BBSENGINEROOT read each have their
own independent fallback paths (zoid6config.php constants,
SCRIPT_FILENAME probe in vhostconfig.php, dirname(__DIR__)
walk) and were not visibly broken on prod. If any of those starts
surfacing a NEEDINFO on a rewritten request, route its reader
through getenv_redirect() in a follow-up.
Regression guard. teos/www/test_htaccess_setenv_reaches_php.sh
spins up a mock httpd on 127.0.0.1, copies the production htaccess
files verbatim, and exercises the rewrite chain into
/engine/router.php for both vhosts. Asserts
\bbsengine6\util\env() returns the htaccess value on every
rewritten request. SAPI auto-detect: mod_php -> php-fpm +
mod_proxy_fcgi -> mod_cgi with a #!/usr/bin/php-cgi shebang
(matches prod's modfcgid env pass-through semantics; the harness
adds no spurious REDIRECT layer of its own). Before the fix the
harness reports env(TEOSLABEL) = '' on every teos rewritten
request; after, 42/42 pass.
Commits:
638c4ec -- fix(php/util): env() walks REDIRECT_<key> so vhost .htaccess SetEnv survives the /engine/ rewritee541c93e)25dd374 in the teos submodule)| Date | Change |
|---|---|
| 2026-10-03 | New \bbsengine6\util\getenv_redirect(string $key, int $maxhops = 3) walks getenv($key) -> getenv('REDIRECT_'.$key) -> getenv('REDIRECT_REDIRECT_'.$key) up to 3 hops, treating empty strings as missing. \bbsengine6\util\env() calls getenv_redirect($key) instead of getenv($key) so vhost .htaccess SetEnv values survive mod_rewrite's internal redirect from /<uri> to /engine/router.php?uri=<uri> (per-directory config is re-evaluated for /engine/ and the vhost htaccess may not be in scope there). \bbsengine6\util\vhost_label() delegates its env tier to env('TEOSLABEL') so bbsengine6\blurb\getlabel() and router_buildBreadcrumbs() route through the same REDIRECT_ walk. Fixes the NEEDINFO:TEOSLABEL.buildbreadcrumbs.100 root crumb on the teos vhost. Regression guard: teos/www/test_htaccess_setenv_reaches_php.sh. See "Recent fixes" above. |
| 2026-10-02 | HTTP entry-point's handbook prefix-strip is now unconditional (REQUEST_URI is canonical; $_GET['uri'] no longer gates the strip). Dispatch loop post-loop fallback normalizes ROUTER_RENDERED to '' (mirror of the in-loop mapping). www/org/htaccess-prod adds an explicit ^handbook/(\d+)/?$ rule before the catch-all. Fixes /handbook/<v>/ 404 and the literal ROUTER_RENDERED echo after styled 404 chrome. See "Recent fixes" above. |
| 2026-09-19 | pattern attribute on handler registry entries (skip handler on URI regex miss); drop error from registry (single source of truth via post-loop fallback); ROUTER_RENDERED sentinel replaces the implicit empty-string-as-success contract; ROUTER_STOP retired; breadcrumb root path hardcoded to 'teos' (matches blurb.php::buildbreadcrumbs); router_safe_path_web() wrapper extracted; engine/serve-tmpl.php::servePage() returns the new sentinel. |
| 2026-09-19 | Dead $reluri . '.md' probe dropped from router_handleMarkdown (HTTP entry-point strips .md once at the top of the script, so the branch was unreachable); router_handleIndex returns ROUTER_RENDERED after include($indexfile) instead of the retired ROUTER_STOP; php/test_router.php Tests 6a/6b pin the new contract. |
| 2026-07-29 | Directory listing skips editor backup / junk files (router_isIgnoredEntry) and dedupes case-variant filenames (router_dedupeItems). Fixes the /rec/arts/tv/mash/ regression. |
| 2026-06-15 | Security fixes + modernization (path traversal, functional style, namespace imports, redirect helper). |
| 2025-06-15 | Router moved to /engine/router.php for clean URL support. The web root previously held /router.php with a shim. |
bbsengine6/engine/router.php is the source of truth.make engine from bbsengine6/ to stage + deploy.make prod from zoid6/sites/www/ to deploy the
www-site htaccess with the router rewrite rule.mod_rewrite and mod_php are enabled./srv/www/vhosts/zoidtechnologies.com/html/engine/router.php
is the live file (post-deploy).folder.tmpl template (ships with
bbsengine6/skin/tmpl/ since 2026-09-17).pytest test_zoidtechnologies_comp.py (lives at the meta-repo root) and the manual smoke list below.# Browse to a known blurb
curl -fsSL https://zoidtechnologies.com/rec/arts/tv/mash/ | head
# Browse to a known folder
curl -fsSL https://zoidtechnologies.com/rec/ | head
# 404 is a non-empty body
curl -sS -o /dev/null -w '%{http_code}\n' \
https://zoidtechnologies.com/rec/rec/arts/
# Static site prefix is NOT routed to engine/router.php
curl -fsSL https://zoidtechnologies.com/achilles/ | head
# Handbook version root renders index.md (not 404, no literal ROUTER_RENDERED)
curl -sS -w '%{http_code}\n' -o /tmp/hb6.html https://bbsengine.org/handbook/6/
grep -q ROUTER_RENDERED /tmp/hb6.html && echo 'FAIL: literal ROUTER_RENDERED in body' || echo 'ok: no literal'
If any of these return empty bodies, the router is regressed --
re-run the regression tests in test_zoidtechnologies_comp.py
(meta-repo root).
../ROBUSTNESS_REVIEW.md -- Phase 3
covers PHP web layer hardening (cookie attributes, CSRF, safe
paths, log redaction). Phase 5 has the regression test list.../SPEC.md -- engine/router.php's
place in the PHP web layer.