Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
4b5fedf
add REST v1 for external PAT clients
maxijabase Aug 31, 2026
b4ab131
add REST bans and comms for PAT clients
maxijabase Aug 31, 2026
2651568
add REST servers notes and mods for PAT clients
maxijabase Aug 31, 2026
c01557f
add REST protests comments and settings
maxijabase Aug 31, 2026
af42af9
harden REST v1 after the security audit
maxijabase Aug 31, 2026
ea648fd
isolate REST CSRF skip from the phpunit process
maxijabase Sep 1, 2026
5757ec9
fix(rest): skip panel cookie JWT under SBPP_REST
maxijabase Sep 3, 2026
e0f99e1
fix(rest): log REST admin create and update
maxijabase Sep 3, 2026
a36172b
fix(rest): 404 anonymous comm comments when comms are off
maxijabase Sep 3, 2026
790fefd
fix(rest): prune stale rate-limit files
maxijabase Sep 3, 2026
d01e8e8
fix(account): require password to mint PAT, revoke on password change
maxijabase Sep 3, 2026
7a9651e
fix(rest): return insert ids from mods.add and comms.add
maxijabase Sep 3, 2026
38ce132
docs(rest-api): split PATH_INFO in the nginx snippet
maxijabase Sep 3, 2026
af38b22
fix(rest): capture mods.add insert id before the audit log
maxijabase Sep 3, 2026
f4e773a
fix(rest): add Coerce helper and cheap contract nits
maxijabase Sep 5, 2026
2a65056
perf(rest): batch admin server_ids and server group_ids
maxijabase Sep 5, 2026
c0e86c4
fix(rest): return GET length in minutes
maxijabase Sep 5, 2026
fc7fc16
fix(rest): let only author or Owner edit comments
maxijabase Sep 5, 2026
7280a4b
feat(rest): archive and restore protests and submissions
maxijabase Sep 5, 2026
6527c40
fix(rest): paginate GET /groups
maxijabase Sep 5, 2026
8ed25f2
fix(rest): validate settings PATCH values
maxijabase Sep 5, 2026
3d3c80c
fix(account): live-insert PAT rows on Your Account
maxijabase Sep 5, 2026
4d084c3
docs(rest-api): document review polish and pin OpenAPI parity
maxijabase Sep 5, 2026
c70be24
fix(rest): unblock PAT table create and settings PATCH
maxijabase Sep 6, 2026
3028963
fix(rest): unify POST /comms and DELETE /comms shapes
maxijabase Sep 6, 2026
324362c
docs(rest-api): omit password on PUT /admins for Steam-only login
maxijabase Sep 6, 2026
6a8f2f5
fix(rest): fetch the new ban once on create
maxijabase Sep 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 74 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ codebase; this file is the cheatsheet.
## Stack at a glance

- `web/` — PHP 8.5 panel (Smarty 5, PDO/MariaDB, vanilla JS). Entry:
`web/index.php` (pages) and `web/api.php` (JSON API).
`web/index.php` (pages), `web/api.php` (panel JSON RPC), and
`web/api/v1.php` (external REST API, PAT auth).
- All classes in `web/includes/` live under `Sbpp\…` namespaces (e.g.
`Sbpp\Db\Database`, `Sbpp\Auth\UserManager`, `Sbpp\Log`,
`Sbpp\Api\Api`, `Sbpp\View\AdminTabs`). The legacy global names
Expand Down Expand Up @@ -45,6 +46,7 @@ code change — never as a follow-up. CI doesn't gate this; it's on you.
| Add/rename/remove a top-level subsystem in `web/includes/` | `ARCHITECTURE.md` (Web panel → Directory layout, and the relevant subsystem section) |
| Change a request lifecycle (page or JSON API) | `ARCHITECTURE.md` (the lifecycle section + any diagrams) |
| Add an API handler **topic file** (new file in `api/handlers/`) | `ARCHITECTURE.md` (handler list under "Handler registration") |
| Add or change a REST v1 route | `ARCHITECTURE.md` (REST API request lifecycle) + `web/api/openapi-v1.yaml` + `docs/src/content/docs/configuring/rest-api.mdx` |
| Add or rename a DB table, or change the schema substantively | `ARCHITECTURE.md` (Database schema table) + ensure `install/includes/sql/struc.sql` is the source of truth + paired `web/updater/data/<N>.php` registered in `store.json` |
| Add or change a row in `install/includes/sql/data.sql` (e.g. new `sb_settings` key) | Paired migration in `web/updater/data/<N>.php` + register in `web/updater/store.json` (see "Updater migrations") |
| Add or remove a quality gate / CI workflow | `ARCHITECTURE.md` (Quality gates) **and** `AGENTS.md` (Quality gates) |
Expand Down Expand Up @@ -438,6 +440,7 @@ matching its directory. PSR-4 autoloads from `web/includes/` →
| `Sbpp\Config` | settings cache |
| `Sbpp\Api\Api` | JSON API dispatcher |
| `Sbpp\Api\ApiError` | structured API error |
| `Sbpp\Rest\*` | REST `/api/v1` front controller, PAT auth, router, envelope |
| `Sbpp\View\AdminTabs` | edit-* Back-link mounter (empty tabs) |
| `Sbpp\View\AdminNavCatalog` | Pattern A section catalogs for the main-sidebar accordion (#1490) |
| `Sbpp\View\BrandLogo` | `template.logo` resolver with `is_file()` fallback to `images/favicon.svg` — single source for the navbar + login chrome brand-mark renders; rejects path-traversal + null-bytes + the v1.x default (case-insensitive); fail-closed on missing `SB_THEMES` |
Expand Down Expand Up @@ -950,6 +953,75 @@ of the diff ship together or not at all.
the browser-native popover surfaces the same error message
pre-flight.

### REST API v1

External clients (bots, website-next backends, scripts) use
`web/api/v1.php` (`/api/v1/…` with rewrite, `/api/v1.php/…` PATH_INFO
fallback). This is a **separate product** from `POST /api.php`.

- Do **not** replace or wrap `api.php`. Panel JS stays on RPC (cookie
JWT + CSRF). REST uses Personal Access Tokens
(`Authorization: Bearer sbpp_pat_…`). Cookie JWT must **not**
authenticate REST (CSRF trap in browsers).
- No CSRF on REST. Token lookup binds `$GLOBALS['userbank']` via
`UserManager(null, $aid)` so the cookie session is discarded.
- Tokens inherit the admin's web flags. No extra scopes. Soft-retired
(`enabled = 0`) → 401. Password `lockout_until` does not apply.
Minting (`account.tokens_create`) requires the current panel password
(same `bad_password` / field `current` as `account.change_password`).
Changing the panel password revokes every token for that admin.
- Writes reuse `Api::invoke()` where the RPC handler already exists
(deactivate/reactivate/remove/rehash, bans.add/unban, comms.add/
unblock/delete, servers.add/remove/send_rcon, notes.add/delete,
mods.add/remove, protests.remove, submissions.remove,
bans.add_comment/edit_comment/remove_comment). List/get, Steam64
upsert, PATCH `/servers`, and GET/PATCH `/settings` are dedicated
`Sbpp\Rest\*` queries. Discard `__redirect` / chrome envelopes.
- `{id}` on `/admins/{id}` is aid **or** a 17-digit Steam64 starting
with 7 that round-trips through Steam2 (universe IDs at or above
`76561197960265728`). Steam2/Steam3 in the path is 400. A 17-digit
string that converts to a negative Z is 400. PUT + Steam64 upserts
(create or update + reactivate). PUT + aid 404s if missing.
- After admin mutate, fire rehash server-side and put the result in
`meta.rehash`. Clients will forget.
- GET `/bans` and `/comms` are public. Hide IP / admin name using the
same `is_admin()` + `banlist.hide*` gate as `api_bans_detail`. Anonymous
GET `/comms` is 404 when `config.enablecomms` is off. A PAT still
reads. GET `/servers` is public for enabled hosts, with a trimmed A2S
`query`, and **never** returns `rcon`. Anonymous GET omits `group_ids`
and ignores `enabled=`. A well-formed PAT that fails to resolve is
401. Cookie JWT never authenticates REST (would leak IPs on public GET).
- `web/api/v1.php` defines `SBPP_REST` before `init.php`. `CSRF::init()`
no-ops so REST does not start a PHP session. `Auth::verify()` also
no-ops so REST does not read `sbpp_auth` or slide
`:prefix_login_tokens`. After PAT bind,
`Log::init` is rebound to the PAT (or anonymous) userbank.
- POST `/bans` and `/comms` `length` is minutes (0 = permanent). GET
`length` is also minutes. `ends` is unix seconds. Optional `kick: true`
on POST `/bans` fans RCON (`meta.kick`). Unban/unblock require
non-empty `ureason`.
- POST `/servers/{sid}/rcon` requires SourceMod RCON or Root **and**
per-server mapping. GET `/notes` requires any web admin and
`?steam=`. DELETE `/notes/{nid}` is author or Owner.
- GET `/protests` and `/submissions` require the matching queue flags
and return reporter `email` / `ip`. POST `/{id}/archive` and
`/{id}/restore` reuse `*.remove` with `archiv=1` / `archiv=2`. DELETE
is hard-delete (`archiv=0`). GET comments on a ban or comm is
public and empty when `config.enablepubliccomments` is off (admins
still see them). Anonymous GET `/comms/{cid}/comments` is 404 when
`config.enablecomms` is off (a PAT still reads), matching GET
`/comms`. PATCH `/comments/{id}` is author or Owner (same gate as
`bans.edit_comment`). DELETE `/comments/{id}` is Owner. GET/PATCH
`/settings` never returns or writes `smtp.pass` or
`telemetry.instance_id`.
- OpenAPI (`web/api/openapi-v1.yaml`) lands in the **same PR** as the
route. Operator docs: `docs/src/content/docs/configuring/rest-api.mdx`.
- Forbidden GET fields match `EntityExporter` (`password`, `validate`,
`attempts`, `lockout_until`, `srv_password`, `servers.rcon`,
`smtp.pass`, `telemetry.instance_id`). Steam64 in JSON is a string.
- Registry: `Sbpp\Rest\Routes::all()`. Adding a write route requires a
row in `RestPermissionMatrixTest`.

### CSRF

- Required on every state-changing form/JSON call.
Expand Down Expand Up @@ -4838,6 +4910,7 @@ the spec, target a 1920px viewport, not 1440px.
| Edit a docs page or add a new one (the Astro + Starlight site published at sbpp.github.io) | `docs/src/content/docs/<group>/<slug>.md` (or `.mdx` when the page uses tabs / cards / asides — e.g. `getting-started/quickstart.mdx`, `setup/mariadb.mdx`). New pages also need a sidebar entry in `docs/astro.config.mjs` (the `sidebar:` array). Site config + theme tokens live in `docs/astro.config.mjs` + `docs/src/styles/sbpp.css`. The Starlight chrome ships from `@astrojs/starlight`; layout overrides land under `docs/src/components/` (see `ThemeProvider.astro` for the canonical override shape). Local dev: `cd docs && npm install && npm run dev`. CI gates: `.github/workflows/docs-build.yml` (per-PR build), `docs-deploy-trigger.yml` (no-op in this repo; no Pages sibling), `docs-screenshots.yml` (gated on the `affects-ui` label, runs `docs/scripts/capture.mjs`). Source of truth is the `docs/` tree. |
| Refresh installer / panel screenshots used in docs pages | `docs/scripts/capture.mjs` (Playwright; `npm run capture` in `docs/`). Output lands under `docs/src/assets/auto/{install,panel}/<stable-slug>.png` so docs pages keep referencing the same path across runs. CI does this automatically on PRs labelled `affects-ui`; locally run after `./sbpp.sh up`. STEAM_API_KEY is the all-zero dummy `00000000000000000000000000000000`. |
| Add a JSON action | `web/api/handlers/_register.php` + `web/api/handlers/<topic>.php` |
| Add or change a REST v1 route | `web/includes/Rest/Routes.php` + handler in `Sbpp\Rest\*`. Same-PR OpenAPI (`web/api/openapi-v1.yaml`) and a row in `web/tests/api/RestPermissionMatrixTest.php`. Operator docs: `docs/src/content/docs/configuring/rest-api.mdx`. Entry: `web/api/v1.php`. PAT mint UI: Your Account + `account.tokens_*` RPC. Do not authenticate REST with the panel cookie. |
| Soft-retire / hard-delete admins, keep ban+comm issuer names, or bulk-select on the admins list (#1509) | Soft-retire: `admins.enabled` + `admins.deactivate` / `admins.reactivate` in `web/api/handlers/admins.php` (Active/Inactive chips + dialogs in `page_admin_admins_list.tpl`). Hard delete still snapshots `bans.admin_name` / `comms.admin_name` before DELETE (migration `811.php`). Issuer display: `COALESCE(NULLIF(*.admin_name, ''), AD.user)` → template paints **Unknown**, never "deleted admin" on the Admin cell (comments still say "deleted admin" per #1500). Bulk: `admins.bulk` (`op` = `deactivate` \| `reactivate` \| `remove` \| `set_web_group` \| `set_srv_group`, partial `applied`/`skipped`) + checkbox column / sticky bar in `page_admin_admins_list.tpl`. Guards: no self on deactivate/remove; owners skipped. Tests: `AdminsTest` + `AdminEnabledAttributionTest` + `admin-deactivate-bulk.spec.ts`. |
| Add or audit a publicly-reachable, unauthenticated auth surface (anything in `web/api/handlers/auth.php` or sibling registered as `requireAuth: false`) without leaking per-account state | The reference shape is `api_auth_lost_password` + `_api_auth_lost_password_generic_response` in `web/api/handlers/auth.php` (#1456). All reachable branches MUST return the same envelope; operator-side toggles (e.g. `config.enablenormallogin`) MAY surface as a per-toggle error code because the value is the same for every caller. The pre-#1456 shape branched on `not_registered` / `mail_failed` and let an unauthenticated visitor enumerate registered admin emails one request at a time by reading the painted toast back. See "Public auth surfaces: response-shape uniformity" in Conventions for the full contract (audit-log discipline, DB-write gating, SMTP gating, the documented response-time residual risk) + the matching Anti-patterns entry. Regression guards: `web/tests/api/AuthTest.php::testLostPasswordResponseIsIdenticalForKnownAndUnknownEmail` (byte-for-byte wire assertion) + `web/tests/api/__snapshots__/auth/lost_password_generic.json` (locked envelope) + `web/tests/e2e/specs/flows/lostpassword-toast.spec.ts` (chrome-side parity: same painted toast for known + unknown emails). Sibling surfaces still subject to follow-up (documented under the convention): `api_auth_login` branches its `Api::redirect()` target on per-account state via `?m=…` flags. |
| Resolve / override the JSON-API endpoint URL the client-side `sb.api.call(...)` POSTs to | `web/scripts/api.js` (`resolveEndpoint()` — runs once at script-load, computes `new URL('../api.php', document.currentScript.src).href`). The script lives at `/scripts/api.js` regardless of which page loads it, so resolving `../api.php` against the script's own URL lands on the panel-root `/api.php` for top-level page renders, iframe-routed surfaces (`pages/admin.kickit.php` / `pages/admin.blockit.php`), AND subdir installs (`https://host/sourcebans/` → script at `…/scripts/api.js` → endpoint at `…/api.php`). The endpoint stays writable on `sb.api` so callers can swap it; do not edit the resolver to a bare `'./api.php'` literal — that's the pre-#1433 regression shape that 404s every iframe round-trip (`./api.php` resolves against the iframe's document URL `/pages/admin.kickit.php` → `/pages/api.php`, no such route). **Load via static `<script src="…">` only** — `document.currentScript` is `null` when the script is appended programmatically (`document.createElement('script')`, `<script>document.write(...)</script>`, async loaders, ES-module `import()`), and a null `currentScript` collapses `SCRIPT_SRC` to the empty string and silently falls back to the bare-relative `./api.php` — i.e. the exact pre-#1433 bug. The three static load sites in the default theme are `core/header.tpl` (top-level panel chrome → `./scripts/api.js`), `page_kickit.tpl`, and `page_blockit.tpl` (iframe surfaces → `../scripts/api.js`); a theme fork that wants to lazy-load needs its own paired endpoint resolver. Pinned by `web/tests/integration/ApiJsEndpointResolutionTest.php` (static) + `web/tests/e2e/specs/flows/kickit-iframe.spec.ts` (runtime). |
Expand Down
66 changes: 65 additions & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ plugins are stable and updated less often.
```
web/
├── index.php Page entry point
├── api.php JSON API entry point
├── api.php Panel JSON RPC entry point (cookie + CSRF)
├── 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
Expand All @@ -77,6 +77,8 @@ web/
├── getdemo.php Demo file download
├── api/handlers/ JSON API: one file per topic, _register.php wires them
├── api/openapi-v1.yaml REST v1 OpenAPI source of truth
├── api/v1.php REST v1 front controller (pretty URL /api/v1/… + PATH_INFO)
├── 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)
Expand All @@ -85,6 +87,7 @@ web/
│ ├── 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
│ ├── Rest/ Sbpp\Rest\* — REST /api/v1 (FrontController, Router, PatAuthenticator, RateLimiter, Envelope, AdminsService, BansService, CommsService, ServersService, NotesService, ModsService, ProtestsService, SubmissionsService, CommentsService, SettingsService, Kicker)
│ ├── 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
Expand Down Expand Up @@ -302,6 +305,66 @@ 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.

### REST API request lifecycle

```
Bearer PAT ┌──────────────┐ ┌─────────────────────┐ ┌─────────────┐
GET /api/v1/… -> │ api/v1.php │ -> │ FrontController │ -> │ Routes.php │
└──────────────┘ │ PatAuthenticator │ └──────┬──────┘
│ RateLimiter │ v
└─────────────────────┘ writes: Api::invoke
lists: Rest queries
```

1. `api/v1.php` defines `SBPP_REST` before including `init.php` so
`CSRF::init()` does not start a PHP session and `Auth::verify()`
does not read `sbpp_auth` or slide `:prefix_login_tokens`, then
calls `FrontController::dispatch()`.
2. The controller **replaces** `$GLOBALS['userbank']` with the PAT
identity or an anonymous `UserManager(null)`, then rebinds
`Log::init` to that userbank. The panel cookie is never read.
3. Rate limit (file under `SB_CACHE/rest-rl/`, 60 req/min). Authenticated
by token id, anonymous by IP.
4. A well-formed `sbpp_pat_…` that does not resolve is 401 on every route,
including public GET. Missing or junk Authorization stays anonymous.
5. `Router` matches method + path from `Routes::all()`. Writes that
already exist as RPC handlers go through `Api::invoke()`. List/get
and Steam64 upsert are dedicated queries.
6. Envelope `{data, meta}` / `{error: {code, message, field?}}`. HTTP
status is load-bearing.

Slice 0: `/me`, `/admins/{id}` (aid or Steam64), deactivate / reactivate,
`/groups`, `/system/rehash`. PATs are minted on Your Account via
`account.tokens_*` (that UI is panel RPC, not REST). Minting requires
the current password; changing it revokes every token.

Slice 1: `/bans`, `/bans/{bid}`, POST unban; `/comms`, `/comms/{cid}`,
POST unblock, DELETE. GET list/get is public and applies the same hide-*
as the panel. Anonymous GET `/comms` is 404 when `config.enablecomms` is
off (a PAT still reads). Writes require a PAT. POST `/bans` and GET
`length` are minutes. `ends` is unix seconds. Optional `kick: true` fans
RCON via `kickit.kick_player` and records `meta.kick`.

Slice 2: `/servers` (public GET of enabled hosts with A2S `query`, never
`rcon`, no `group_ids` for anonymous; PAT may filter `enabled=` and sees
`group_ids`; POST / PATCH / DELETE; POST `/{sid}/rcon`), `/notes` (GET
`?steam=`, POST, DELETE; any web admin), `/mods` (GET / POST / DELETE).
Writes reuse `servers.add` / `servers.remove` / `servers.send_rcon`,
`notes.add` / `notes.delete`, `mods.add` / `mods.remove`. PATCH
`/servers` is dedicated (no RPC handler).

Slice 3: `/protests` and `/submissions` (GET list/get, POST archive /
restore via `protests.remove` / `submissions.remove` with `archiv=1` /
`archiv=2`, DELETE hard-delete with `archiv=0`), nested
comments on `/bans/{bid}/comments` and `/comms/{cid}/comments` (public GET
honours `config.enablepubliccomments` and `banlist.hideadminname`;
anonymous GET of comm comments is 404 when `config.enablecomms` is off,
matching `/comms`; POST /
PATCH reuse `bans.add_comment` / `bans.edit_comment`, and PATCH is author
or Owner on both REST and the RPC handler; DELETE is Owner via
`bans.remove_comment`), `/settings` GET+PATCH (dedicated; never
`smtp.pass` or `telemetry.instance_id`).

### Auth (`includes/Auth/` — `Sbpp\Auth\*`)

- `Sbpp\Auth\Auth::login(aid, maxlife)` mints a JWT and stores it in
Expand Down Expand Up @@ -978,6 +1041,7 @@ in dev/CI). Major tables:
| `sb_groups` | Web admin groups (permission bitmasks). |
| `sb_srvgroups` | SourceMod admin groups (char flags). |
| `sb_admins_servers_groups` | Admin × server × group mapping. |
| `sb_api_tokens` | REST PAT hashes (SHA-256). Never plaintext. |
| `sb_servers` / `sb_servers_groups` | Game servers + server-group membership. |
| `sb_bans` | The bans themselves (+ `admin_name` issuer snapshot). |
| `sb_comms` | Mutes / gags / blocks (+ `admin_name` issuer snapshot). |
Expand Down
3 changes: 3 additions & 0 deletions docker/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ RUN apt-get update \
&& a2enmod rewrite \
&& rm -rf /var/lib/apt/lists/*

COPY docker/apache/sbpp-dev-rewrite.conf /etc/apache2/conf-available/sbpp-dev-rewrite.conf
RUN a2enconf sbpp-dev-rewrite

# opcache is bundled into PHP 8.5 core (no longer a separate ext module);
# `docker-php-ext-install opcache` fails on the 8.5 image with
# "cp: cannot stat 'modules/*'" because there's no .so to copy. The
Expand Down
Loading
Loading