A tour of the codebase for new contributors (human or LLM). Pair this with
AGENTS.md (workflow + conventions) and
docker/README.md (local dev stack).
Maintainers: this file describes the codebase as it stands. When you change the architecture — new subsystem, new request flow, schema change, removed legacy pattern — update the relevant section in the same PR. See
AGENTS.md→ "Keep the docs in sync" for the trigger-by-trigger checklist.
SourceBans++ is a Source-engine admin/ban/comms management system. It has two halves that are deployed separately:
- Web panel (
web/) — a PHP 8.5 + MariaDB application that admins use in a browser to manage bans, server admins, groups, etc. It also serves the public ban list and a JSON API consumed by its own client-side JS. - SourceMod plugins (
game/addons/sourcemod/) —.spplugins that game servers load to enforce bans, gags, mutes, etc. They talk to the same MariaDB the web panel uses.
The web panel is the primary surface area for day-to-day development; the plugins are stable and updated less often.
.
├── web/ PHP web panel (panel + JSON API + tests)
├── game/addons/ SourceMod plugin sources (.sp / configs / translations)
├── docker/ Local dev stack (Dockerfile, db-init, php config)
├── docker-compose.yml web + db (MariaDB) + adminer + mailpit
├── sbpp.sh Wrapper for the dev stack and quality gates
├── .github/workflows/ CI gates (phpstan, test, ts-check, api-contract, release)
├── README.md Landing page — short, links to docs / AGENTS / ARCHITECTURE
├── ARCHITECTURE.md This file — codebase overview
├── AGENTS.md Conventions for AI agents / contributors
├── CHANGELOG.md Release notes
├── docs/ Starlight docs site (install / upgrade / configure)
├── LICENSE.txt Elastic License 2.0 (web panel)
├── LICENSE-plugins.txt GPLv3 (SourceMod plugins)
├── CLA.md Contributor License Agreement (web/** PRs)
└── THIRD-PARTY-NOTICES.txt
- PHP 8.5 with
pdo,pdo_mysql,intl,mbstring,openssl,sodium. Composer manages dependencies intoweb/includes/vendor/(note the non-defaultvendor-dir, set incomposer.json). - MariaDB 10.11 in dev (MySQL 5.6+ supported in production).
- Smarty 5 for server-side templates.
- lcobucci/jwt for the auth cookie.
- symfony/mailer for outbound email.
- league/commonmark for safely rendering admin-authored Markdown
(dashboard intro text — see
Sbpp\Markup\IntroRenderer). - xpaw/php-source-query-class for live server queries.
- maxmind-db/reader for IP→country lookups (
web/data/GeoLite2-Country.mmdb). - Vanilla JavaScript on the client — no framework, no bundler. Files
carry
// @ts-checkand are type-checked withtsc --checkJs.
web/
├── index.php Page entry point
├── api.php JSON API entry point
├── init.php Bootstrap (constants, autoload, DB, Auth, CSRF, Smarty)
├── config.php DB credentials etc. (generated; ignored by git)
├── config.php.template Template the installer + dev entrypoint render
├── exportbans.php Public ban-list export (CSV/XML)
├── export.php Full data export (owner-only ZIP bundle; see includes/Export/)
├── getdemo.php Demo file download
│
├── api/handlers/ JSON API: one file per topic, _register.php wires them
├── pages/ Page handlers (procedural .php, included by build())
│ └── core/ header / navbar / title / footer chrome
├── includes/ Library code (PSR-4 Sbpp\ at this prefix; #1290 phase B)
│ ├── Db/Database.php Sbpp\Db\Database — PDO wrapper + :prefix_ substitution
│ ├── Config.php Sbpp\Config — DB-backed settings key/value cache
│ ├── Log.php Sbpp\Log — audit + error log (writes to sb_log)
│ ├── Api/Api.php Sbpp\Api\Api — JSON dispatcher
│ ├── Api/ApiError.php Sbpp\Api\ApiError — structured API error
│ ├── Auth/UserManager.php Sbpp\Auth\UserManager (was CUserManager) — current admin + perms
│ ├── Auth/Auth.php Sbpp\Auth\Auth — login flow / cookie issue
│ ├── Auth/JWT.php Sbpp\Auth\JWT — token encode/decode
│ ├── Auth/Host.php Sbpp\Auth\Host — hostname helper
│ ├── Auth/Handler/ Sbpp\Auth\Handler\{Normal,Steam}AuthHandler — login handlers
│ ├── Auth/openid.php LightOpenID — third-party, intentionally global ns
│ ├── Security/CSRF.php Sbpp\Security\CSRF — token helpers
│ ├── Security/Crypto.php Sbpp\Security\Crypto — password / token crypto
│ ├── View/AdminTabs.php Sbpp\View\AdminTabs — Pattern A admin sub-section nav
│ ├── View/ Sbpp\View\* — typed Smarty view-model DTOs
│ ├── View/Install/ Sbpp\View\Install\* — install-wizard step DTOs (#1332)
│ ├── Markup/ Sbpp\Markup\IntroRenderer — admin Markdown -> safe HTML
│ ├── Theme/ Sbpp\Theme\ThemeConf — regex-read theme.conf.php metadata for the admin Themes picker (#1466)
│ ├── Servers/ Sbpp\Servers\{SourceQueryCache, RconStatusCache} — per-(ip, port) cache around the xPaw A2S probe (#1311) + per-sid cache around the RCON `status` command (#PLAYER_CTX_MENU)
│ ├── Upload/ Sbpp\Upload\UploadHandler — shared file-upload handler (perm + CSRF + extension allowlist + filename sanitiser + popup chrome) for the demo / icon / mapimage popup pages
│ ├── Mail/ Sbpp\Mail\{Mail,Mailer,EmailType} — Symfony Mailer wrapper + enum
│ ├── Telemetry/ Sbpp\Telemetry\{Telemetry,Schema1} — anonymous opt-out daily ping (#1126); schema-1.lock.json is the vendored cross-repo contract
│ ├── Announce/ Sbpp\Announce\{AnnouncementFetcher,Announcement} — anonymous opt-out daily fetch of project news / security advisories from https://sbpp.github.io/announcements.json (source of truth: docs/public/announcements.json); admin-only banner on the home dashboard
│ ├── Export/ Sbpp\Export\{ManifestBuilder,EntityExporter,BundleWriter,S3PresignedUploader,ExportError,Manifest} — owner-only full data export feature; streamed one-shot ZIP bundle of every row + every uploaded demo; entry point web/export.php (binary wire format, sits at panel-root next to exportbans.php / getdemo.php)
│ ├── SteamID/ SteamID parsing / vanity-URL resolution
│ ├── PHPStan/ Sbpp\PHPStan\* — custom PHPStan rules (Smarty + SQL prefix)
│ ├── page-builder.php route() + build() (the page router; procedural)
│ ├── system-functions.php Legacy helpers shared across pages (procedural)
│ ├── SmartyCustomFunctions.php {csrf_field} / {load_template} / {has_access}
│ └── vendor/ Composer artifacts (gitignored)
├── scripts/ Browser JS (// @ts-check + JSDoc, no bundler)
│ ├── sb.js DOM helpers + sb namespace
│ ├── api.js sb.api.call() — JSON client
│ ├── banlist.js Public ban-list interactions (filters, drawer)
│ ├── api-contract.js AUTOGEN: Actions.* + Perms.*
│ ├── globals.d.ts Ambient TS declarations
│ └── tsconfig.json
├── themes/default/ Smarty templates + CSS + images for the default theme
├── configs/permissions/ web.json + sourcemod.json — bitmask flag definitions
├── tests/ PHPUnit (api/ for handlers, integration/ for flows)
├── bin/ CLI tools (currently just generate-api-contract.php)
├── init-recovery.php Panel-runtime guard helpers + friendly error pages (#1335 C1 + M1)
├── install/ Install wizard self-hosters run on a fresh setup (#1332)
│ ├── index.php Entry point — paths-init → already-installed gate (#1335 C2) → vendor/-check (recovery short-circuit) → bootstrap → dispatch
│ ├── init.php Paths-only bootstrap (NEVER touches vendor/)
│ ├── bootstrap.php Composer + Smarty bootstrap (loaded only when vendor/ is present)
│ ├── recovery.php Self-contained "vendor/ missing" surface (pure inline HTML + CSS)
│ ├── already-installed.php Self-contained "panel already installed" guard (#1335 C2 — pure inline HTML + CSS)
│ ├── pages/page.<N>.php Per-step page handlers (1=license, …, 6=optional AMXBans import)
│ ├── includes/routing.php Step → page-handler dispatch
│ ├── includes/helpers.php Shared step-handler helpers (prefix validation, raw-PDO probe, KV escape, PDO error translation)
│ └── includes/sql/ struc.sql + data.sql — the schema source of truth
├── updater/ Per-version migrations existing installs run after upgrade
├── phpstan.neon PHPStan level 5 + custom rules + dba bootstrap
├── phpstan-baseline.neon Existing violations (regenerate only on real fixes)
├── phpunit.xml PHPUnit config (tests bootstrap from tests/bootstrap.php)
├── package.json Dev-only — pulls in typescript for the ts-check gate
└── composer.json vendor-dir set to includes/vendor
The panel has exactly two PHP entry points reachable from the browser:
| URL | Script | Purpose |
|---|---|---|
index.php?p=…&c=…&o=… |
index.php |
HTML pages (server-rendered) |
api.php (POST JSON) |
api.php |
JSON API (client-side fetch) |
Both scripts include init.php first, which performs identical bootstrap.
init.php does the following, in order:
- Defines path constants (
ROOT,INCLUDES_PATH,TEMPLATES_PATH, …) and theIN_SBsentinel that page files check. - Redirects to
/install/ifconfig.phpis missing (the wizard is the canonical fresh-install path; #1335 M1 replaced the bare-textdie()). Ifinstall/orupdater/are still on disk after a successful install/upgrade, refuses to boot viaweb/init-recovery.php'ssbpp_check_install_guard()— unconditional in production, with a single explicitSBPP_DEV_KEEP_INSTALLopt-in for the docker dev stack (#1335 C1; the loopback /HTTP_HOSTexemption was a panel-takeover path and is gone). - Loads Composer autoload (
includes/vendor/autoload.php). - Manually requires the auth + security + Database modules and
initialises them. The classes themselves ARE PSR-4 namespaced
(
Sbpp\Db\Database,Sbpp\Auth\UserManager,Sbpp\Security\CSRF, … — seeAGENTS.md"Namespacing"), but the explicitrequire_oncechain stays in place so each file'sclass_alias(\Sbpp\…\X::class, 'X')runs eagerly. Without that, procedural call sites that saynew Database()would trigger an autoload lookup for globalDatabase, find nothing (the autoloader resolves the namespaced name, not the alias), and die. The chain becomes optional only after the follow-up #1290 PR burns every legacy global-name call site. - Resolves the panel version via
Sbpp\Version::resolve()— three-tier fallback (release tarball'sconfigs/version.json→git describe→ the'dev'sentinel) anddefine()sSB_VERSION/SB_GITREVfrom the result. The chrome's<footer data-version="…">hook (web/themes/default/core/footer.tpl) mirrorsSB_VERSIONverbatim so telemetry and E2E specs can distinguish dev installs (data-version="dev") from release tarball installs without parsing the user-visible string (#1207 CC-5). Dev-checkout panels are identified bySB_VERSION === Version::DEV_SENTINEL; the footer's "| Git: " suffix gates onSB_GITREVdirectly so a separate boolean isn't needed (#1214). - Reads
configs/permissions/web.json+sourcemod.jsonanddefine()s each flag as a global PHP constant (ADMIN_OWNER,ADMIN_ADD_BAN, …). - Constructs the global
$theme(Smarty) with the configured theme dir, registers custom functions ({csrf_field},{load_template},{has_access}), and assignscsrf_token/csrf_field_nameso every rendered page has them available.
After init.php returns, callers may rely on these globals:
$GLOBALS['PDO'] (a Sbpp\Db\Database instance, also reachable as the
Database alias), $userbank (Sbpp\Auth\UserManager, also CUserManager
via alias), $theme (Smarty), the permission constants, and
SB_VERSION/SB_GITREV.
┌──────────────┐ ┌──────────┐ ┌──────────────────┐ ┌───────────────┐
│ index.php │ -> │ init.php │ -> │ route() / build()│ -> │ pages/*.php │
└──────────────┘ └──────────┘ └──────────────────┘ └───────────────┘
│
v
Smarty .tpl render
index.phpincludesinit.php, thensystem-functions.phpandpage-builder.php.route(default_page)reads?p=(page),?c=(category),?o=(option) from the query string and returns[title, page_php_file]. Admin pages also callCheckAdminAccess(flags)before returning. An unrecognised admin sub-route (e.g.?p=admin&c=overrides,?p=admin&c=bnas) returns['Page not found', '/page.404.php']and setshttp_response_code(404)so the chrome still renders around the error message but the HTTP status reflects reality (#1207 ADM-1). The bare admin landing (?p=adminwith noc=) still resolves to the admin home — only populated, unrecognisedc=values 404.build(title, page)includespages/core/header.php,pages/core/navbar.php,pages/core/title.php, then the page file, thenpages/core/footer.php.pages/core/title.phpruns before the page handler, so it can't read a$breadcrumbthe page handler will assign later. Instead it builds the default 2-segment "Home > $title" breadcrumb itself and dispatches by?p=…slug toSbpp\View\*View::breadcrumb()for routes whose audience makes the "Home" prefix misleading (currentlyloginandlostpassword, where logged-out visitors have no meaningful Home — #1207 AUTH-3). View DTOs that want to publish a non-default breadcrumb shape expose a staticbreadcrumb(): arrayreturning the same[ ['title' => ..., 'url' => ...] ]structurecore/title.tplconsumes.
- The page file (e.g.
pages/page.home.php) queries the DB and renders either:- Legacy: ad-hoc
$theme->assign(...)chains followed by$theme->display('foo.tpl'). - Preferred: a
Sbpp\View\*DTO passed toSbpp\View\Renderer::render($theme, $view)(see "View DTOs" below).
- Legacy: ad-hoc
POST forms hit index.php again with ?p=…. route() calls
CSRF::rejectIfInvalid() for any POST before dispatching, so every form
must include {csrf_field} in its template.
fetch /api.php ┌─────────────┐ ┌──────────────────┐ ┌───────────────┐
{action, params} -> │ api.php │ -> │ Api::dispatch() │ -> │ Api::invoke() │
{X-CSRF-Token} └─────────────┘ └──────────────────┘ └───────┬───────┘
v
api/handlers/*.php
v
pure fn(array): array
api.phpregisters a JSON-emitting exception handler + shutdown handler (so even fatal errors return{ok:false, error:{…}}with a 500 status), then includesinit.php, registers handlers viaApi::bootstrap(), and callsApi::dispatch().Api::dispatch()enforcesPOST, parses the JSON body into{action, params}, validates the CSRF token (headerX-CSRF-Tokenorparams.csrf_token), and callsApi::invoke().Api::invoke()looks up the registered handler. The dispatcher enforces the auth baseline:public=true→ anyone.requireAdmin=true→ must be a logged-in admin.perm != 0→ must hold the bitmask (web flags) or chars (SM flags) viaCUserManager::HasAccess().- Otherwise → must be logged in.
Permission failures get logged via
Log::add('w', 'Hacking Attempt', …).
- The handler is a pure
function(array $params): array. It can:- Return an array — becomes
{ok:true, data:{…}}. throw new ApiError($code, $msg, $field?, $httpStatus?)— becomes a structured{ok:false, error:{code, message, field?}}envelope.return Api::redirect($url)— becomes{ok:false, redirect:…}andsb.api.call()follows it client-side.
- Return an array — becomes
Every action lives in a single registry so the action-to-permission map is reviewable in one place:
Api::register('bans.add', 'api_bans_add', ADMIN_OWNER | ADMIN_ADD_BAN);
Api::register('account.change_email', 'api_account_change_email'); // logged-in only
Api::register('auth.login', 'api_auth_login', 0, false, true); // public
Api::register('admins.generate_password', 'api_admins_generate_password', 0, true); // any adminHandler functions live in topic-grouped files
(api/handlers/{account,admins,auth,bans,blockit,comms,groups,kickit,mods,notes,protests,servers,submissions,system}.php).
The notes topic was added with #1165 to back the player-detail
drawer's admin-only Notes tab; bans.player_history and
comms.player_history (live in their existing topic files) feed the
drawer's History and Comms tabs.
Sbpp\Auth\Auth::login(aid, maxlife)mints a JWT and stores it in thesbpp_authcookie (HttpOnly, SameSite=Lax).Auth::verify()returns the parsed token (ornull).Auth::logout()clears the cookie.- The token's only meaningful claim is
aid(admin id).Sbpp\Auth\UserManager(legacyCUserManageralias) reads the row fromsb_adminsand exposesis_logged_in(),is_admin(),HasAccess(flags), andGetProperty(name). - Two login back-ends:
Sbpp\Auth\Handler\NormalAuthHandler— username + bcrypt password, with attempt counter and 10-minute lockout after 5 failures (#1081 hardening).Sbpp\Auth\Handler\SteamAuthHandler— OpenID via the third-partyincludes/Auth/openid.php(LightOpenID, intentionally kept in the global namespace).
Sbpp\Auth\JWT::validate()rejects expired or tampered tokens.Auth::gc()garbage-collectssb_login_tokensrows older than 30 days.
Two parallel permission systems:
- Web flags (
configs/permissions/web.json) — 32-bit bitmask. Used by handler registrations andCheckAdminAccess(). Constants get defined globally ininit.php(ADMIN_OWNER,ADMIN_ADD_BAN, …). Mirrored to JS asPerms.*in the autogeneratedapi-contract.js. - SourceMod flags (
configs/permissions/sourcemod.json) — character string (e.g.'mz').Sbpp\Auth\UserManager::HasAccess()accepts either form;Sbpp\Api\Api::register()forwards whichever the registration declared.
ADMIN_OWNER (1<<24) is the implicit super-user bit; nearly every
registration ORs it in. ALL_WEB is the union mask used for is_admin().
CSRF::init()(called frominit.php) starts the session and lazily generates a 256-bit hex token bound to$_SESSION['csrf_token'].- Templates emit the hidden form input with
{csrf_field}. Api::dispatch()validates the token fromX-CSRF-Token(preferred) or the JSON body'scsrf_tokenfield.sb.api.call()reads the token from<meta name="csrf-token">and sets the header automatically.- Page POST handlers call
CSRF::rejectIfInvalid()(also invoked centrally byroute()).
A thin PDO wrapper. Two things to know:
-
All queries write
:prefix_literals (e.g.SELECT … FROM \:prefix_bans`) whichsetPrefix()rewrites to the configured prefix (sb` in dev/CI). Use this — never inline the prefix. -
The wrapper is a "prepare → bind → execute → fetch" chain:
$GLOBALS['PDO']->query("SELECT user FROM `:prefix_admins` WHERE aid = :aid"); $GLOBALS['PDO']->bind(':aid', $aid); $row = $GLOBALS['PDO']->single(); // or ->resultset() / ->execute()
-
The constructor sets
PDO::ATTR_EMULATE_PREPARES => false(added at #1124 / motivated by #1167'sLIMIT '0','30'MariaDB strict-mode regression). Two practical consequences for callers: numeric values go through MySQL's binary protocol with proper type metadata (LIMIT ?,?works as expected), AND every named placeholder occurrence is expanded into its own positional?slot in the prepared statement — so a query that mentions:sidtwice needs TWObind(':sid', …)calls (or distinct names like:sid+:sid_inner), otherwiseexecute()raisesSQLSTATE[HY093] Invalid parameter number. Pre-#1124 emulated prepares masked the duplicate-name pattern by client-side string substitution at every occurrence. The contract is pinned byweb/tests/integration/SrvAdminsPdoParamTest.php(the regression guard for #1314, wherepages/admin.srvadmins.phpreused:sidtwice and bound once — page-load-blocking fatal for every admin withADMIN_LIST_SERVERSafter upgrading to v2.0). The:prefix_placeholder is rewritten bysetPrefix()BEFOREprepare(), so:prefix_adminsreuse is harmless and stays out of this rule.
The legacy ADOdb layer was fully removed in commit b9c812b2; do not
reintroduce it. PHPStan + staabm/phpstan-dba introspect the live
schema (rendered from install/includes/sql/struc.sql) and type-check
every raw SQL string at analysis time.
The PDO DSN defaults to charset=utf8mb4 (MariaDB's 4-byte-safe
alias). init.php wires DB_CHARSET → the Database constructor →
mysql:…;charset=utf8mb4, which issues SET NAMES utf8mb4 on every
connection. That matches the SourceMod plugin (sbpp_comms.sp,
sbpp_main.sp) and the {charset} placeholder the installer renders
into struc.sql. The older utf8 alias is a 3-byte subset and will
reject supplementary-plane characters (emoji, some CJK), so do not
downgrade the default.
- Settings live in
sb_settingsas a flat key/value table. Config::init($PDO)loads them all into a static array on bootstrap.Config::get('config.theme'),Config::getBool(...),Config::time(ts).- The cache is process-local — re-read by tests via
Config::init()after truncating tables (seetests/Fixture.php).
Templates live in themes/<name>/*.tpl and are rendered through Smarty
5. The default theme is themes/default/ — a ground-up redesign that
shipped at v2.0.0 (#1123): drawer-based navigation, command palette
(Ctrl/Cmd-K), Markdown-rendered admin intro, accessibility-first form
controls, light/dark/system theming. Custom themes ship their own
theme.conf.php with theme_name / theme_author / theme_version /
theme_link / theme_screenshot.
The command palette (#palette-root <dialog> rendered by
themes/default/js/theme.js) is the only search affordance in the
chrome. The topbar carries an icon-only ghost button
(.topbar__search in core/title.tpl) that opens the same dialog as
the Meta+k keybinding — the pre-v2.0.0 inline search input was
dropped at #1207 CC-1 (mobile, slice 1) + CC-3 (desktop, slice 9)
because the labelled "search input + Ctrl K hint" was a duplicate
affordance for the same dialog. Player result rows in the palette
carry data-drawer-bid (bare Enter / click hands off to the existing
[data-drawer-bid] click delegate, which closes the palette and
opens the player drawer) and data-steamid (the Ctrl/Cmd+Enter
handler in theme.js's handlePaletteCopyShortcut reads it and
copies via navigator.clipboard.writeText + showToast). Keyboard
glyphs in the row's .palette__row-hints group are server-rendered
in non-Mac form (Enter, Ctrl); applyPlatformHints rewrites
[data-enterkey] → ⏎ and [data-modkey] → ⌘ on Mac/iOS clients at
boot and after every render so glyph swaps don't require re-fetching
results (#1184, #1207 DET-2).
The palette's "Navigate" entries are server-rendered + permission
filtered (#1304). Sbpp\View\PaletteActions::for($userbank) builds
the entry catalog the way web/pages/core/navbar.php builds the
sidebar: each entry carries a permission (int bitmask checked via
HasAccess with ADMIN_OWNER OR'd in, or true for public) and an
optional config key (a sb_settings boolean — same config.enable*
toggles the sidebar already honours). web/pages/core/footer.php
encodes the filtered list (with JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT so the content can never break out of
its <script> wrapper) and assigns it as $palette_actions_json;
core/footer.tpl emits it inside <script type="application/json" id="palette-actions" data-testid="palette-actions">. theme.js's
loadNavItems() reads + JSON.parses the blob at boot and uses it
in place of the pre-#1304 hardcoded NAV_ITEMS array (which leaked
admin links — Admin panel, Add ban — to logged-out and
partial-permission users). The player-search half (bans.search)
stays public; the leak was strictly the navigation entries.
The preferred way to render is via typed view-model DTOs:
use Sbpp\View\HomeDashboardView;
use Sbpp\View\Renderer;
Renderer::render($theme, new HomeDashboardView(
dashboard_text: (string) Config::get('dash.intro.text'),
total_bans: $total_bans,
// … every other variable the .tpl actually consumes …
));- One
Sbpp\View\*class per.tpl, keyed by itsTEMPLATEconstant. - All template variables are declared as public readonly constructor promoted properties.
Renderer::render()assigns every public property onto Smarty, then displays the template.SmartyTemplateRule(includes/PHPStan/SmartyTemplateRule.php) scans the.tplfor{$foo},{foreach from=$xs},{include file=…}, etc. references and reports:- View properties not referenced by the template (dead).
- Template variables without a matching property (typos).
Transitive
{include}s are resolved on disk; the outer view must declare the union of variables both templates use.
- Templates that use the non-default delimiter pair
-{ … }-(currentlypage_login.tpl,page_blockit.tpl,page_kickit.tpl, andpage_admin_servers_rcon.tpl) overrideView::DELIMITERSso the rule parses them correctly. Page handlers swapsetLeftDelimiter/setRightDelimiteraroundRenderer::render()so the chrome stays on the standard pair.page_youraccount.tplwas on this list before #1123 B20 rewrote it in standard{ }delimiters (rationale on theSbpp\View\YourAccountViewdocblock). - Permission gates inside templates are declared on the View as
can_*booleans.Sbpp\View\Perms::for($userbank)returns the fullarray<string, bool>map keyed by snake-case flag name (can_owner,can_add_ban,can_web_settings, …); page handlers pluck the keys the View declares rather than splatting the whole map. - Templates can also gate UI inline with
{has_access flags=ADMIN_OWNER|ADMIN_ADD_BAN}…{/has_access}(Phase A3); the block plugin reads the sameCUserManagerthe View was built with, so server-rendered UI and View permission checks always agree.
Pages that render multiple templates build one View per template and
call Renderer::render for each. The Sbpp\View\ namespace currently
covers ~30 templates: home dashboard, ban/comms lists, every admin
sub-tab (bans, comms, admins, groups, mods, servers, overrides,
settings, features, themes, logs), the audit log, the ban submission /
protest forms, the login / your-account forms, the kickit / blockit
side-modals, the updater, and the upload-icon dialog.
Vanilla JS, classic <script> tags, no bundler. The whole tree carries
// @ts-check and is checked by tsc --noEmit --checkJs in CI (#1098).
| File | Role |
|---|---|
sb.js |
DOM helpers (sb.$id, sb.$qs, sb.$idRequired), sb.message, tabs, accordion, tooltips. Also defines a global $ shim used by inline page-tail scripts (replaces the few MooTools idioms legacy code expects). |
api.js |
sb.api.call(action, params) — POSTs JSON to /api.php with X-CSRF-Token, returns the typed envelope, follows redirects. sb.api.callOrAlert() shows an sb.message.error() on failure. |
banlist.js |
Layered enhancements for the public ban-list page (status-filter chips, comment-edit form via JSON API). The row's SteamID copy button is wired by theme.js's document-level [data-copy] delegate alongside every other copy affordance on the panel. |
server-tile-hydrate.js |
Per-tile A2S hydration shared by the public servers list (page_servers.tpl), the admin Server Management list (page_admin_servers_list.tpl, #1313), and the dashboard Servers widget (page_dashboard.tpl, #1375). Auto-runs on first paint for every container marked data-server-hydrate="auto"; walks [data-testid="server-tile"] children and fires Actions.ServersHostPlayers per tile, patching the live cells (status pill / map / players / hostname / players bar / refresh button). Feature-detects every optional element so the same helper covers all three surfaces without per-page branching — the dashboard widget ships only the hostname slot and the helper no-ops the rest. |
contextMenoo.js |
Right-click context menu (deliberately misspelled). |
api-contract.js |
Autogenerated. Actions.* + Perms.* constants. |
globals.d.ts |
Ambient TS declarations for the sb namespace. |
tsconfig.json |
target: ES2020, strict: true, allowJs: true, checkJs: true. |
The pre-v2.0.0 bulk file (sourcebans.js, ~1.7k lines of MooTools-flavoured
helpers — ShowBox, DoLogin, LoadServerHost, selectLengthTypeReason,
…) was dropped at v2.0.0 (#1123 D1). Pages that need a per-form helper
inline a self-contained vanilla version (see web/pages/admin.edit.ban.php
and web/pages/admin.edit.comms.php for canonical examples); the chrome's
toast surface is now window.SBPP.showToast from the theme JS.
Type contracts:
SbAnyElis intentionally permissive (every form-element member is REQUIRED, even on a<div>) so legacy code type-checks without a per-site cast. New code should preferdocument.querySelector<HTMLInputElement>(...).sb.$id(id)returnsSbAnyEl | nulland must be narrowed.sb.$idRequired(id)throws on missing — use it where a missing element is a programmer error.
MooTools, React, and any runtime bundler have all been removed and must not come back: self-hosters install by unzipping the release tarball.
The browser used to hand-duplicate every action name and perm constant. That made silent drift easy. Now:
web/bin/generate-api-contract.phpreads_register.php,api/handlers/*.php(for@param/@returntypedefs), andconfigs/permissions/web.json, and emits a deterministic, sorted JS file.- The output is committed to git like a lockfile so release tarballs ship it; self-hosters never run codegen.
- CI (
.github/workflows/api-contract.yml) regenerates on a clean checkout and fails ongit diff. Regenerate locally with./sbpp.sh composer api-contractwhenever a handler name, perm mask, or@param/@returnchanges.
In JS code: always reference actions and perms by symbol —
sb.api.call(Actions.AdminsRemove, …) and Perms.ADMIN_ADD_BAN —
never raw strings.
Sbpp\Mail\Mail::send($to, EmailType::PasswordReset, ['{link}' => …])
wraps symfony/mailer. SMTP creds come from the smtp.* keys in
sb_settings (smtp.host / smtp.user / smtp.pass / smtp.port /
smtp.verify_peer); the sender identity comes from
config.mail.from_email + config.mail.from_name (#1109), with the
legacy SB_EMAIL constant in config.php as a fallback that emits a
once-per-process deprecation warning to sb_log. Mailer::resolveFrom()
formats the chosen pair into "Name" <email> for Symfony's Email::from().
Email templates live in themes/<name>/mails/*.html.
Sbpp\Markup\IntroRenderer::renderIntroText($markdown) wraps
league/commonmark for any DB-stored display text that admins type into
the panel and we then render to other users. Currently used only by the
dashboard dash.intro.text setting; the convention is "if a panel form
saves rich text into sb_settings and a template will render it to
arbitrary visitors, the value goes through IntroRenderer first."
The converter is configured with:
html_input: 'escape'— inline HTML is rendered as visible escaped text, not parsed. So a<script>an admin pastes shows up literally in the dashboard, it does not execute. We deliberately don't use'strip'so admins notice when they pasted HTML by accident.allow_unsafe_links: false—javascript:,data:,vbscript:hrefs are stripped during rendering, so a Markdown link can't be turned into an XSS vector either.max_nesting_level: 50— belt-and-braces against pathological inputs blowing the parser stack.
The converter is constructed lazily and cached as a private static,
so configuration cost is paid once per request. Call sites pass the
rendered HTML (not the raw Markdown) into the View DTO; the
template emits with nofilter and a Smarty comment pointing back at
the renderer (see web/themes/default/page_dashboard.tpl).
IntroRenderer is also reachable from the JSON API as
system.preview_intro_text (#1207 SET-1). The settings page uses it to
power the live Markdown preview pane next to the dash.intro.text
textarea: the textarea is the source of truth, and on input (200ms
debounce) the JS handler POSTs the current value, receives the rendered
HTML back, and patches the preview pane in place. The first paint
comes from PHP via the AdminSettingsView::$config_dash_text_preview
field, so the page works without JS too. The preview pane runs the
same IntroRenderer the public dashboard runs, so what the admin
sees in the preview is what visitors see — never wire up a third-party
JS Markdown renderer in its place; that would diverge from the
safe-on-render contract.
Issue #1113 is the audit that introduced this: dash.intro.text used
to render straight DB HTML through {$dashboard_text nofilter},
making any admin with ADMIN_SETTINGS a stored-XSS source. The
companion changes:
- A paired updater migration (
web/updater/data/804.php) replaces only the legacy default value (<center><p>Your new SourceBans install</p>…) with the new Markdown default; admins who customised the value keep their text unchanged, but now rendered as escaped text — acceptable degradation for a security fix. - The settings UI swapped the TinyMCE WYSIWYG for a plain
<textarea>with a Markdown cheat-sheet link; the staticweb/includes/tinymce/bundle is no longer referenced and its directory is a follow-up cleanup.
Sbpp\Servers\SourceQueryCache::fetch($ip, $port, $ttl=30) is a thin
on-disk cache around xPaw\SourceQuery\SourceQuery — the UDP A2S
probe used by the public servers page (?p=servers) and any
third-party theme that still emits the legacy __sbppLoadServerHost
helper from page.servers.php. Returns either the cached
{info, players} payload or null when the underlying probe failed;
both states are persisted under SB_CACHE/srvquery/<sha1(ip:port)>.json
so an unreachable server costs ONE A2S probe per ~30s window instead
of one per request.
Keyed by (ip, port) rather than by sid — multiple
:prefix_servers rows pointing at the same game server share a slot,
and the cache stays user-agnostic. Per-caller fields (is_owner,
can_ban, the per-call trunchostname truncation) are stamped on
top by the handler after the fetch returns. Atomic writes use the
same tempfile + rename() shape as _api_system_release_save_cache
in system.php so two concurrent FPM workers never read a
half-written entry.
Issue #1311 is the audit that introduced this: every public servers
page hit, plus every per-tile Re-query button click (anonymous-callable
through servers.host_players / servers.host_property /
servers.host_players_list / servers.players, public=true in
_register.php), used to fan out one fresh xPaw\SourceQuery::Connect
GetInfo+GetPlayersUDP round-trip per configured server. A hand-mash of the refresh button orfor i in $(seq 1 100); do curl ?p=servers; donetranslated 1:1 to A2S queries leaving the panel host. The handlers now go throughSourceQueryCache::fetch(...)so back-to-back panel hits coalesce at the cache boundary; the matching client-side debounce on the public servers page (page_servers.tpl'sloadTile()flipstile.__sbppLoading+ the Re-query button'sdisabledattr while a probe is in flight) closes the UX vector.
Sbpp\Servers\RconStatusCache::fetch($sid, $ttl=30) is the sibling
cache for the RCON status round-trip — needed because A2S
GetPlayers does NOT carry SteamIDs. Same on-disk shape
(SB_CACHE/srvstatus/<sha1(sid)>.json, tempfile + rename atomic
writes, success and failure both cached for ~30s), keyed by sid
instead of (ip, port) because the cache value depends on the
server's stored RCON password and only the panel's :prefix_servers
row carries it. The cache exists to feed the restored right-click
context menu on player rows (#PLAYER_CTX_MENU); api_servers_host_players
attaches the per-player steamid field by matching A2S-reported
names against the RCON-reported status output, ONLY when the
caller holds WebPermission::Owner | WebPermission::AddBan AND has
per-server RCON access via _api_servers_admin_can_rcon. The handler
itself is the load-bearing permission gate; the cache stays
user-agnostic.
The cache probes via rcon($cmd, $sid, silent: true) rather than the
default audit-logging shape (the third parameter on the global
rcon() helper in web/includes/system-functions.php defaults to
false everywhere else — only the cache opts in). Without the
silent flag every page hit on ?p=servers from an admin would emit
a "RCON Sent" entry per server, drowning out the legitimate
RCON-from-the-RCON-panel entries the audit log exists to surface.
Log::add('m', 'Topic', 'Detail') writes a row to sb_log with the
current admin's id, IP, and a severity char (m info, w warning,
e error). The dispatcher's "Hacking Attempt" warnings and the
set_error_handler shim in init.php both go through here.
Sbpp\Telemetry\Telemetry::tickIfDue() is registered as a
register_shutdown_function at the tail of init.php, so every panel
- JSON API request runs the tick after the response has been built. The tick is the issue #1126 contract:
- Opt-out gate.
Config::getBool('telemetry.enabled')is the first read; afalsereturns immediately, so the disabled path does no DB work past the cached settings hitinit.phpalready paid for. - Cooldown check.
now - last_ping < 24h ± 1h jitterreturns early. Jitter prevents panels behind the same NAT from synchronising and DDoS-ing the Worker on the hour boundary. - Atomic slot reservation. A single
UPDATE :prefix_settings SET value = :now WHERE setting = 'telemetry.last_ping' AND CAST(value AS UNSIGNED) <= :thresholdeither matches one row (we win the race) or zero (someone else already claimed this 24h window).rowCount() === 1is the gate — equivalent technique toMailer::resolveFrom()'s warn-once lazy lock. The slot is reserved at the START of the attempt, not after success, so a flapping endpoint costs one ping/day, not one ping/request. - Response handoff.
fastcgi_finish_request()closes the user's TCP socket before the network call when the SAPI is FPM; non-FPM falls back toob_end_flush + flush. - POST payload. cURL with 3s connect / 5s total timeouts,
User-Agent: SourceBans++/<version> (telemetry), no redirects, any non-2xx silent. The endpoint comes from:prefix_settings.telemetry.endpointso operators can repoint to a self-hosted collector or set it to''to disable network calls without flipping the user-facing toggle.
The whole tickIfDue body is wrapped in try { … } catch (\Throwable)
so telemetry can NEVER hard-fail the request. Pings are never
audit-logged either — a flapping endpoint would otherwise generate
sb_log noise that scares admins. Only the enable/disable
transitions are audit-logged (in
web/pages/admin.settings.php's features POST handler).
Telemetry::collect() is the side-effect-free payload builder. The
exact wire format is captured in
web/includes/Telemetry/schema-1.lock.json — a Draft-7 JSON Schema
vendored byte-for-byte from the sbpp/cf-analytics
companion repo. Sbpp\Telemetry\Schema1::payloadFieldNames() reads
the lock file at request time and returns the recursively-flattened
leaf field set; this is the single source of truth that gates the
extractor parity test:
TelemetrySchemaParityTest—Telemetry::collect()'s output field set deep-equalsSchema1::payloadFieldNames()in BOTH directions. Adding a typed slot in cf-analytics → next sync → the panel build fails until a matching extractor lands.
The schema lock file is also the single source of truth for anyone
who wants the field-by-field breakdown — it is NOT mirrored into any
human-readable doc, and the previous README mirror + paired
TelemetryReadmeParityTest was removed because the duplication paid
for the drift risk it created.
The schema-lock-file vendoring + extractor-parity pair is the convention for this codebase — when another subsystem grows a similar cross-repo JSON contract, mirror this shape.
The instance_id is bin2hex(random_bytes(16)), lazily generated
on the first call to collect() and persisted in
:prefix_settings.telemetry.instance_id. The Settings UI's Features
section wipes the row to '' on opt-out (so a re-enable mints a
fresh ID and the Worker can't link the two states) — the panel never
re-uses an instance_id once the toggle has flipped to disabled and
back. panel.theme is reported as default only when the active
theme exists under web/themes/ (currently only default ships);
custom forks report custom so a community theme name like
gridiron-clan-2025 never leaves the panel.
Manual schema sync: make sync-telemetry-schema pulls the latest
lock file from
https://raw.githubusercontent.com/sbpp/cf-analytics/main/schema/1.lock.json
and overwrites the vendored copy. No scheduled auto-PR workflow in
v1 — the parity tests gate the result and the maintainer invokes
the make target when picking up cf-analytics changes.
Sbpp\Export\* packages the panel's "full data export" feature — a
synchronous, one-shot streamed ZIP bundle of every row in the
database plus every uploaded demo. Reachable to logged-in owners at
?p=admin&c=export; the actual streaming work lives at panel-root
in web/export.php because the wire format is binary
(Content-Type: application/zip) and doesn't fit the JSON API
dispatcher's Content-Type: application/json contract. Same shape
as the long-standing web/exportbans.php (public ban-list export)
and web/getdemo.php (single demo download).
The subsystem is five classes plus an entry point plus an admin page:
Sbpp\Export\ManifestBuilder— pre-flight pass.build()returns aManifestDTO withrow_countsper entity,demo_total_bytes(sum of on-disk demos referenced by:prefix_demos),estimated_bundle_bytes, the UUIDv4bundle_id, the cap thresholds + theexceeds_capflag (S3-mode semantics — see below), and thepii_policyblock (operator attestation —password_hashes: "never"is load-bearing and never reachable for relaxation;includes_chat_messages: falsebecause the panel schema doesn't carry chat messages anywhere). The cap surface is mode-conditional and lives in the entry point, not the builder: s3 mode caps atMAX_S3_PUT_BYTES - SAFETY_MARGIN_BYTES(5 GiB minus 64 MiB; structural to the S3 single-PUT object-size limit shared by AWS S3, Cloudflare R2, MinIO, Backblaze B2, Wasabi — multipart upload above 5 GiB is a fundamentally different flow than presigned single-PUT), and the entry point short-circuits withExportError(CAP_EXCEEDED)BEFORE launching the build when$manifest->exceeds_capis true on the s3 arm. Zip mode is uncapped — Zip64 is enabled by default in the v3.xZipStreamconstructor, and the consumer-compatibility argument that motivated the previous "No ZIP64" stance no longer holds in practice (every modern unzipper handles Zip64). Thecap_bytes/exceeds_capfield names are preserved on the manifest wire format (noFORMAT_VERSIONbump) but redefined to refer specifically to the S3 PUT cap.Sbpp\Export\EntityExporter— per-entity SELECT + JSONL emission. One public method per entity (admins,bans,comms,log,notes, …). UsesDatabase::iterate()so no entity is held in memory. Hard-codedFORBIDDEN_COLUMNSfilter at the SQLSELECT/WHERElayer:admins.password,admins.validate,admins.attempts,admins.lockout_until,servers.rcon, andsettingsrows with keysmtp.pass/telemetry.instance_idnever reach the JSONL regardless of caller. Per-row contracts:nullfor absent (never""), timestamps as unix-seconds ints, Steam64 as decimal strings (NOT JSON numbers — Steam64 exceedsNumber.MAX_SAFE_INTEGER), source PKs renamed toid. Per-entity derivations:comms.mute_kind(mute|gag|silence|unknownfrom:prefix_comms.type),bans.state(mirrorsBanListView's classifier),bans.demo_filename+demo_size_bytesvia LEFT JOIN against:prefix_demos,log.levelfromLogType. JSON encoder flags MUST includeJSON_INVALID_UTF8_SUBSTITUTE— player names on:prefix_bans.name/:prefix_comms.namecan carry malformed UTF-8 from the pre-#1108 / #765 Latin-1-on-utf8 truncation shape; without the flag the export 500s mid-stream on a hostile-historical row.Sbpp\Export\BundleWriter— orchestrates. TakesZipStream\ZipStream+ theManifest+ theEntityExporter+ a nullable?int $capBytes(the mode-conditional running budget —nullfrom the zip arm so the writer never throws, non-null from the s3 arm so the writer enforces as the compressed-byte total grows). Emitsmanifest.jsonFIRST viaaddFile()(the manifest-first contract — downstream consumers can read just the manifest by parsing the first central-directory entry), then iterates entity exporters in deterministic name order viaaddFileFromCallback(), then iterates demos viaaddFileFromPath(..., compressionMethod: CompressionMethod::STORE)(demo files are already compressed by the Source engine; re-deflating is a CPU tax for no gain). Tracks running compressed-byte count; when$capBytes !== null, aborts withExportError(CAP_EXCEEDED)if the running total exceeds the budget (defence-in-depth againstManifestBuilder's pre-flight estimate undershooting on the s3-mode arm). After each entity / demo in zip-mode ($flushAfterEntries = true), callsflush() + @ob_flush()to push bytes downstream so the browser's download progress bar moves in real time. S3 mode ($flushAfterEntries = false) skips the flush because the output is a tempfile, not a socket.Sbpp\Export\S3PresignedUploader— cURLPUTagainst the operator-supplied presigned URL. Scheme guard is unconditional:https://only,http://raisesExportError(PRESIGN_INVALID_SCHEME)before any network call — a panel-full dataset is in flight and cleartext transit is unsupported. URL parse failures raisePRESIGN_INVALID_URL. The cURL call usesCURLOPT_CUSTOMREQUEST = 'PUT'+CURLOPT_INFILE+CURLOPT_INFILESIZE+ explicitContent-Lengthheader (presigned PUT signatures bind the Content-Length value);CURLOPT_CONNECTTIMEOUT = 60,CURLOPT_TIMEOUT = 0(no overall ceiling — uploads can be slow). HTTP 200/201/204 are success; anything else raisesExportError(S3_PUT_FAILED)with the response body truncated to 2 KiB for diagnostics. Test override:_setHttpTransportForTests(?callable $transport)mirroringSbpp\Announce\AnnouncementFetcher::_setHttpFetcherForTestsso the integration tests can pin the wire-layer contract without spinning up a real S3 endpoint.Sbpp\Export\ExportError—final class extends \RuntimeExceptioncarrying the wire-facing code as apublic readonly string $errorCode(the property is renamed because the parent\Exception::$codeisint-typed at the language level and can't be narrowed to areadonly string); thecode()accessor is the fluent read shape call sites consume. The supported error codes are class constants (CAP_EXCEEDED,S3_PUT_FAILED,PRESIGN_INVALID_SCHEME,PRESIGN_INVALID_URL,DISK_WRITE_FAILED,DISK_FULL) so call sites can't typo a string literal. The entry point's redirect-failure helper maps each code to an operator-readable toast body via amatchtable inweb/pages/admin.export.php.
web/export.php is the top-level streaming entry point. Lifecycle:
REQUEST_METHOD === 'POST'gate — GET / HEAD return HTTP 405 with a plain-textAllow: POSTbody. The form posts; a direct GET is either a hostile probe or operator URL typing.init.phpbootstraps$userbank/CSRF/$GLOBALS['PDO']/SB_VERSION/SB_DEMOS/SB_CACHE.CSRF::rejectIfInvalid()— state-changing PII-class surface; a stale tab MUST NOT trigger an export.$userbank->HasAccess(WebPermission::Owner)re-check (the navbar / palette filter is UX gating; this is the load-bearing security gate that catches direct-curl POSTs by partial-permission admins). Failures land aLogType::Warningrow in the audit log so a triage flow can find them.- Shared-host hardening:
@set_time_limit(0),@ini_set('memory_limit', '256M'),ignore_user_abort(true). The@guards a host withdisable_functions = set_time_limit,ini_setfrom injecting warning text into the streamed response body. - Output buffer drain: walk every
ob_get_level()and end each soBundleWriter::write()'sflush() + @ob_flush()calls actually reach the wire. X-Accel-Buffering: no+ Apache'sapache_setenv('no-gzip', '1')so reverse proxies and Apache don't re-buffer the stream.- Mode dispatch:
zip→Content-Type: application/zip+Content-Disposition: attachment; filename="sbpp-export-<uuid>.zip"- cache-defeat headers; ZipStream writes to
php://output;BundleWriter::write()runs withflushAfterEntries=true.
- cache-defeat headers; ZipStream writes to
s3→ build toSB_CACHE/exports/<uuid>.zip(tempfile);register_shutdown_functionregisters an@unlink()cleanup BEFORE the build so a mid-build fatal still wipes the staging file;BundleWriter::write()runs withflushAfterEntries=false; thenS3PresignedUploader::upload()PUTs to the operator's URL; on success 302 to?p=admin&c=export&result=success&bid=<uuid>; onExportError302 to?result=error&code=<code>(the page handler reads the arm and emits a persistent error toast via\Sbpp\View\Toast::emitso the operator MUST acknowledge the destructive-operation failure before moving on).
- Every reachable terminal branch lands an audit-log entry —
LogType::Messagefor success (carriesaid,bundle_id, estimated + actual byte counts);LogType::Errorfor failure (carriesaid,bundle_id,ExportError::code()value plus the underlying message). The operator's "what happened to my export attempt" question is answerable from a single audit-log query.
The entry point catches only ExportError — anything else (a real
DB outage, a memory exhaustion, a regression in the writer)
propagates to the dispatcher's generic 500 so the stack trace lands
in the audit log via the project's error handler. Catching
\Throwable blanket would mask real bugs behind a generic "export
failed" toast.
The admin page handler is web/pages/admin.export.php →
Sbpp\View\AdminExportView →
web/themes/default/page_admin_export.tpl. The page handler runs
the pre-flight pass (counts only) and renders the form chrome with
two cards (ZIP download / S3 upload) plus a stats grid (admin / ban
/ comm / demo counts + estimated bundle size). When the pre-flight
detects exceeds_cap, only the S3 submit button is server-rendered
as disabled and an .empty-state block explains the 5 GiB S3
PUT limit + suggests the uncapped ZIP download as the alternative.
The ZIP download submit button stays enabled regardless of
exceeds_cap — direct ZIP download has no cap (Zip64 supports
arbitrary bundle sizes). The S3-mode cap is re-enforced at the
entry point's pre-flight pass too (a stale-tab POST hits the
gate), but the form's disabled state is the UX-first contract.
No schema change ships with this subsystem: there's no :prefix_exports
table, no scheduled jobs, no persistent state. V1 is deliberately
one-shot — every export starts from a fresh pre-flight pass. The
audit log carries the durable record per operator request.
Owner-only by design: every PII category in scope (admin emails, IP addresses, every Steam ID, every unban reason, every comment) means a partial-permission admin who can export everything is functionally an owner regardless. Granular delegation is deliberately deferred to a future version where downstream consumers have asked for it.
The schema source of truth is web/install/includes/sql/struc.sql (with
{prefix} and {charset} placeholders rendered to sb and utf8mb4
in dev/CI). Major tables:
| Table | Purpose |
|---|---|
sb_admins |
Web admins + bcrypt password + lockout state. |
sb_groups |
Web admin groups (permission bitmasks). |
sb_srvgroups |
SourceMod admin groups (char flags). |
sb_admins_servers_groups |
Admin × server × group mapping. |
sb_servers / sb_servers_groups |
Game servers + server-group membership. |
sb_bans |
The bans themselves. |
sb_comms |
Mutes / gags / blocks. |
sb_banlog |
Per-server enforcement events (dashboard). |
sb_comments |
Threaded comments on a ban. |
sb_demos |
Uploaded demo metadata. |
sb_protests |
Ban-appeal submissions. |
sb_submissions |
Public ban-report submissions. |
sb_overrides / sb_srvgroups_overrides |
SM command overrides. |
sb_mods |
Configured game mods (icons, names). |
sb_settings |
Flat key/value config used by Config. |
sb_log |
Audit log (see Log.php). |
sb_login_tokens |
JWT id (jti) → last-accessed for GC. |
sb_notes |
Per-Steam-ID admin scratchpad (Notes tab in the player drawer, #1165). |
Reseeded in tests via web/tests/Fixture.php, which renders struc.sql
data.sqlagainst a dedicatedsourcebans_testdatabase before every test method.
Five :prefix_* columns carry a small fixed set of values; PHP wraps
each with a backed enum so call sites read as intent rather than as
magic primitives:
| Enum | On-disk column | Backing | Cases |
|---|---|---|---|
LogType |
:prefix_log.type enum('m','w','e') |
string | Message='m' / Warning='w' / Error='e' |
LogSearchType |
the audit-log ?advType= query param tag (no DB column) |
string | Admin / Message / Date / Type (also carries WHERE-builder) |
BanType |
:prefix_bans.type tinyint |
int | Steam=0 / Ip=1 |
BanRemoval |
:prefix_bans.RemoveType varchar(3) / :prefix_comms.RemoveType varchar(3) |
string | Deleted='D' / Unbanned='U' / Expired='E' |
WebPermission |
:prefix_admins.extraflags int / :prefix_groups.flags int (bitmask) |
int | one case per web/configs/permissions/web.json flag (Owner=16777216, …) |
The on-disk schema stays as enum('m','w','e') / varchar(3) /
int / tinyint. The enum is the PHP-side typed wrapper. At every SQL bind
site, pass $enum->value (the column-typed primitive); the case
itself is for in-PHP type-safety only. phpstan/phpstan-dba types the
raw SQL against the live MariaDB schema, so a wrong-typed bind (e.g.
binding a BanType enum case directly instead of ->value) fails
the gate.
Files live in the global namespace under web/includes/
(LogType.php, LogSearchType.php, BanType.php, BanRemoval.php,
WebPermission.php); they're loaded by require_once in init.php
tests/bootstrap.phpahead ofLog.php/CUserManager.phpso the typed parameters compile.
WebPermission adds a mask(WebPermission ...$flags): int helper
for assembling multi-flag bitmasks; HasAccess() accepts
WebPermission|int|string so the modern enum form
(HasAccess(WebPermission::Owner)) and the legacy procedural form
(HasAccess(ADMIN_OWNER | ADMIN_ADD_BAN)) coexist. The ADMIN_*
defined constants in init.php stay as the back-compat surface
for procedural code that pre-dates the enum; the JS-side
Perms.ADMIN_* contract in web/scripts/api-contract.js is
unchanged.
The updater is how existing installs catch up to schema or data changes
that fresh installs receive via install/includes/sql/{struc,data}.sql.
Operators run it by visiting web/updater/index.php after dropping in a
new release.
Updater.php reads web/updater/store.json, a sorted map of integer
version keys to PHP file names:
{
"1": "1.php",
"...": "...",
"802": "802.php",
"803": "803.php"
}It selects config.version from sb_settings, then runs every script
whose key is greater than that value, in order, and bumps config.version
to the highest key on success. The keys are loose historical numbers, not
semver — pick the next integer above the current max when adding one.
Each script is a tiny PHP file require_once'd inside the Updater
instance scope, so $this->dbs (the Database wrapper) is in scope.
Migrations should:
- Use the
:prefix_placeholder, never a literal table prefix. - Be idempotent: prefer
INSERT IGNORE,CREATE TABLE IF NOT EXISTS, andALTER TABLEpaired with aSHOW COLUMNSguard. The runner has no rollback — partial state must be safe to re-run. - Mirror the corresponding seed in
web/install/includes/sql/data.sql(or DDL instruc.sql).data.sqlis consulted only on a fresh install; a row added there without a matching updater script will be missing on every upgraded install. The two are halves of the same change.
PHPStan can't see that $this is supplied by the loader, so each script
suppresses the false positive with // @phpstan-ignore variable.undefined
above each $this->dbs call. See 802.php (new sb_settings row) and
803.php (the config.mail.from_* rows for #1109) for the canonical
shape; 700.php shows a multi-row insert and 801.php shows DDL.
game/addons/sourcemod/
├── scripting/
│ ├── sbpp_main.sp Core ban/admin enforcement (loaded by every server)
│ ├── sbpp_admcfg.sp Admin auth-config writer (sm_addgroup, sm_addadmin)
│ ├── sbpp_checker.sp Auto-checker: blocks evading bans/comms
│ ├── sbpp_comms.sp Mute/gag enforcement
│ ├── sbpp_report.sp In-game !report → web submission
│ ├── sbpp_sleuth.sp Alt-account / shared-account detection
│ └── include/ Public natives (`sourcebanspp.inc`)
├── configs/ Plugin configs (cvars, defaults)
├── translations/ SourceMod translation files
└── plugins/ Empty in source — `.smx` lands here when compiled
Plugins talk to the same MariaDB the panel uses, write to sb_bans /
sb_comms directly, and consume sb_settings for runtime configuration.
Build with the standard SourceMod compiler — see the
SourceMod wiki.
Spelt out fully in docker/README.md. Quick mental
model:
docker-compose.ymlbrings up four services:web(PHP 8.5 + Apache, bind-mounting./web),db(MariaDB 10.11),adminer(DB UI), andmailpit(catch-all SMTP).docker/Dockerfilelayers the PHP extensions, OPcache config, and Composer ontophp:8.5-apache.docker/php/web-entrypoint.shwaits for MariaDB, rendersweb/config.phpfrom env vars (only if absent), runscomposer installifvendor/is empty, thenexecs Apache.docker/php/dev-prepend.phpdefinesSBPP_DEV_KEEP_INSTALLon every request soweb/init-recovery.php'ssbpp_check_install_guard()skips the install/ + updater/-presence refusal — the dev stack ships those directories on the bind mount by design (the wizard isn't exercised locally;docker/db-init/seeds the schema out of band). Pre-#1335 this file rewroteHTTP_HOSTtolocalhostto ride ainit.phpexemption that was a panel-takeover path in production; the explicit loud-named-define escape hatch replaced it.docker/db-init/00-render-schema.shruns once on first DB boot: substitutes{prefix}/{charset}instruc.sql+data.sql, loads them, and seeds anadminrow with bcrypt ofadmin.sbpp.shis a thin wrapper arounddocker composeplus the quality gates and DB tasks. Run./sbpp.sh -hfor the full menu.
The seeded admin password and the SBPP_DEV_KEEP_INSTALL constant are
dev-only and documented as such in docker-compose.yml.
Six CI jobs run on every PR (.github/workflows/):
| Gate | Workflow | Local command | What it covers |
|---|---|---|---|
| PHPStan | phpstan.yml |
./sbpp.sh phpstan |
Static analysis (level 5) + Smarty rule + phpstan-dba SQL checks. |
| PHPUnit | test.yml |
./sbpp.sh test |
Behavioural tests against sourcebans_test. |
| ts-check | ts-check.yml |
./sbpp.sh ts-check |
tsc --checkJs over web/scripts/. |
| API contract | api-contract.yml |
./sbpp.sh composer api-contract |
Regenerates scripts/api-contract.js and fails on diff. |
| Playwright E2E | e2e.yml |
./sbpp.sh e2e |
Browser-level smoke / flows / a11y against the dev stack (chromium + mobile-chromium). |
| Plugin build | plugin-build.yml |
(cd game/addons/sourcemod/scripting && spcomp -i include sbpp_*.sp) |
Compiles every top-level .sp plugin with spcomp (1.12.x, mirroring release.yml). Path-filtered to game/addons/sourcemod/scripting/** — only fires when a PR touches plugin sources. |
PHPStan is at level 5 (bumped from 4 in #1101); raise one step at a
time, never jump 5→7. The baseline at web/phpstan-baseline.neon
captures pre-existing violations; only regenerate it when a real fix
removes an entry or when bumping the level.
phpstan-dba (#1100) introspects the live MariaDB to type-check raw SQL
strings against the schema. Set PHPSTAN_DBA_DISABLE=1 to skip it; CI
sets DBA_REQUIRE=1 so credential drift fails loudly.
phpstan/phpstan-deprecation-rules (#1273) is wired in via
phpstan.neon with phpVersion: 80500 so the analyser flags the
PHP 8.1 null-into-scalar deprecation surface (strlen($null),
trim($null), substr($null, ...), preg_match($null, ...), …)
before it bites us on the PHP 9 bump. web/includes/Auth/openid.php
is excluded from PHPStan, so the same surface there is gated by the
runtime smoke test in web/tests/integration/Php82DeprecationsTest.php
(per-process set_error_handler that promotes E_DEPRECATED to a
thrown ErrorException while it requires each marquee page handler).
Tests live in web/tests/:
tests/
├── bootstrap.php Defines path/env/permission constants without config.php
├── Fixture.php Drops + re-creates sourcebans_test, seeds admin row
├── ApiTestCase.php Base class: setUp() truncates DB, $this->loginAs(aid),
│ $this->assertSnapshot() for wire-format snapshots
├── api/ Per-handler tests + the per-action permission-matrix lock
│ └── __snapshots__/ Checked-in JSON envelopes asserted byte-for-byte (#1112)
└── integration/ End-to-end flows (LoginFlowTest, BanFlowTest, …)
Fixture::install() runs once per test process; Fixture::reset()
truncates every table and re-seeds defaults between tests so each
setUp() starts identical to a fresh ./sbpp.sh up.
ApiTestCase::api(action, params) invokes a handler in-process through
Api::invoke() and returns the same envelope the dispatcher would
produce, so auth/permission checks are exercised exactly the way HTTP
requests would exercise them.
ApiTestCase::assertSnapshot(name, envelope, redact) (#1112) compares
the envelope against a checked-in JSON file under
tests/api/__snapshots__/<topic>/<scenario>.json. The file is the
contract between the panel and any custom theme / external integration:
shape changes have to be intentional and re-recorded with
UPDATE_SNAPSHOTS=1 ./sbpp.sh test. Dynamic values (autoincrement IDs,
the seeded admin's aid, RNG-derived passwords) are passed in as a
redact list of dot-paths and replaced with the literal <*> so the
rest of the shape still locks down.
web/tests/api/PermissionMatrixTest.php (#1112) pins every registered
action's (perm, requireAdmin, public) triple via PHPUnit dataProvider
rows. A new action without a matrix entry — or an existing action whose
gate moves — fails the build loudly. Api::actions() (added alongside)
exposes the registry's keys for the matrix sweep so the test can detect
both directions of drift (extra registrations, removed registrations).
Browser-level coverage on top of the unit + API gates. Lives under
web/tests/e2e/ with its own package.json so PHPUnit and Playwright
don't fight over a shared dependency surface.
web/tests/e2e/
├── package.json # @playwright/test, @axe-core/playwright, typescript
├── playwright.config.ts # baseURL, projects (chromium + mobile-chromium), reporters
├── tsconfig.json # strict, ES2020, bundler resolution
├── fixtures/
│ ├── auth.ts # single-import surface re-exporting test/expect (extended in later slices)
│ ├── axe.ts # expectNoCriticalA11y(page, testInfo, …)
│ ├── db.ts # resetE2eDb / truncateE2eDb helpers (host-side or in-container)
│ └── global-setup.ts # one-time: reset sourcebans_e2e + mint admin storageState
├── pages/ # Page Object Models (BasePage in _base.ts)
├── scripts/
│ ├── reset-e2e-db.php # bridge to Sbpp\Tests\Fixture pointed at sourcebans_e2e
│ └── upload-screenshots.sh # pushes per-PR PNGs to the screenshots-archive orphan branch
└── specs/
├── _screenshots.spec.ts # @screenshot gallery spec (skipped unless SCREENSHOTS=1)
├── smoke/ # one .spec.ts per route — login.spec.ts is the seed
├── flows/ # multi-step critical flows (added in later slices)
├── a11y/ # axe scans (added in later slices)
└── responsive/ # mobile-viewport behaviour (added in later slices)
Three properties drive the harness:
- DB isolation. The suite owns a dedicated
sourcebans_e2eschema, parallel tosourcebans_test.reset-e2e-db.phpreuses the sameSbpp\Tests\FixturePHPUnit uses (struc.sql + data.sql, same seeded admin) but pointed at the e2e DB, so a passing PHPUnit run guarantees the e2e fixture is structurally identical. Specs calltruncateE2eDb()between cases for the cheap reset path; full install only runs once perplaywright testinvocation. - Storage-state auth.
fixtures/global-setup.tsdrives the login form once against the seeded admin/admin user and writesplaywright/.auth/admin.json. Every spec inherits that storage state viaplaywright.config.tsso they don't pay the login cost per-test. The login spec is the only spec that opts back out (test.use({ storageState: { cookies: [], origins: [] } })) so the form itself stays exercised. - axe gate at
critical.expectNoCriticalA11yruns@axe-core/playwrightagainst the current page, attaches the full report to the failing test asaxe, and asserts zerocritical-impact violations. The threshold is locked here — seeAGENTS.md"Playwright E2E specifics" for why.
A separate _screenshots.spec.ts walks every route × theme × project
and emits PNGs into web/tests/e2e/screenshots/<theme>/<viewport>/.
The accompanying upload-screenshots.sh pushes the gallery to the
screenshots-archive orphan branch under screenshots/pr-<N>/<slug>/
(unique per PR + slice, so the orphan branch never has merge
conflicts) and prints a markdown table for gh pr comment.
When working in older code, you'll see things that are no longer the recommended pattern. Prefer the current pattern when adding new code, but don't bulk-rewrite legacy code without justification.
| Old | Current |
|---|---|
xajax callbacks (sb-callback.php) |
JSON API in api/handlers/*.php |
ADOdb ($db->Execute, RecordSet) |
Database PDO wrapper |
MooTools ($('id').addEvent(...)) |
sb.$id / sb.api.call + native addEventListener |
web/scripts/sourcebans.js (ShowBox, DoLogin, LoadServerHost, …) |
Removed at v2.0.0 (#1123 D1); inline self-contained helpers per page; window.SBPP.showToast for toasts |
Ad-hoc $theme->assign() chains |
Sbpp\View\* DTO + Renderer::render |
| String literals for action names | Actions.PascalName (from api-contract.js) |
install/ flow as a runtime concern |
DB seeded out-of-band; installer left for production users (modernized in #1332 — typed Sbpp\View\Install\*View DTOs + Smarty templates, no MooTools / wizard-local sourcebans.js) |
web/install/template/*.php procedural templates + web/install/scripts/sourcebans.js (MooTools-dependent ShowBox/$E/$() helpers, broken since #1123 D1) |
Removed at #1332. Wizard pages live as web/install/pages/page.<N>.php handlers + web/themes/default/install/page_<step>.tpl Smarty templates + Sbpp\View\Install\Install*View DTOs |
htmlspecialchars_decode on JSON params |
Store raw UTF-8; Smarty auto-escape handles display (#1108) |
DB_CHARSET = 'utf8' (3-byte alias) |
utf8mb4 end-to-end (panel PDO + plugin SET NAMES) (#1108) |
TinyMCE WYSIWYG for dash.intro.text |
Plain <textarea> + Sbpp\Markup\IntroRenderer (CommonMark, escape unsafe HTML) (#1113) |
init.php's HTTP_HOST != 'localhost' exemption on the install/ + updater/-presence guard |
Unconditional guard via web/init-recovery.php's sbpp_check_install_guard(); docker dev rides explicit SBPP_DEV_KEEP_INSTALL constant (#1335 C1) |
Bare-text die() in init.php for missing-config / install-still-present / autoload-missing paths |
Self-contained chrome via web/init-recovery.php's sbpp_render_install_blocked_page() (mirror of recovery.php's pure-inline-HTML contract) (#1335 M1) |
/install/ walkable on a panel where config.php already exists |
Wizard refuses to start via web/install/already-installed.php's 409 surface (#1335 C2) |