From 4b5fedfb6f7f8c3a1032c4355f2545704b6e9811 Mon Sep 17 00:00:00 2001 From: Maximiliano Jabase Date: Mon, 31 Aug 2026 17:01:29 -0300 Subject: [PATCH 01/27] add REST v1 for external PAT clients External bots and website-next need PAT-authenticated REST instead of the panel RPC. Slice 0 covers admins, groups, and rehash. --- AGENTS.md | 39 +- ARCHITECTURE.md | 34 +- docker/Dockerfile | 3 + docker/apache/sbpp-dev-rewrite.conf | 11 + docker/apache/sbpp-prod.conf | 11 + docs/astro.config.mjs | 1 + .../src/content/docs/configuring/rest-api.mdx | 160 +++++ web/api/handlers/_register.php | 3 + web/api/handlers/account.php | 59 ++ web/api/openapi-v1.yaml | 457 +++++++++++++ web/api/v1.php | 44 ++ web/config.php.template | 5 + web/includes/Auth/UserManager.php | 4 +- web/includes/Rest/AdminId.php | 63 ++ web/includes/Rest/AdminsService.php | 622 ++++++++++++++++++ web/includes/Rest/Envelope.php | 86 +++ web/includes/Rest/FrontController.php | 173 +++++ web/includes/Rest/GroupsService.php | 60 ++ web/includes/Rest/PatAuthenticator.php | 252 +++++++ web/includes/Rest/RateLimiter.php | 111 ++++ web/includes/Rest/Rehasher.php | 55 ++ web/includes/Rest/Response.php | 54 ++ web/includes/Rest/Router.php | 99 +++ web/includes/Rest/Routes.php | 285 ++++++++ web/includes/View/YourAccountView.php | 4 + web/install/includes/sql/struc.sql | 16 + web/pages/page.youraccount.php | 1 + web/scripts/api-contract.js | 25 +- web/tests/RestTestCase.php | 74 +++ web/tests/api/AccountTest.php | 53 ++ web/tests/api/PermissionMatrixTest.php | 3 + web/tests/api/RestAdminsTest.php | 179 +++++ web/tests/api/RestAuthTest.php | 107 +++ web/tests/api/RestPermissionMatrixTest.php | 64 ++ .../__snapshots__/account/tokens_create.json | 11 + .../views/youraccount_owner.json | 3 +- web/tests/bootstrap.php | 1 + web/tests/e2e/pages/admin/MyAccount.ts | 16 + web/tests/e2e/specs/flows/rest-api.spec.ts | 61 ++ web/tests/integration/YourAccountViewTest.php | 3 + web/themes/default/page_youraccount.tpl | 154 ++++- web/updater/data/812.php | 30 + web/updater/store.json | 3 +- 43 files changed, 3487 insertions(+), 12 deletions(-) create mode 100644 docker/apache/sbpp-dev-rewrite.conf create mode 100644 docs/src/content/docs/configuring/rest-api.mdx create mode 100644 web/api/openapi-v1.yaml create mode 100644 web/api/v1.php create mode 100644 web/includes/Rest/AdminId.php create mode 100644 web/includes/Rest/AdminsService.php create mode 100644 web/includes/Rest/Envelope.php create mode 100644 web/includes/Rest/FrontController.php create mode 100644 web/includes/Rest/GroupsService.php create mode 100644 web/includes/Rest/PatAuthenticator.php create mode 100644 web/includes/Rest/RateLimiter.php create mode 100644 web/includes/Rest/Rehasher.php create mode 100644 web/includes/Rest/Response.php create mode 100644 web/includes/Rest/Router.php create mode 100644 web/includes/Rest/Routes.php create mode 100644 web/tests/RestTestCase.php create mode 100644 web/tests/api/RestAdminsTest.php create mode 100644 web/tests/api/RestAuthTest.php create mode 100644 web/tests/api/RestPermissionMatrixTest.php create mode 100644 web/tests/api/__snapshots__/account/tokens_create.json create mode 100644 web/tests/e2e/specs/flows/rest-api.spec.ts create mode 100644 web/updater/data/812.php diff --git a/AGENTS.md b/AGENTS.md index 965142fad..598b69bba 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 @@ -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/.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/.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) | @@ -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` | @@ -950,6 +953,39 @@ 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. +- Writes reuse `Api::invoke()` where the RPC handler already exists + (deactivate/reactivate/remove/rehash). List/get and Steam64 upsert + 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. +- 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. @@ -4838,6 +4874,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//.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}/.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/.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 ``, 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). | diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e6a6943dc..e527325f5 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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 @@ -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) @@ -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) │ ├── 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 @@ -302,6 +305,34 @@ 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` registers a JSON exception handler, includes `init.php` + (no CSRF), and calls `FrontController::dispatch()`. +2. The controller **replaces** `$GLOBALS['userbank']` with the PAT + identity or an anonymous `UserManager(null)`. The panel cookie is + ignored. +3. Rate limit (file under `SB_CACHE/rest-rl/`, 60 req/min). Authenticated + by token id, anonymous by IP. +4. `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. +5. Envelope `{data, meta}` / `{error: {code, message, field?}}`. HTTP + status is load-bearing. + +Slice 0 resources: `/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). + ### Auth (`includes/Auth/` — `Sbpp\Auth\*`) - `Sbpp\Auth\Auth::login(aid, maxlife)` mints a JWT and stores it in @@ -978,6 +1009,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). | diff --git a/docker/Dockerfile b/docker/Dockerfile index ac7136980..284bf4dfa 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -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 diff --git a/docker/apache/sbpp-dev-rewrite.conf b/docker/apache/sbpp-dev-rewrite.conf new file mode 100644 index 000000000..997a770d8 --- /dev/null +++ b/docker/apache/sbpp-dev-rewrite.conf @@ -0,0 +1,11 @@ +# Pretty URLs for the REST API in the local dev image. +# Loaded as /etc/apache2/conf-available/sbpp-dev-rewrite.conf via a2enconf. +# PATH_INFO fallback `/api/v1.php/me` works without this file. + + + RewriteEngine On + CGIPassAuth On + RewriteRule ^ - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] + RewriteRule ^api/v1$ api/v1.php [QSA,L] + RewriteRule ^api/v1/(.*)$ api/v1.php/$1 [QSA,L] + diff --git a/docker/apache/sbpp-prod.conf b/docker/apache/sbpp-prod.conf index 70b655be3..7a8c232e3 100644 --- a/docker/apache/sbpp-prod.conf +++ b/docker/apache/sbpp-prod.conf @@ -19,6 +19,11 @@ Options -Indexes -MultiViews +FollowSymLinks AllowOverride None Require all granted + RewriteEngine On + CGIPassAuth On + RewriteRule ^ - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] + RewriteRule ^api/v1$ api/v1.php [QSA,L] + RewriteRule ^api/v1/(.*)$ api/v1.php/$1 [QSA,L] # Block dotfiles globally — `.env`, `.git/*`, `.htaccess`, `.DS_Store`. @@ -101,3 +106,9 @@ # so the entrypoint's per-deploy conf only has to add `RemoteIPInternalProxy` # lines (the header name is project-wide constant). RemoteIPHeader X-Forwarded-For + +# JSON RPC handlers are PHP includes, not public URLs. Path-anchored +# deny so we do not collide with published assets (see #1419). + + Require all denied + diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index c7ba4e6cb..4cbace54c 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -145,6 +145,7 @@ export default defineConfig({ items: [ { label: 'Project announcements', slug: 'configuring/announcements' }, { label: 'Full data export', slug: 'configuring/data-export' }, + { label: 'REST API', slug: 'configuring/rest-api' }, ], }, { diff --git a/docs/src/content/docs/configuring/rest-api.mdx b/docs/src/content/docs/configuring/rest-api.mdx new file mode 100644 index 000000000..d30508708 --- /dev/null +++ b/docs/src/content/docs/configuring/rest-api.mdx @@ -0,0 +1,160 @@ +--- +title: REST API +description: Personal Access Tokens and the versioned /api/v1 HTTP API for bots, scripts, and website backends. +sidebar: + order: 3 + label: REST API +--- + +import { Aside, Code, Tabs, TabItem } from '@astrojs/starlight/components'; + +The panel ships a versioned REST API at `/api/v1` for **external** clients. +Mint a Personal Access Token on **Your account**, then send it as +`Authorization: Bearer sbpp_pat_…`. + +The in-panel JavaScript does **not** use this API. It keeps talking to +`POST /api.php` with the session cookie and a CSRF token. + + + +## Create a token + +1. Sign in to the panel. +2. Open **Your account**. +3. Under **API tokens**, give the token a name (for example `website-next`) + and an expiry (or Never). +4. Copy the secret. It is shown once. + +The secret looks like `sbpp_pat_` plus 64 hex characters. The panel stores +only a SHA-256 hash. Revoke the token from the same card if it leaks. + +The token inherits that admin's web flags. There are no extra scopes. A +read-only bot is an admin with list flags, not a trimmed token. + +Soft-retired admins (`enabled = 0`) cannot use a token. Password lockout +does not apply. Revoke is the kill switch. + +## Call the API + +Pretty URLs (production Docker image, and the dev image after rebuild): + + + +PATH_INFO fallback (always works, including tarball installs without rewrite): + + + + + + +```bash +TOKEN='sbpp_pat_…' + +curl -sS -H "Authorization: Bearer $TOKEN" \ + https://bans.example.com/api/v1.php/me +``` + + + + +```bash +TOKEN='sbpp_pat_…' +STEAM64='76561198000000000' + +curl -sS -X PUT \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"name":"Moderator","web_group_id":2,"server_group_id":1}' \ + "https://bans.example.com/api/v1.php/admins/$STEAM64" +``` + + + + +Success bodies are `{ "data": …, "meta": … }`. Errors are +`{ "error": { "code", "message", "field"? } }`. HTTP status is real +(200, 201, 400, 401, 403, 404, 409, 429, 500), unlike `POST /api.php` +which usually returns 200. + +Steam64 values in JSON are **strings**, not numbers. In `/admins/{id}` +the `{id}` is a numeric aid or a 17-digit community ID (typically +`7656119…`). Steam2 and Steam3 in the URL return 400. + +The OpenAPI document is at `GET /api/v1/openapi.yaml` (public). + +## website-next (staff hub) + +Use this API to grant or revoke in-game admin from your site. Discord +roles stay in your bot. SourceBans does not know about Discord. + +1. Create an Owner (or Add/Edit/Delete Admins) account in the panel. +2. Mint a PAT on that account. +3. Store the PAT in the website-next **backend** environment. +4. `PUT /admins/{steam64}` to create or update. A missing row is created. + An inactive row is reactivated. +5. `POST /admins/{steam64}/deactivate` to demote. That is a soft retire, + not a hard delete, so ban history still shows the name. +6. Create/update/deactivate already run `sm_rehash` when + `config.enableadminrehashing` is on. You can also `POST /system/rehash`. + +`GET /groups` returns web groups and SourceMod groups so you can map +"Moderator" to a `gid`. Group create/edit is not in this version. Make +the groups in the panel first. + +CORS is off by default. A backend-to-panel call does not need it. To +allow a browser origin, set this in `config.php`: + +```php +define('SB_REST_CORS_ORIGINS', 'https://staff.example.com'); +``` + +## Rate limit + +Default 60 requests per minute. Anonymous callers (only `GET /openapi.yaml` +in this version) are keyed by IP. Token callers are keyed by token. +A 429 includes `Retry-After`. + +## Slice 0 routes + +| Method | Path | Notes | +| --- | --- | --- | +| GET | `/me` | Caller admin | +| GET | `/admins` | Paginated list (`page`, `per_page`, cap 100) | +| GET | `/admins/{id}` | `{id}` is aid or Steam64 | +| PUT | `/admins/{id}` | Steam64 upserts. aid 404s if missing | +| PATCH | `/admins/{id}` | Merge. Never creates | +| POST | `/admins/{id}/deactivate` | Soft retire | +| POST | `/admins/{id}/reactivate` | Restore | +| DELETE | `/admins/{id}` | Hard delete | +| GET | `/groups` | Web + SourceMod groups | +| POST | `/system/rehash` | Optional `{ "sids": [1,2] }` | +| GET | `/openapi.yaml` | This spec | + +Bans, comms, servers, notes, mods, protests, and settings are later +slices. They are not in this version. + +## nginx snippet + +If you terminate TLS on nginx and proxy to Apache/PHP, you can also +rewrite in nginx: + +```nginx +location /api/v1 { + rewrite ^/api/v1$ /api/v1.php last; + rewrite ^/api/v1/(.*)$ /api/v1.php/$1 last; +} +``` + +Pass `Authorization` through to PHP. Some Apache/PHP builds leave +`$_SERVER['HTTP_AUTHORIZATION']` empty. The panel also reads the header +via `getallheaders()`. For nginx + PHP-FPM: + +```nginx +fastcgi_param HTTP_AUTHORIZATION $http_authorization; +``` + +Tarball installs without rewrite should use `/api/v1.php/…`. diff --git a/web/api/handlers/_register.php b/web/api/handlers/_register.php index 7a5abf728..6763987e5 100644 --- a/web/api/handlers/_register.php +++ b/web/api/handlers/_register.php @@ -54,6 +54,9 @@ Api::register('account.check_srv_password', 'api_account_check_srv_password'); Api::register('account.change_srv_password','api_account_change_srv_password'); Api::register('account.change_email', 'api_account_change_email'); +Api::register('account.tokens_list', 'api_account_tokens_list'); +Api::register('account.tokens_create', 'api_account_tokens_create'); +Api::register('account.tokens_revoke', 'api_account_tokens_revoke'); // ---- admins ----------------------------------------------------------- Api::register('admins.add', 'api_admins_add', ADMIN_OWNER | ADMIN_ADD_ADMINS); diff --git a/web/api/handlers/account.php b/web/api/handlers/account.php index 44acc64cf..2d9806ee0 100644 --- a/web/api/handlers/account.php +++ b/web/api/handlers/account.php @@ -152,3 +152,62 @@ function api_account_change_email(array $params): array ], ]; } + +/** + * @return array{tokens: list} + */ +function api_account_tokens_list(array $params): array +{ + global $userbank; + return ['tokens' => \Sbpp\Rest\PatAuthenticator::listForAid($userbank->GetAid())]; +} + +/** + * @param array{name?: string, expires_days?: int|string|null} $params + * @return array{id: int, name: string, token: string, token_prefix: string, created: int, expires_at: int|null} + */ +function api_account_tokens_create(array $params): array +{ + global $userbank; + $name = trim((string) ($params['name'] ?? '')); + if ($name === '' || strlen($name) > 64) { + throw new ApiError('validation', 'Give this token a name (1 to 64 characters).', 'name'); + } + + $daysRaw = $params['expires_days'] ?? 0; + $days = is_numeric($daysRaw) ? (int) $daysRaw : -1; + if (!in_array($days, [0, 30, 90, 365], true)) { + throw new ApiError('validation', 'Expiry must be never, 30, 90, or 365 days.', 'expires_days'); + } + $expiresAt = $days === 0 ? null : time() + ($days * 86400); + + $minted = \Sbpp\Rest\PatAuthenticator::mint($userbank->GetAid(), $name, $expiresAt); + Log::add(LogType::Message, 'API token created', 'API token "' . $name . '" created.'); + + return [ + 'id' => $minted['id'], + 'name' => $minted['name'], + 'token' => $minted['secret'], + 'token_prefix' => $minted['prefix'], + 'created' => $minted['created'], + 'expires_at' => $minted['expires_at'], + ]; +} + +/** + * @param array{id?: int|string} $params + * @return array{revoked: int} + */ +function api_account_tokens_revoke(array $params): array +{ + global $userbank; + $id = (int) ($params['id'] ?? 0); + if ($id <= 0) { + throw new ApiError('validation', 'Token id is required.', 'id'); + } + if (!\Sbpp\Rest\PatAuthenticator::revoke($userbank->GetAid(), $id)) { + throw new ApiError('not_found', 'Token not found.'); + } + Log::add(LogType::Message, 'API token revoked', 'API token #' . $id . ' revoked.'); + return ['revoked' => $id]; +} diff --git a/web/api/openapi-v1.yaml b/web/api/openapi-v1.yaml new file mode 100644 index 000000000..fdfe01543 --- /dev/null +++ b/web/api/openapi-v1.yaml @@ -0,0 +1,457 @@ +openapi: 3.0.3 +info: + title: SourceBans++ REST API + version: 1.0.0 + description: > + Versioned HTTP API for external clients (bots, website-next, scripts). + Authenticate with a Personal Access Token. The panel JavaScript continues + to use POST /api.php (cookie JWT + CSRF) and is not a client of this API. +servers: + - url: /api/v1 + description: Pretty URL (Apache rewrite) + - url: /api/v1.php + description: PATH_INFO fallback +security: + - bearerAuth: [] +tags: + - name: me + - name: admins + - name: groups + - name: system + - name: meta +paths: + /openapi.yaml: + get: + tags: [meta] + security: [] + summary: OpenAPI 3 document for this API + responses: + "200": + description: YAML spec + content: + application/yaml: + schema: + type: string + /me: + get: + tags: [me] + summary: The admin bound to this token + responses: + "200": + description: Admin resource + content: + application/json: + schema: + $ref: "#/components/schemas/AdminEnvelope" + "401": + $ref: "#/components/responses/Unauthorized" + /admins: + get: + tags: [admins] + summary: List admins + parameters: + - $ref: "#/components/parameters/page" + - $ref: "#/components/parameters/perPage" + responses: + "200": + description: Paginated admin list + content: + application/json: + schema: + $ref: "#/components/schemas/AdminListEnvelope" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /admins/{id}: + parameters: + - $ref: "#/components/parameters/adminId" + get: + tags: [admins] + summary: Get one admin by aid or Steam64 + responses: + "200": + description: Admin resource + content: + application/json: + schema: + $ref: "#/components/schemas/AdminEnvelope" + "400": + $ref: "#/components/responses/Error" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + put: + tags: [admins] + summary: Create or replace an admin + description: > + `{id}` as Steam64 upserts (201 create, 200 update, reactivates if + enabled=0). `{id}` as aid replaces an existing row (404 if missing). + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/AdminWrite" + responses: + "200": + description: Updated + content: + application/json: + schema: + $ref: "#/components/schemas/AdminEnvelope" + "201": + description: Created + content: + application/json: + schema: + $ref: "#/components/schemas/AdminEnvelope" + "400": + $ref: "#/components/responses/Error" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "409": + $ref: "#/components/responses/Error" + patch: + tags: [admins] + summary: Merge-update an existing admin (never creates) + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/AdminWrite" + responses: + "200": + description: Updated + content: + application/json: + schema: + $ref: "#/components/schemas/AdminEnvelope" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + delete: + tags: [admins] + summary: Hard-delete an admin + requestBody: + content: + application/json: + schema: + type: object + properties: + reason: + type: string + responses: + "200": + description: Deleted + "400": + $ref: "#/components/responses/Error" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "409": + $ref: "#/components/responses/Error" + /admins/{id}/deactivate: + parameters: + - $ref: "#/components/parameters/adminId" + post: + tags: [admins] + summary: Soft-retire an admin (enabled=0) + requestBody: + content: + application/json: + schema: + type: object + properties: + reason: + type: string + responses: + "200": + description: Deactivated + content: + application/json: + schema: + $ref: "#/components/schemas/AdminEnvelope" + "400": + $ref: "#/components/responses/Error" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "409": + $ref: "#/components/responses/Error" + /admins/{id}/reactivate: + parameters: + - $ref: "#/components/parameters/adminId" + post: + tags: [admins] + summary: Restore a soft-retired admin + requestBody: + content: + application/json: + schema: + type: object + properties: + reason: + type: string + responses: + "200": + description: Reactivated + content: + application/json: + schema: + $ref: "#/components/schemas/AdminEnvelope" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "409": + $ref: "#/components/responses/Error" + /groups: + get: + tags: [groups] + summary: List web groups and SourceMod server groups + responses: + "200": + description: Group catalog + content: + application/json: + schema: + $ref: "#/components/schemas/GroupsEnvelope" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /system/rehash: + post: + tags: [system] + summary: Run sm_rehash on game servers + description: > + If `sids` is omitted or empty, every enabled server is targeted. + Admin create/update/deactivate/reactivate already rehashes automatically. + requestBody: + content: + application/json: + schema: + type: object + properties: + sids: + type: array + items: + type: integer + responses: + "200": + description: Rehash result + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + description: Personal Access Token (`sbpp_pat_` + 64 hex chars). Minted on Your Account. + parameters: + adminId: + name: id + in: path + required: true + schema: + type: string + description: Numeric aid, or a 17-digit Steam64 that round-trips through Steam2. Steam2/Steam3 is 400. + page: + name: page + in: query + schema: + type: integer + minimum: 1 + default: 1 + perPage: + name: per_page + in: query + schema: + type: integer + minimum: 1 + maximum: 100 + default: 30 + schemas: + Error: + type: object + required: [error] + properties: + error: + type: object + required: [code, message] + properties: + code: + type: string + message: + type: string + field: + type: string + Admin: + type: object + properties: + id: + type: integer + name: + type: string + steam: + type: string + nullable: true + steam64: + type: string + nullable: true + description: Decimal string. Never a JSON number. + email: + type: string + enabled: + type: boolean + immunity: + type: integer + web_group_id: + type: integer + nullable: true + web_group_name: + type: string + nullable: true + server_group_id: + type: integer + nullable: true + server_group_name: + type: string + nullable: true + server_ids: + type: array + items: + type: integer + lastvisit: + type: integer + nullable: true + AdminWrite: + type: object + properties: + name: + type: string + steam: + type: string + email: + type: string + web_group_id: + type: integer + nullable: true + server_group_id: + type: integer + nullable: true + server_ids: + type: array + items: + type: integer + immunity: + type: integer + password: + type: string + description: Optional. Generated when omitted on create. + AdminEnvelope: + type: object + required: [data] + properties: + data: + $ref: "#/components/schemas/Admin" + meta: + type: object + properties: + rehash: + type: object + AdminListEnvelope: + type: object + required: [data, meta] + properties: + data: + type: array + items: + $ref: "#/components/schemas/Admin" + meta: + type: object + properties: + page: + type: integer + per_page: + type: integer + total: + type: integer + GroupsEnvelope: + type: object + required: [data] + properties: + data: + type: object + properties: + web: + type: array + items: + type: object + properties: + id: + type: integer + name: + type: string + flags: + type: integer + server: + type: array + items: + type: object + properties: + id: + type: integer + name: + type: string + flags: + type: string + immunity: + type: integer + responses: + Error: + description: Structured error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + Unauthorized: + description: Missing or invalid PAT + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + Forbidden: + description: Token admin lacks the required flag + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + NotFound: + description: Resource not found + content: + application/json: + schema: + $ref: "#/components/schemas/Error" diff --git a/web/api/v1.php b/web/api/v1.php new file mode 100644 index 000000000..2da5958f1 --- /dev/null +++ b/web/api/v1.php @@ -0,0 +1,44 @@ +getMessage() . ' @ ' . $e->getFile() . ':' . $e->getLine() + : 'An unexpected error occurred. See server logs for details.'; + echo json_encode(['error' => ['code' => 'server_error', 'message' => $msg]]); +}); + +register_shutdown_function(function (): void { + $err = error_get_last(); + if ($err === null) { + return; + } + $fatal = [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR, E_RECOVERABLE_ERROR]; + if (!in_array($err['type'], $fatal, true)) { + return; + } + if (!headers_sent()) { + header('Content-Type: application/json; charset=utf-8'); + http_response_code(500); + } + error_log(sprintf('[rest] fatal: %s in %s:%d', $err['message'], $err['file'], $err['line'])); + $msg = (defined('DEBUG_MODE') && DEBUG_MODE) + ? sprintf('%s @ %s:%d', $err['message'], $err['file'], $err['line']) + : 'A fatal error occurred. See server logs for details.'; + echo json_encode(['error' => ['code' => 'fatal', 'message' => $msg]]); +}); + +include_once dirname(__DIR__) . '/init.php'; +require_once INCLUDES_PATH . '/system-functions.php'; + +\Sbpp\Rest\FrontController::dispatch()->send(); diff --git a/web/config.php.template b/web/config.php.template index 9ab35b58a..e02a7c703 100644 --- a/web/config.php.template +++ b/web/config.php.template @@ -19,3 +19,8 @@ define('STEAMAPIKEY', ''); // Steam API Key for Shizz define('SB_EMAIL', ''); define('SB_NEW_SALT', '$5$'); //Salt for passwords define('SB_SECRET_KEY', ''); //Secret for JWT + +// Optional: comma-separated origins allowed to call the REST API from a browser. +// Leave undefined (or empty) to keep CORS off. Server-to-server callers +// (website-next backend) do not need this. +// define('SB_REST_CORS_ORIGINS', 'https://staff.example.com'); diff --git a/web/includes/Auth/UserManager.php b/web/includes/Auth/UserManager.php index 7d7c0fecd..e6e731bdf 100644 --- a/web/includes/Auth/UserManager.php +++ b/web/includes/Auth/UserManager.php @@ -19,11 +19,11 @@ final class UserManager private readonly Database $dbh; - public function __construct(?Token $token) + public function __construct(?Token $token, ?int $aidOverride = null) { $this->dbh = new Database(DB_HOST, DB_PORT, DB_NAME, DB_USER, DB_PASS, DB_PREFIX, DB_CHARSET); - $this->aid = (int)($token?->claims()->get('aid') ?? -1); + $this->aid = $aidOverride ?? (int)($token?->claims()->get('aid') ?? -1); $this->GetUserArray($this->aid); } diff --git a/web/includes/Rest/AdminId.php b/web/includes/Rest/AdminId.php new file mode 100644 index 000000000..f8971c274 --- /dev/null +++ b/web/includes/Rest/AdminId.php @@ -0,0 +1,63 @@ +steam64 !== null; + } +} diff --git a/web/includes/Rest/AdminsService.php b/web/includes/Rest/AdminsService.php new file mode 100644 index 000000000..978f76ba5 --- /dev/null +++ b/web/includes/Rest/AdminsService.php @@ -0,0 +1,622 @@ + $query + * @return array{data: list>, meta: array{page: int, per_page: int, total: int}} + */ + public function list(array $query): array + { + $page = max(1, (int) ($query['page'] ?? 1)); + $perPage = (int) ($query['per_page'] ?? 30); + if ($perPage < 1) { + $perPage = 30; + } + $perPage = min(100, $perPage); + $offset = ($page - 1) * $perPage; + + $pdo = $this->db(); + $countRow = $pdo->query('SELECT COUNT(*) AS c FROM `:prefix_admins`')->single(); + $total = is_array($countRow) ? (int) ($countRow['c'] ?? 0) : 0; + + $pdo->query( + 'SELECT A.aid, A.user, A.authid, A.email, A.enabled, A.immunity, A.gid, A.srv_group, A.lastvisit,' + . ' WG.name AS web_group_name, SG.id AS server_group_id' + . ' FROM `:prefix_admins` A' + . ' LEFT JOIN `:prefix_groups` WG ON A.gid = WG.gid' + . ' LEFT JOIN `:prefix_srvgroups` SG ON A.srv_group = SG.name' + . ' ORDER BY A.aid ASC' + . ' LIMIT :lim OFFSET :off' + ); + $pdo->bind(':lim', $perPage); + $pdo->bind(':off', $offset); + $rows = $pdo->resultset(); + + $data = []; + foreach ($rows as $row) { + $data[] = $this->toResource($row, $this->serverIds((int) $row['aid'])); + } + + return [ + 'data' => $data, + 'meta' => ['page' => $page, 'per_page' => $perPage, 'total' => $total], + ]; + } + + /** + * @return array + */ + public function get(AdminId $id): array + { + $row = $this->find($id); + if ($row === null) { + throw new ApiError('not_found', 'Admin not found.', null, 404); + } + return $this->toResource($row, $this->serverIds((int) $row['aid'])); + } + + /** + * PUT (upsert on Steam64, replace-existing on aid) or PATCH (merge, no create). + * + * @param array $body + */ + public function upsert(AdminId $id, array $body, string $method): Response + { + $this->assertBodySteamMatchesPath($id, $body); + $existing = $this->find($id); + + if ($id->aid !== null) { + if ($existing === null) { + throw new ApiError('not_found', 'Admin not found.', null, 404); + } + $updated = $this->update((int) $existing['aid'], $body, reactivate: false); + return Envelope::ok($updated['admin'], ['rehash' => $updated['rehash']]); + } + + if ($existing === null) { + if ($method === 'PATCH') { + throw new ApiError('not_found', 'Admin not found.', null, 404); + } + $created = $this->create((string) $id->steam64, $body); + return Envelope::ok($created['admin'], ['rehash' => $created['rehash']], 201); + } + + $updated = $this->update((int) $existing['aid'], $body, reactivate: $method === 'PUT'); + return Envelope::ok($updated['admin'], ['rehash' => $updated['rehash']]); + } + + /** + * @return array + */ + public function deactivate(AdminId $id, string $reason): array + { + $row = $this->requireRow($id); + $aid = (int) $row['aid']; + $this->refuseSelf($aid, 'deactivate'); + $out = Api::invoke('admins.deactivate', ['aid' => $aid, 'ureason' => $reason]); + $sids = $this->rehashSidsFromHandler($out, $aid); + $fresh = $this->requireRow(new AdminId($aid, null)); + return [ + 'admin' => $this->toResource($fresh, $this->serverIds($aid)), + 'rehash' => Rehasher::run($sids), + ]; + } + + /** + * @return array + */ + public function reactivate(AdminId $id, string $reason): array + { + $row = $this->requireRow($id); + $aid = (int) $row['aid']; + $out = Api::invoke('admins.reactivate', ['aid' => $aid, 'ureason' => $reason]); + $sids = $this->rehashSidsFromHandler($out, $aid); + $fresh = $this->requireRow(new AdminId($aid, null)); + return [ + 'admin' => $this->toResource($fresh, $this->serverIds($aid)), + 'rehash' => Rehasher::run($sids), + ]; + } + + /** + * @return array + */ + public function remove(AdminId $id, string $reason): array + { + $row = $this->requireRow($id); + $aid = (int) $row['aid']; + $this->refuseSelf($aid, 'delete'); + $sids = function_exists('_api_admins_rehash_sids') ? _api_admins_rehash_sids($aid) : []; + $out = Api::invoke('admins.remove', ['aid' => $aid, 'ureason' => $reason]); + $fromHandler = $this->sidsFromCsv(isset($out['rehash']) && is_string($out['rehash']) ? $out['rehash'] : null); + return [ + 'deleted' => $aid, + 'rehash' => Rehasher::run($fromHandler !== [] ? $fromHandler : $sids), + ]; + } + + /** + * @param array $body + * @return array{admin: array, rehash: array} + */ + private function create(string $steam64, array $body): array + { + global $userbank; + $name = trim((string) ($body['name'] ?? '')); + if ($name === '') { + throw new ApiError('validation', 'You must type a name for the admin.', 'name', 400); + } + if (str_contains($name, "'")) { + throw new ApiError('validation', "An admin name can not contain a \"'\".", 'name', 400); + } + if ($userbank->isNameTaken($name)) { + throw new ApiError('conflict', 'An admin with this name already exists.', 'name', 409); + } + + $steam2 = SteamID::toSteam2($steam64); + if ($userbank->isSteamIDTaken($steam2)) { + throw new ApiError('conflict', 'An admin with this Steam ID already exists.', 'steam', 409); + } + + $webGroupId = $this->optionalInt($body, 'web_group_id'); + $email = trim((string) ($body['email'] ?? '')); + if ($webGroupId !== null && $webGroupId > 0 && $email === '') { + throw new ApiError('validation', 'You must type an e-mail address.', 'email', 400); + } + if ($email !== '' && $userbank->isEmailTaken($email)) { + throw new ApiError('conflict', 'This email address is already in use.', 'email', 409); + } + + $gid = $this->resolveWebGroup($webGroupId); + $srv = $this->resolveServerGroup($this->optionalInt($body, 'server_group_id')); + $immunity = max(0, (int) ($body['immunity'] ?? 0)); + $password = (string) ($body['password'] ?? ''); + if ($password === '') { + $password = Crypto::genPassword(); + } + if (strlen($password) < MIN_PASS_LENGTH) { + throw new ApiError( + 'validation', + 'Your password must be at-least ' . MIN_PASS_LENGTH . ' characters long.', + 'password', + 400, + ); + } + + $aid = $userbank->AddAdmin( + $name, + $steam2, + $password, + $email, + $gid, + 0, + $srv['name'], + '', + $immunity, + '', + ); + if ($aid <= -1) { + throw new ApiError('create_failed', 'The admin failed to be added to the database.', null, 500); + } + + $serverIds = $this->optionalIntList($body, 'server_ids'); + $this->replaceServerAccess($aid, $srv['id'], $serverIds ?? []); + + $sids = function_exists('_api_admins_rehash_sids') ? _api_admins_rehash_sids($aid) : []; + $fresh = $this->requireRow(new AdminId($aid, null)); + return [ + 'admin' => $this->toResource($fresh, $this->serverIds($aid)), + 'rehash' => Rehasher::run($sids), + ]; + } + + /** + * @param array $body + * @return array{admin: array, rehash: array} + */ + private function update(int $aid, array $body, bool $reactivate): array + { + global $userbank; + $pdo = $this->db(); + $pdo->query('SELECT user, authid, email, gid, srv_group, immunity, enabled FROM `:prefix_admins` WHERE aid = :aid'); + $pdo->bind(':aid', $aid); + $current = $pdo->single(); + if (!is_array($current)) { + throw new ApiError('not_found', 'Admin not found.', null, 404); + } + + $name = array_key_exists('name', $body) ? trim((string) $body['name']) : (string) $current['user']; + if ($name === '') { + throw new ApiError('validation', 'You must type a name for the admin.', 'name', 400); + } + if (str_contains($name, "'")) { + throw new ApiError('validation', "An admin name can not contain a \"'\".", 'name', 400); + } + if ($name !== (string) $current['user'] && $userbank->isNameTaken($name)) { + throw new ApiError('conflict', 'An admin with this name already exists.', 'name', 409); + } + + $steam2 = (string) $current['authid']; + if (array_key_exists('steam', $body) && trim((string) $body['steam']) !== '') { + $rawSteam = trim((string) $body['steam']); + if (!preg_match(SteamID::HANDLER_STRICT_REGEX, $rawSteam)) { + throw new ApiError('validation', 'Please enter a valid Steam ID or Community ID.', 'steam', 400); + } + $steam2 = SteamID::toSteam2($rawSteam); + if ($steam2 !== (string) $current['authid'] && $userbank->isSteamIDTaken($steam2)) { + throw new ApiError('conflict', 'An admin with this Steam ID already exists.', 'steam', 409); + } + } + + $email = array_key_exists('email', $body) ? trim((string) $body['email']) : (string) $current['email']; + $webGroupId = array_key_exists('web_group_id', $body) + ? $this->optionalInt($body, 'web_group_id') + : (int) $current['gid']; + $gid = array_key_exists('web_group_id', $body) + ? $this->resolveWebGroup($webGroupId) + : (int) $current['gid']; + if ($gid > 0 && $email === '') { + throw new ApiError('validation', 'You must type an e-mail address.', 'email', 400); + } + if ($email !== '' && $email !== (string) $current['email'] && $userbank->isEmailTaken($email)) { + throw new ApiError('conflict', 'This email address is already in use.', 'email', 409); + } + + $immunity = array_key_exists('immunity', $body) + ? max(0, (int) $body['immunity']) + : (int) $current['immunity']; + + $srvName = (string) ($current['srv_group'] ?? ''); + $srvId = -1; + if (array_key_exists('server_group_id', $body)) { + $srv = $this->resolveServerGroup($this->optionalInt($body, 'server_group_id')); + $srvName = $srv['name']; + $srvId = $srv['id']; + } else { + $pdo->query('SELECT id FROM `:prefix_srvgroups` WHERE name = :name'); + $pdo->bind(':name', $srvName); + $sg = $pdo->single(); + $srvId = is_array($sg) ? (int) $sg['id'] : -1; + } + + $enabled = (int) ($current['enabled'] ?? 1); + if ($reactivate) { + $enabled = 1; + } + + $pdo->query( + 'UPDATE `:prefix_admins` SET user = :user, authid = :authid, email = :email,' + . ' gid = :gid, immunity = :immunity, srv_group = :srv_group, enabled = :enabled' + . ' WHERE aid = :aid' + ); + $pdo->bind(':user', $name); + $pdo->bind(':authid', $steam2); + $pdo->bind(':email', $email); + $pdo->bind(':gid', $gid); + $pdo->bind(':immunity', $immunity); + $pdo->bind(':srv_group', $srvName); + $pdo->bind(':enabled', $enabled); + $pdo->bind(':aid', $aid); + $pdo->execute(); + + if (array_key_exists('server_ids', $body) || array_key_exists('server_group_id', $body)) { + $serverIds = array_key_exists('server_ids', $body) + ? ($this->optionalIntList($body, 'server_ids') ?? []) + : $this->serverIds($aid); + $this->replaceServerAccess($aid, $srvId, $serverIds); + } + + $sids = function_exists('_api_admins_rehash_sids') ? _api_admins_rehash_sids($aid) : []; + $fresh = $this->requireRow(new AdminId($aid, null)); + return [ + 'admin' => $this->toResource($fresh, $this->serverIds($aid)), + 'rehash' => Rehasher::run($sids), + ]; + } + + /** + * @param array $body + */ + private function assertBodySteamMatchesPath(AdminId $id, array $body): void + { + if (!$id->isSteam64() || !array_key_exists('steam', $body)) { + return; + } + $raw = trim((string) $body['steam']); + if ($raw === '') { + return; + } + if (!preg_match(SteamID::HANDLER_STRICT_REGEX, $raw)) { + throw new ApiError('validation', 'Please enter a valid Steam ID or Community ID.', 'steam', 400); + } + $path64 = (string) $id->steam64; + $body64 = SteamID::toSteam64(SteamID::toSteam2($raw)); + if ((string) $body64 !== $path64) { + throw new ApiError( + 'conflict', + 'Body steam does not match the Steam64 in the URL.', + 'steam', + 409, + ); + } + } + + /** + * @return array|null + */ + private function find(AdminId $id): ?array + { + $pdo = $this->db(); + $sql = 'SELECT A.aid, A.user, A.authid, A.email, A.enabled, A.immunity, A.gid, A.srv_group, A.lastvisit,' + . ' WG.name AS web_group_name, SG.id AS server_group_id' + . ' FROM `:prefix_admins` A' + . ' LEFT JOIN `:prefix_groups` WG ON A.gid = WG.gid' + . ' LEFT JOIN `:prefix_srvgroups` SG ON A.srv_group = SG.name' + . ' WHERE '; + if ($id->steam64 !== null) { + $steam2 = SteamID::toSteam2($id->steam64); + $pdo->query($sql . 'A.authid = :authid LIMIT 1'); + $pdo->bind(':authid', $steam2); + } else { + $pdo->query($sql . 'A.aid = :aid LIMIT 1'); + $pdo->bind(':aid', (int) $id->aid); + } + $row = $pdo->single(); + return is_array($row) ? $row : null; + } + + /** + * @return array + */ + private function requireRow(AdminId $id): array + { + $row = $this->find($id); + if ($row === null) { + throw new ApiError('not_found', 'Admin not found.', null, 404); + } + return $row; + } + + /** + * @param array $row + * @param list $serverIds + * @return array + */ + private function toResource(array $row, array $serverIds): array + { + $steam2 = (string) ($row['authid'] ?? ''); + $steam64 = null; + if ($steam2 !== '' && SteamID::isValidID($steam2)) { + $converted = SteamID::toSteam64($steam2); + if ($converted !== false && $converted !== null && $converted !== '') { + $steam64 = (string) $converted; + } + } + $gid = (int) ($row['gid'] ?? -1); + $rawSrvGid = $row['server_group_id'] ?? null; + $srvGroupId = ($rawSrvGid !== null && $rawSrvGid !== '' && (int) $rawSrvGid > 0) + ? (int) $rawSrvGid + : null; + + return [ + 'id' => (int) $row['aid'], + 'name' => (string) $row['user'], + 'steam' => $steam2 !== '' ? $steam2 : null, + 'steam64' => $steam64, + 'email' => (string) ($row['email'] ?? ''), + 'enabled' => (int) ($row['enabled'] ?? 1) === 1, + 'immunity' => (int) ($row['immunity'] ?? 0), + 'web_group_id' => $gid > 0 ? $gid : null, + 'web_group_name' => $gid > 0 ? (string) ($row['web_group_name'] ?? '') : null, + 'server_group_id' => $srvGroupId, + 'server_group_name' => $srvGroupId !== null ? (string) ($row['srv_group'] ?? '') : null, + 'server_ids' => $serverIds, + 'lastvisit' => $row['lastvisit'] === null ? null : (int) $row['lastvisit'], + ]; + } + + /** + * @return list + */ + private function serverIds(int $aid): array + { + $pdo = $this->db(); + $pdo->query( + 'SELECT server_id FROM `:prefix_admins_servers_groups`' + . ' WHERE admin_id = :aid AND server_id > 0' + ); + $pdo->bind(':aid', $aid); + $ids = []; + foreach ($pdo->resultset() as $row) { + $ids[] = (int) $row['server_id']; + } + return $ids; + } + + /** + * @param list $serverIds + */ + private function replaceServerAccess(int $aid, int $srvGroupId, array $serverIds): void + { + $pdo = $this->db(); + $pdo->beginTransaction(); + try { + $pdo->query('DELETE FROM `:prefix_admins_servers_groups` WHERE admin_id = :aid'); + $pdo->bind(':aid', $aid); + $pdo->execute(); + + if ($srvGroupId > 0) { + $pdo->query( + 'INSERT INTO `:prefix_admins_servers_groups` (admin_id, group_id, srv_group_id, server_id)' + . ' VALUES (:aid, :gid, :sgid, -1)' + ); + $pdo->bind(':aid', $aid); + $pdo->bind(':gid', $srvGroupId); + $pdo->bind(':sgid', $srvGroupId); + $pdo->execute(); + } + + foreach ($serverIds as $sid) { + if ($sid <= 0) { + continue; + } + $pdo->query( + 'INSERT INTO `:prefix_admins_servers_groups` (admin_id, group_id, srv_group_id, server_id)' + . ' VALUES (:aid_s, :gid_s, -1, :sid)' + ); + $pdo->bind(':aid_s', $aid); + $pdo->bind(':gid_s', $srvGroupId > 0 ? $srvGroupId : -1); + $pdo->bind(':sid', $sid); + $pdo->execute(); + } + $pdo->endTransaction(); + } catch (\Throwable $e) { + $pdo->cancelTransaction(); + throw $e; + } + } + + private function resolveWebGroup(?int $webGroupId): int + { + global $userbank; + if ($webGroupId === null || $webGroupId <= 0) { + return -1; + } + $pdo = $this->db(); + $pdo->query('SELECT gid, flags FROM `:prefix_groups` WHERE gid = :gid AND type = 1'); + $pdo->bind(':gid', $webGroupId); + $row = $pdo->single(); + if (!is_array($row)) { + throw new ApiError('validation', 'Unknown web group.', 'web_group_id', 400); + } + if (((int) $row['flags'] & ADMIN_OWNER) !== 0 && !$userbank->HasAccess(WebPermission::Owner)) { + throw new ApiError('forbidden', 'No access', null, 403); + } + return (int) $row['gid']; + } + + /** + * @return array{id: int, name: string} + */ + private function resolveServerGroup(?int $serverGroupId): array + { + if ($serverGroupId === null || $serverGroupId <= 0) { + return ['id' => -1, 'name' => '']; + } + $pdo = $this->db(); + $pdo->query('SELECT id, name FROM `:prefix_srvgroups` WHERE id = :id'); + $pdo->bind(':id', $serverGroupId); + $row = $pdo->single(); + if (!is_array($row)) { + throw new ApiError('validation', 'Unknown server group.', 'server_group_id', 400); + } + return ['id' => (int) $row['id'], 'name' => (string) $row['name']]; + } + + /** + * @param array $body + */ + private function optionalInt(array $body, string $key): ?int + { + if (!array_key_exists($key, $body) || $body[$key] === null || $body[$key] === '') { + return null; + } + if (!is_numeric($body[$key])) { + throw new ApiError('validation', 'Must be an integer.', $key, 400); + } + return (int) $body[$key]; + } + + /** + * @param array $body + * @return list|null + */ + private function optionalIntList(array $body, string $key): ?array + { + if (!array_key_exists($key, $body)) { + return null; + } + if ($body[$key] === null) { + return []; + } + if (!is_array($body[$key])) { + throw new ApiError('validation', 'Must be an array of integers.', $key, 400); + } + $out = []; + foreach ($body[$key] as $v) { + if (!is_numeric($v)) { + throw new ApiError('validation', 'Must be an array of integers.', $key, 400); + } + $out[] = (int) $v; + } + return $out; + } + + private function refuseSelf(int $aid, string $verb): void + { + /** @var UserManager $userbank */ + $userbank = $GLOBALS['userbank']; + if ($aid === $userbank->GetAid()) { + throw new ApiError('validation', 'You cannot ' . $verb . ' your own account.', 'id', 400); + } + } + + /** + * @param array $out + * @return list + */ + private function rehashSidsFromHandler(array $out, int $aid): array + { + $fromHandler = $this->sidsFromCsv(isset($out['rehash']) && is_string($out['rehash']) ? $out['rehash'] : null); + if ($fromHandler !== []) { + return $fromHandler; + } + return function_exists('_api_admins_rehash_sids') ? _api_admins_rehash_sids($aid) : []; + } + + /** + * @return list + */ + private function sidsFromCsv(?string $csv): array + { + if ($csv === null || $csv === '') { + return []; + } + $sids = []; + foreach (explode(',', $csv) as $part) { + $part = trim($part); + if ($part !== '') { + $sids[] = (int) $part; + } + } + return $sids; + } + + private function db(): Database + { + $pdo = $GLOBALS['PDO'] ?? null; + if ($pdo instanceof Database) { + return $pdo; + } + return new Database(DB_HOST, DB_PORT, DB_NAME, DB_USER, DB_PASS, DB_PREFIX, DB_CHARSET); + } +} diff --git a/web/includes/Rest/Envelope.php b/web/includes/Rest/Envelope.php new file mode 100644 index 000000000..02ac8b7a4 --- /dev/null +++ b/web/includes/Rest/Envelope.php @@ -0,0 +1,86 @@ +|list $data + * @param array $meta + * @param array $headers + */ + public static function ok(array $data, array $meta = [], int $status = 200, array $headers = []): Response + { + $payload = ['data' => $data]; + if ($meta !== []) { + $payload['meta'] = $meta; + } + return new Response($status, $payload, $headers); + } + + /** @param array $headers */ + public static function empty(int $status = 204, array $headers = []): Response + { + return new Response($status, [], $headers); + } + + /** @param array $headers */ + public static function error( + string $code, + string $message, + int $status, + ?string $field = null, + array $headers = [], + ): Response { + $err = ['code' => $code, 'message' => $message]; + if ($field !== null) { + $err['field'] = $field; + } + return new Response($status, ['error' => $err], $headers); + } + + public static function fromApiError(ApiError $e): Response + { + $status = $e->httpStatus !== 200 ? $e->httpStatus : self::statusForCode($e->errorCode); + return self::error($e->errorCode, $e->getMessage(), $status, $e->field); + } + + public static function yaml(string $body, int $status = 200): Response + { + return new Response( + $status, + [], + [], + $body, + 'application/yaml; charset=utf-8', + ); + } + + private static function statusForCode(string $code): int + { + return match ($code) { + 'not_found' => 404, + 'forbidden' => 403, + 'validation', 'bad_request', 'bad_password', 'bad_email' => 400, + 'cannot_delete_owner', + 'cannot_deactivate_owner', + 'already_inactive', + 'already_active', + 'conflict' => 409, + default => 400, + }; + } +} diff --git a/web/includes/Rest/FrontController.php b/web/includes/Rest/FrontController.php new file mode 100644 index 000000000..e6796d9fa --- /dev/null +++ b/web/includes/Rest/FrontController.php @@ -0,0 +1,173 @@ + (string) $rl['limit'], + 'X-RateLimit-Remaining' => (string) $rl['remaining'], + ]); + if (!$rl['ok']) { + return Envelope::error( + 'rate_limited', + 'Too many requests.', + 429, + null, + array_merge($rlHeaders, ['Retry-After' => (string) $rl['retry_after']]), + ); + } + + try { + $path = self::requestPath(); + $body = self::decodeBody($method, $rawBody); + $query = $_GET; + $router = new Router(Routes::all()); + $matched = $router->match($method, $path); + + if (isset($matched['error'])) { + $allow = implode(', ', $matched['allow']); + $headers = $rlHeaders; + if ($matched['error'] === 405 && $allow !== '') { + $headers['Allow'] = $allow; + } + $code = $matched['error'] === 405 ? 'method_not_allowed' : 'not_found'; + $message = $matched['error'] === 405 ? 'Method not allowed.' : 'Not found.'; + return Envelope::error($code, $message, $matched['error'], null, $headers); + } + + /** @var array{route: array{method: string, path: string, auth: bool, perm: int, handler: callable}, params: array} $matched */ + $route = $matched['route']; + $params = $matched['params']; + + if ($route['auth']) { + /** @var UserManager $userbank */ + $userbank = $GLOBALS['userbank']; + if (!$userbank->is_logged_in()) { + return Envelope::error('unauthorized', 'A valid API token is required.', 401, null, $rlHeaders); + } + if ($route['perm'] !== 0 && !$userbank->HasAccess($route['perm'])) { + return Envelope::error('forbidden', 'No access', 403, null, $rlHeaders); + } + } + + $response = ($route['handler'])($params, $body, $query); + if (!$response instanceof Response) { + return Envelope::error('server_error', 'Handler returned an invalid response.', 500, null, $rlHeaders); + } + return new Response( + $response->status, + $response->payload, + array_merge($rlHeaders, $response->headers), + $response->rawBody, + $response->contentType, + ); + } catch (ApiError $e) { + $mapped = Envelope::fromApiError($e); + return new Response( + $mapped->status, + $mapped->payload, + array_merge($rlHeaders, $mapped->headers), + ); + } catch (Throwable $e) { + error_log('[rest] uncaught: ' . $e->getMessage() . "\n" . $e->getTraceAsString()); + $msg = (defined('DEBUG_MODE') && DEBUG_MODE) + ? $e->getMessage() . ' @ ' . $e->getFile() . ':' . $e->getLine() + : 'An unexpected error occurred. See server logs for details.'; + return Envelope::error('server_error', $msg, 500, null, $rlHeaders); + } + } + + public static function requestPath(): string + { + $pathInfo = $_SERVER['PATH_INFO'] ?? ''; + if (is_string($pathInfo) && $pathInfo !== '') { + return Router::normalize($pathInfo); + } + + $uri = (string) (parse_url((string) ($_SERVER['REQUEST_URI'] ?? ''), PHP_URL_PATH) ?? ''); + if (preg_match('#/api/v1(?:\.php)?(/.*)?$#', $uri, $m) === 1) { + return Router::normalize($m[1] ?? '/'); + } + return '/'; + } + + /** + * @return array + */ + private static function decodeBody(string $method, ?string $rawBody): array + { + if (!in_array($method, ['POST', 'PUT', 'PATCH', 'DELETE'], true)) { + return []; + } + $raw = $rawBody ?? (file_get_contents('php://input') ?: ''); + if ($raw === '') { + return []; + } + try { + $decoded = json_decode($raw, true, 512, JSON_THROW_ON_ERROR); + } catch (\JsonException) { + throw new ApiError('bad_request', 'Invalid JSON body.', null, 400); + } + if (!is_array($decoded)) { + throw new ApiError('bad_request', 'JSON body must be an object.', null, 400); + } + /** @var array $decoded */ + return $decoded; + } + + /** + * @return array + */ + private static function corsHeaders(): array + { + if (!defined('SB_REST_CORS_ORIGINS') || SB_REST_CORS_ORIGINS === '') { + return []; + } + $origin = (string) ($_SERVER['HTTP_ORIGIN'] ?? ''); + if ($origin === '') { + return []; + } + $allowed = array_map('trim', explode(',', (string) SB_REST_CORS_ORIGINS)); + if (!in_array($origin, $allowed, true)) { + return []; + } + return [ + 'Access-Control-Allow-Origin' => $origin, + 'Access-Control-Allow-Headers' => 'Authorization, Content-Type', + 'Access-Control-Allow-Methods' => 'GET, PUT, PATCH, POST, DELETE, OPTIONS', + 'Vary' => 'Origin', + ]; + } +} diff --git a/web/includes/Rest/GroupsService.php b/web/includes/Rest/GroupsService.php new file mode 100644 index 000000000..54de54517 --- /dev/null +++ b/web/includes/Rest/GroupsService.php @@ -0,0 +1,60 @@ +, server: list} + */ + public function list(): array + { + $pdo = $this->db(); + $webRows = $pdo->query( + 'SELECT gid, name, flags FROM `:prefix_groups` WHERE type = 1 ORDER BY name ASC' + )->resultset(); + $web = []; + foreach ($webRows as $row) { + $web[] = [ + 'id' => (int) $row['gid'], + 'name' => (string) $row['name'], + 'flags' => (int) $row['flags'], + ]; + } + + $srvRows = $pdo->query( + 'SELECT id, name, flags, immunity FROM `:prefix_srvgroups` ORDER BY name ASC' + )->resultset(); + $server = []; + foreach ($srvRows as $row) { + $server[] = [ + 'id' => (int) $row['id'], + 'name' => (string) $row['name'], + 'flags' => (string) $row['flags'], + 'immunity' => (int) $row['immunity'], + ]; + } + + return ['web' => $web, 'server' => $server]; + } + + private function db(): Database + { + $pdo = $GLOBALS['PDO'] ?? null; + if ($pdo instanceof Database) { + return $pdo; + } + return new Database(DB_HOST, DB_PORT, DB_NAME, DB_USER, DB_PASS, DB_PREFIX, DB_CHARSET); + } +} diff --git a/web/includes/Rest/PatAuthenticator.php b/web/includes/Rest/PatAuthenticator.php new file mode 100644 index 000000000..31fc325eb --- /dev/null +++ b/web/includes/Rest/PatAuthenticator.php @@ -0,0 +1,252 @@ +query('SELECT COUNT(*) AS c FROM `:prefix_api_tokens` WHERE aid = :aid AND revoked_at IS NULL'); + $pdo->bind(':aid', $aid); + $row = $pdo->single(); + if (is_array($row) && (int) ($row['c'] ?? 0) >= self::MAX_PER_ADMIN) { + throw new \Sbpp\Api\ApiError( + 'validation', + 'You already have the maximum number of API tokens.', + 'name', + 400, + ); + } + + $secret = self::generateSecret(); + $now = time(); + $pdo->query( + 'INSERT INTO `:prefix_api_tokens` (aid, name, token_hash, token_prefix, created, expires_at)' + . ' VALUES (:aid, :name, :hash, :token_prefix, :created, :expires_at)' + ); + $pdo->bind(':aid', $aid); + $pdo->bind(':name', $name); + $pdo->bind(':hash', self::hash($secret)); + $pdo->bind(':token_prefix', self::prefixOf($secret)); + $pdo->bind(':created', $now); + $pdo->bind(':expires_at', $expiresAt); + $pdo->execute(); + + return [ + 'id' => (int) $pdo->lastInsertId(), + 'secret' => $secret, + 'prefix' => self::prefixOf($secret), + 'name' => $name, + 'created' => $now, + 'expires_at' => $expiresAt, + ]; + } + + /** + * @return list + */ + public static function listForAid(int $aid): array + { + $pdo = self::db(); + $pdo->query( + 'SELECT id, name, token_prefix, created, last_used, expires_at' + . ' FROM `:prefix_api_tokens`' + . ' WHERE aid = :aid AND revoked_at IS NULL' + . ' ORDER BY created DESC' + ); + $pdo->bind(':aid', $aid); + $rows = $pdo->resultset(); + $out = []; + foreach ($rows as $r) { + $out[] = [ + 'id' => (int) $r['id'], + 'name' => (string) $r['name'], + 'token_prefix' => (string) $r['token_prefix'], + 'created' => (int) $r['created'], + 'last_used' => $r['last_used'] === null ? null : (int) $r['last_used'], + 'expires_at' => $r['expires_at'] === null ? null : (int) $r['expires_at'], + ]; + } + return $out; + } + + public static function revoke(int $aid, int $id): bool + { + $pdo = self::db(); + $pdo->query( + 'UPDATE `:prefix_api_tokens` SET revoked_at = :now' + . ' WHERE id = :id AND aid = :aid AND revoked_at IS NULL' + ); + $pdo->bind(':now', time()); + $pdo->bind(':id', $id); + $pdo->bind(':aid', $aid); + $pdo->execute(); + return $pdo->rowCount() > 0; + } + + /** + * @return Identity|null + */ + public static function resolve(string $secret): ?array + { + if (!self::isWellFormedSecret($secret)) { + return null; + } + + $pdo = self::db(); + $now = time(); + $pdo->query( + 'SELECT t.id, t.aid, t.last_used, t.expires_at, t.revoked_at, a.enabled' + . ' FROM `:prefix_api_tokens` t' + . ' INNER JOIN `:prefix_admins` a ON a.aid = t.aid' + . ' WHERE t.token_hash = :hash' + . ' LIMIT 1' + ); + $pdo->bind(':hash', self::hash($secret)); + $row = $pdo->single(); + if (!is_array($row)) { + return null; + } + if ($row['revoked_at'] !== null) { + return null; + } + if ($row['expires_at'] !== null && (int) $row['expires_at'] <= $now) { + return null; + } + if ((int) ($row['enabled'] ?? 1) !== 1) { + return null; + } + + $tokenId = (int) $row['id']; + $lastUsed = $row['last_used'] === null ? null : (int) $row['last_used']; + if ($lastUsed === null || $lastUsed < $now - self::LAST_USED_THROTTLE_SECONDS) { + $pdo->query('UPDATE `:prefix_api_tokens` SET last_used = :now WHERE id = :id'); + $pdo->bind(':now', $now); + $pdo->bind(':id', $tokenId); + $pdo->execute(); + } + + return [ + 'aid' => (int) $row['aid'], + 'token_id' => $tokenId, + ]; + } + + /** + * Cookie JWT is ignored. Only `Authorization: Bearer sbpp_pat_…`. + * + * @return Identity|null + */ + public static function fromRequest(): ?array + { + $header = self::authorizationHeader(); + if ($header === '') { + return null; + } + if (preg_match('/^Bearer\s+(\S+)/i', trim($header), $m) !== 1) { + return null; + } + return self::resolve($m[1]); + } + + /** + * Replace `$GLOBALS['userbank']` with the PAT identity, or an anonymous + * UserManager. Never leave the cookie JWT session in place. + * + * @return Identity|null + */ + public static function bindUserbank(): ?array + { + $identity = self::fromRequest(); + if ($identity === null) { + $GLOBALS['userbank'] = new UserManager(null); + return null; + } + $GLOBALS['userbank'] = new UserManager(null, $identity['aid']); + return $identity; + } + + /** + * Apache's apache2handler SAPI does not copy `Authorization` into + * `$_SERVER['HTTP_AUTHORIZATION']`. `getallheaders()` still sees it. + */ + public static function authorizationHeader(): string + { + $direct = (string) ($_SERVER['HTTP_AUTHORIZATION'] ?? $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ?? ''); + if ($direct !== '') { + return $direct; + } + if (function_exists('getallheaders')) { + $headers = getallheaders(); + if (is_array($headers)) { + foreach ($headers as $name => $value) { + if (strtolower((string) $name) === 'authorization') { + return (string) $value; + } + } + } + } + return ''; + } + + private static function db(): Database + { + $pdo = $GLOBALS['PDO'] ?? null; + if ($pdo instanceof Database) { + return $pdo; + } + return new Database(DB_HOST, DB_PORT, DB_NAME, DB_USER, DB_PASS, DB_PREFIX, DB_CHARSET); + } +} diff --git a/web/includes/Rest/RateLimiter.php b/web/includes/Rest/RateLimiter.php new file mode 100644 index 000000000..a3e1a193f --- /dev/null +++ b/web/includes/Rest/RateLimiter.php @@ -0,0 +1,111 @@ + true, 'remaining' => $limit, 'retry_after' => $retryAfter, 'limit' => $limit]; + } + + $path = $dir . '/' . hash('sha1', $key) . '.json'; + $count = 0; + $storedWindow = $window; + if (is_file($path)) { + $raw = @file_get_contents($path); + if (is_string($raw) && $raw !== '') { + try { + $decoded = json_decode($raw, true, 8, JSON_THROW_ON_ERROR); + if (is_array($decoded)) { + $storedWindow = (int) ($decoded['window'] ?? $window); + $count = (int) ($decoded['count'] ?? 0); + } + } catch (\JsonException) { + $count = 0; + $storedWindow = $window; + } + } + } + + if ($storedWindow !== $window) { + $count = 0; + } + + $count++; + if ($count > $limit) { + self::write($path, $window, $count); + return ['ok' => false, 'remaining' => 0, 'retry_after' => $retryAfter, 'limit' => $limit]; + } + + self::write($path, $window, $count); + return [ + 'ok' => true, + 'remaining' => max(0, $limit - $count), + 'retry_after' => $retryAfter, + 'limit' => $limit, + ]; + } + + private static function write(string $path, int $window, int $count): void + { + $payload = json_encode(['window' => $window, 'count' => $count], JSON_THROW_ON_ERROR); + $tmp = $path . '.' . bin2hex(random_bytes(4)) . '.tmp'; + if (@file_put_contents($tmp, $payload) === false) { + return; + } + if (!@rename($tmp, $path)) { + @unlink($tmp); + } + } + + private static function dir(): string + { + $root = defined('SB_CACHE') ? SB_CACHE : (defined('ROOT') ? ROOT . 'cache/' : sys_get_temp_dir() . '/sbpp-rest-rl/'); + return rtrim(str_replace('\\', '/', $root), '/') . '/rest-rl'; + } +} diff --git a/web/includes/Rest/Rehasher.php b/web/includes/Rest/Rehasher.php new file mode 100644 index 000000000..1100f513d --- /dev/null +++ b/web/includes/Rest/Rehasher.php @@ -0,0 +1,55 @@ + $sids + * @return array{attempted: bool, sids: list, results: list} + */ + public static function run(array $sids): array + { + $sids = array_values(array_unique(array_map('intval', $sids))); + if ($sids === [] || !Config::getBool('config.enableadminrehashing')) { + return ['attempted' => false, 'sids' => $sids, 'results' => []]; + } + + $csv = implode(',', array_map(static fn (int $sid): string => (string) $sid, $sids)); + $out = Api::invoke('system.rehash_admins', ['servers' => $csv]); + /** @var list $results */ + $results = is_array($out['results'] ?? null) ? $out['results'] : []; + + return [ + 'attempted' => true, + 'sids' => $sids, + 'results' => $results, + ]; + } + + /** + * @return list + */ + public static function allEnabledSids(): array + { + $rows = $GLOBALS['PDO']->query( + 'SELECT sid FROM `:prefix_servers` WHERE enabled = 1' + )->resultset(); + $sids = []; + foreach ($rows as $row) { + $sids[] = (int) $row['sid']; + } + return $sids; + } +} diff --git a/web/includes/Rest/Response.php b/web/includes/Rest/Response.php new file mode 100644 index 000000000..583d67390 --- /dev/null +++ b/web/includes/Rest/Response.php @@ -0,0 +1,54 @@ + $payload + * @param array $headers + */ + public function __construct( + public readonly int $status, + public readonly array $payload = [], + public readonly array $headers = [], + public readonly ?string $rawBody = null, + public readonly string $contentType = 'application/json; charset=utf-8', + ) { + } + + public function send(): never + { + if (!headers_sent()) { + foreach ($this->headers as $name => $value) { + header($name . ': ' . $value); + } + header('Content-Type: ' . $this->contentType); + header('Cache-Control: no-store'); + http_response_code($this->status); + } + + if ($this->status !== 204) { + if ($this->rawBody !== null) { + echo $this->rawBody; + } else { + echo json_encode( + $this->payload, + JSON_THROW_ON_ERROR + | JSON_INVALID_UTF8_SUBSTITUTE + | JSON_UNESCAPED_SLASHES, + ); + } + } + exit; + } +} diff --git a/web/includes/Rest/Router.php b/web/includes/Rest/Router.php new file mode 100644 index 000000000..9f19aea73 --- /dev/null +++ b/web/includes/Rest/Router.php @@ -0,0 +1,99 @@ + */ + private array $routes; + + /** @param list $routes */ + public function __construct(array $routes) + { + $this->routes = $routes; + } + + /** @return list */ + public function routes(): array + { + return $this->routes; + } + + /** + * @return array{route: Route, params: array}|array{error: int, allow: list} + */ + public function match(string $method, string $path): array + { + $method = strtoupper($method); + $path = self::normalize($path); + $allow = []; + foreach ($this->routes as $route) { + $params = self::matchPath($route['path'], $path); + if ($params === null) { + continue; + } + $allow[] = $route['method']; + if ($route['method'] === $method) { + return ['route' => $route, 'params' => $params]; + } + } + if ($allow !== []) { + return ['error' => 405, 'allow' => array_values(array_unique($allow))]; + } + return ['error' => 404, 'allow' => []]; + } + + public static function normalize(string $path): string + { + if ($path === '') { + return '/'; + } + if ($path[0] !== '/') { + $path = '/' . $path; + } + if ($path !== '/' && str_ends_with($path, '/')) { + $path = rtrim($path, '/'); + } + return $path; + } + + /** + * @return array|null + */ + private static function matchPath(string $pattern, string $path): ?array + { + $pattern = self::normalize($pattern); + $regex = preg_replace_callback('/\{([a-zA-Z_][a-zA-Z0-9_]*)\}/', static function (array $m): string { + return '(?P<' . $m[1] . '>[^/]+)'; + }, $pattern); + if ($regex === null) { + return null; + } + if (preg_match('#^' . $regex . '$#', $path, $m) !== 1) { + return null; + } + $params = []; + foreach ($m as $k => $v) { + if (is_string($k)) { + $params[$k] = $v; + } + } + return $params; + } +} diff --git a/web/includes/Rest/Routes.php b/web/includes/Rest/Routes.php new file mode 100644 index 000000000..993cc614e --- /dev/null +++ b/web/includes/Rest/Routes.php @@ -0,0 +1,285 @@ + + */ + public static function all(): array + { + $readAdmins = ADMIN_OWNER | ADMIN_LIST_ADMINS | ADMIN_ADD_ADMINS | ADMIN_EDIT_ADMINS; + $writeAdmins = ADMIN_OWNER | ADMIN_ADD_ADMINS | ADMIN_EDIT_ADMINS; + $deleteAdmins = ADMIN_OWNER | ADMIN_DELETE_ADMINS; + $readGroups = ADMIN_OWNER | ADMIN_LIST_GROUPS | ADMIN_ADD_ADMINS | ADMIN_EDIT_ADMINS; + $rehash = ADMIN_OWNER | ADMIN_EDIT_ADMINS | ADMIN_EDIT_GROUPS | ADMIN_ADD_ADMINS; + + return [ + [ + 'method' => 'GET', + 'path' => '/openapi.yaml', + 'auth' => false, + 'perm' => 0, + 'handler' => self::openapi(...), + ], + [ + 'method' => 'GET', + 'path' => '/me', + 'auth' => true, + 'perm' => 0, + 'handler' => self::me(...), + ], + [ + 'method' => 'GET', + 'path' => '/admins', + 'auth' => true, + 'perm' => $readAdmins, + 'handler' => self::adminsList(...), + ], + [ + 'method' => 'GET', + 'path' => '/admins/{id}', + 'auth' => true, + 'perm' => $readAdmins, + 'handler' => self::adminsGet(...), + ], + [ + 'method' => 'PUT', + 'path' => '/admins/{id}', + 'auth' => true, + 'perm' => $writeAdmins, + 'handler' => self::adminsPut(...), + ], + [ + 'method' => 'PATCH', + 'path' => '/admins/{id}', + 'auth' => true, + 'perm' => ADMIN_OWNER | ADMIN_EDIT_ADMINS, + 'handler' => self::adminsPatch(...), + ], + [ + 'method' => 'POST', + 'path' => '/admins/{id}/deactivate', + 'auth' => true, + 'perm' => $deleteAdmins, + 'handler' => self::adminsDeactivate(...), + ], + [ + 'method' => 'POST', + 'path' => '/admins/{id}/reactivate', + 'auth' => true, + 'perm' => $deleteAdmins, + 'handler' => self::adminsReactivate(...), + ], + [ + 'method' => 'DELETE', + 'path' => '/admins/{id}', + 'auth' => true, + 'perm' => $deleteAdmins, + 'handler' => self::adminsDelete(...), + ], + [ + 'method' => 'GET', + 'path' => '/groups', + 'auth' => true, + 'perm' => $readGroups, + 'handler' => self::groupsList(...), + ], + [ + 'method' => 'POST', + 'path' => '/system/rehash', + 'auth' => true, + 'perm' => $rehash, + 'handler' => self::systemRehash(...), + ], + ]; + } + + /** + * @param array $params + * @param array $body + * @param array $query + */ + private static function openapi(array $params, array $body, array $query): Response + { + $path = ROOT . 'api/openapi-v1.yaml'; + if (!is_file($path)) { + throw new ApiError('not_found', 'OpenAPI spec is not available.', null, 404); + } + $yaml = file_get_contents($path); + if ($yaml === false) { + throw new ApiError('server_error', 'OpenAPI spec could not be read.', null, 500); + } + return Envelope::yaml($yaml); + } + + /** + * @param array $params + * @param array $body + * @param array $query + */ + private static function me(array $params, array $body, array $query): Response + { + /** @var UserManager $userbank */ + $userbank = $GLOBALS['userbank']; + $admin = (new AdminsService())->get(new AdminId($userbank->GetAid(), null)); + return Envelope::ok($admin); + } + + /** + * @param array $params + * @param array $body + * @param array $query + */ + private static function adminsList(array $params, array $body, array $query): Response + { + $result = (new AdminsService())->list($query); + return Envelope::ok($result['data'], $result['meta']); + } + + /** + * @param array $params + * @param array $body + * @param array $query + */ + private static function adminsGet(array $params, array $body, array $query): Response + { + $admin = (new AdminsService())->get(AdminId::parse($params['id'] ?? '')); + return Envelope::ok($admin); + } + + /** + * @param array $params + * @param array $body + * @param array $query + */ + private static function adminsPut(array $params, array $body, array $query): Response + { + $id = AdminId::parse($params['id'] ?? ''); + self::assertWritePerm($id); + return (new AdminsService())->upsert($id, $body, 'PUT'); + } + + /** + * @param array $params + * @param array $body + * @param array $query + */ + private static function adminsPatch(array $params, array $body, array $query): Response + { + $id = AdminId::parse($params['id'] ?? ''); + return (new AdminsService())->upsert($id, $body, 'PATCH'); + } + + /** + * @param array $params + * @param array $body + * @param array $query + */ + private static function adminsDeactivate(array $params, array $body, array $query): Response + { + $reason = trim((string) ($body['reason'] ?? '')); + $result = (new AdminsService())->deactivate(AdminId::parse($params['id'] ?? ''), $reason); + return Envelope::ok($result['admin'], ['rehash' => $result['rehash']]); + } + + /** + * @param array $params + * @param array $body + * @param array $query + */ + private static function adminsReactivate(array $params, array $body, array $query): Response + { + $reason = trim((string) ($body['reason'] ?? '')); + $result = (new AdminsService())->reactivate(AdminId::parse($params['id'] ?? ''), $reason); + return Envelope::ok($result['admin'], ['rehash' => $result['rehash']]); + } + + /** + * @param array $params + * @param array $body + * @param array $query + */ + private static function adminsDelete(array $params, array $body, array $query): Response + { + $reason = trim((string) ($body['reason'] ?? '')); + $result = (new AdminsService())->remove(AdminId::parse($params['id'] ?? ''), $reason); + return Envelope::ok(['id' => $result['deleted']], ['rehash' => $result['rehash']]); + } + + /** + * @param array $params + * @param array $body + * @param array $query + */ + private static function groupsList(array $params, array $body, array $query): Response + { + return Envelope::ok((new GroupsService())->list()); + } + + /** + * @param array $params + * @param array $body + * @param array $query + */ + private static function systemRehash(array $params, array $body, array $query): Response + { + $sids = []; + if (isset($body['sids']) && is_array($body['sids'])) { + foreach ($body['sids'] as $sid) { + if (is_numeric($sid)) { + $sids[] = (int) $sid; + } + } + } + if ($sids === []) { + $sids = Rehasher::allEnabledSids(); + } + return Envelope::ok(['rehash' => Rehasher::run($sids)]); + } + + /** + * PUT create requires ADD_ADMINS. PUT update requires EDIT_ADMINS. + */ + private static function assertWritePerm(AdminId $id): void + { + /** @var UserManager $userbank */ + $userbank = $GLOBALS['userbank']; + if ($userbank->HasAccess(WebPermission::Owner)) { + return; + } + $exists = true; + try { + (new AdminsService())->get($id); + } catch (ApiError $e) { + if ($e->errorCode !== 'not_found') { + throw $e; + } + $exists = false; + } + if (!$exists) { + if (!$userbank->HasAccess(WebPermission::AddAdmins)) { + throw new ApiError('forbidden', 'No access', null, 403); + } + return; + } + if (!$userbank->HasAccess(WebPermission::EditAdmins)) { + throw new ApiError('forbidden', 'No access', null, 403); + } + } +} diff --git a/web/includes/View/YourAccountView.php b/web/includes/View/YourAccountView.php index 6a468c80b..90711ff81 100644 --- a/web/includes/View/YourAccountView.php +++ b/web/includes/View/YourAccountView.php @@ -42,6 +42,9 @@ final class YourAccountView extends View * 'Kick', 'Ban']`). Preserves `SmFlagsToSb()`'s legacy * contract so the parallel admin-list / admin-edit surfaces * keep their existing wire shape. + * @param list $api_tokens + * Active Personal Access Tokens for the REST API. Empty when + * the admin has none. The plaintext secret is never listed. */ public function __construct( public readonly bool $srvpwset, @@ -50,6 +53,7 @@ public function __construct( public readonly array $web_permissions_grouped, public readonly false|array $server_permissions, public readonly int $min_pass_len, + public readonly array $api_tokens, ) { } } diff --git a/web/install/includes/sql/struc.sql b/web/install/includes/sql/struc.sql index f35b39c4c..37393ff5b 100644 --- a/web/install/includes/sql/struc.sql +++ b/web/install/includes/sql/struc.sql @@ -30,6 +30,22 @@ CREATE TABLE IF NOT EXISTS `{prefix}_admins_servers_groups` ( ) ENGINE=InnoDB DEFAULT CHARSET={charset}; +CREATE TABLE IF NOT EXISTS `{prefix}_api_tokens` ( + `id` int(10) UNSIGNED NOT NULL auto_increment, + `aid` int(6) NOT NULL, + `name` varchar(64) NOT NULL, + `token_hash` char(64) NOT NULL, + `token_prefix` varchar(16) NOT NULL, + `created` int(11) NOT NULL, + `last_used` int(11) NULL default NULL, + `expires_at` int(11) NULL default NULL, + `revoked_at` int(11) NULL default NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `token_hash` (`token_hash`), + KEY `aid` (`aid`) +) ENGINE=InnoDB DEFAULT CHARSET={charset}; + + CREATE TABLE IF NOT EXISTS `{prefix}_banlog` ( `sid` int(6) NOT NULL, `time` int(11) NOT NULL, diff --git a/web/pages/page.youraccount.php b/web/pages/page.youraccount.php index 851df6cb9..705779ae5 100644 --- a/web/pages/page.youraccount.php +++ b/web/pages/page.youraccount.php @@ -33,4 +33,5 @@ web_permissions_grouped: \Sbpp\View\PermissionCatalog::groupedDisplayFromMask($webExtraFlags), server_permissions: SmFlagsToSb($userbank->GetProperty("srv_flags")), min_pass_len: (int) MIN_PASS_LENGTH, + api_tokens: \Sbpp\Rest\PatAuthenticator::listForAid((int) $userbank->GetAid()), )); diff --git a/web/scripts/api-contract.js b/web/scripts/api-contract.js index b749a2f73..25c1c2824 100644 --- a/web/scripts/api-contract.js +++ b/web/scripts/api-contract.js @@ -35,6 +35,18 @@ * @typedef {Object} ApiAccountCheckSrvPasswordRequest * @typedef {Object} ApiAccountCheckSrvPasswordResponse */ +/** + * @typedef {Object} ApiAccountTokensCreateRequest + * @typedef {{id: number, name: string, token: string, token_prefix: string, created: number, expires_at: number|null}} ApiAccountTokensCreateResponse + */ +/** + * @typedef {Object} ApiAccountTokensListRequest + * @typedef {{tokens: Array<{id: number, name: string, token_prefix: string, created: number, last_used: number|null, expires_at: number|null}>}} ApiAccountTokensListResponse + */ +/** + * @typedef {Object} ApiAccountTokensRevokeRequest + * @typedef {{revoked: number}} ApiAccountTokensRevokeResponse + */ /** * @typedef {Object} ApiAdminsAddRequest * @typedef {Object} ApiAdminsAddResponse @@ -567,11 +579,11 @@ */ /** * Public action: report whether a newer SourceBans++ release is available. - * Sources from `api.github.com/repos/srcdslab/sourcebans-pp/releases/latest` with - * a 1-day on-disk cache + stale-while-error fallback (the cached payload is - * served regardless of TTL when the upstream call fails) so a busy panel can't - * blow through GitHub's 60 req/hr unauthenticated limit and a transient GitHub - * blip doesn't paint the panel red. + * Sources from `api.github.com/repos/srcdslab/sourcebans-pp/releases/latest` + * with a 1-day on-disk cache + stale-while-error fallback (the cached payload + * is served regardless of TTL when the upstream call fails) so a busy panel + * can't blow through GitHub's 60 req/hr unauthenticated limit and a transient + * GitHub blip doesn't paint the panel red. * * @typedef {Object} ApiSystemCheckVersionRequest * @typedef {{release_latest: string, release_url: string, release_msg: string, release_update: boolean}} ApiSystemCheckVersionResponse @@ -678,6 +690,9 @@ var Actions = Object.freeze({ AccountChangeSrvPassword: 'account.change_srv_password', AccountCheckPassword: 'account.check_password', AccountCheckSrvPassword: 'account.check_srv_password', + AccountTokensCreate: 'account.tokens_create', + AccountTokensList: 'account.tokens_list', + AccountTokensRevoke: 'account.tokens_revoke', AdminsAdd: 'admins.add', AdminsBulk: 'admins.bulk', AdminsDeactivate: 'admins.deactivate', diff --git a/web/tests/RestTestCase.php b/web/tests/RestTestCase.php new file mode 100644 index 000000000..a58851189 --- /dev/null +++ b/web/tests/RestTestCase.php @@ -0,0 +1,74 @@ +|null $body + * @param array $query + */ + protected function rest( + string $method, + string $path, + ?array $body = null, + ?string $token = null, + array $query = [], + ): Response { + $prevServer = $_SERVER; + $prevGet = $_GET; + $_SERVER['REQUEST_METHOD'] = strtoupper($method); + $_SERVER['PATH_INFO'] = $path; + $_SERVER['REQUEST_URI'] = '/api/v1.php' . $path; + $_SERVER['REMOTE_ADDR'] = $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1'; + unset($_SERVER['HTTP_AUTHORIZATION'], $_SERVER['REDIRECT_HTTP_AUTHORIZATION']); + if ($token !== null) { + $_SERVER['HTTP_AUTHORIZATION'] = 'Bearer ' . $token; + } + $_GET = $query; + try { + $raw = $body === null ? null : json_encode($body, JSON_THROW_ON_ERROR); + return FrontController::dispatch($raw); + } finally { + $_SERVER = $prevServer; + $_GET = $prevGet; + } + } + + protected function assertRestError(Response $response, int $status, string $code): void + { + $this->assertSame($status, $response->status, json_encode($response->payload)); + $this->assertSame($code, $response->payload['error']['code'] ?? null, json_encode($response->payload)); + } +} diff --git a/web/tests/api/AccountTest.php b/web/tests/api/AccountTest.php index c06771051..d59b1cd5a 100644 --- a/web/tests/api/AccountTest.php +++ b/web/tests/api/AccountTest.php @@ -254,4 +254,57 @@ public function testChangeEmailRejectsAnonymousCaller(): void ]); $this->assertEnvelopeError($env, 'forbidden'); } + + public function testTokensCreateReturnsSecretOnce(): void + { + $this->loginAsAdmin(); + $env = $this->api('account.tokens_create', [ + 'name' => 'bot', + 'expires_days' => 0, + ]); + $this->assertTrue($env['ok'] ?? false, json_encode($env)); + $this->assertMatchesRegularExpression('/^sbpp_pat_[0-9a-f]{64}$/', $env['data']['token']); + $this->assertSnapshot( + 'account/tokens_create', + $env, + ['data.id', 'data.token', 'data.token_prefix', 'data.created'], + ); + } + + public function testTokensListOmitsSecret(): void + { + $this->loginAsAdmin(); + $this->api('account.tokens_create', ['name' => 'bot', 'expires_days' => 0]); + $env = $this->api('account.tokens_list'); + $this->assertTrue($env['ok'] ?? false, json_encode($env)); + $this->assertCount(1, $env['data']['tokens']); + $this->assertArrayNotHasKey('token', $env['data']['tokens'][0]); + $this->assertArrayNotHasKey('token_hash', $env['data']['tokens'][0]); + $this->assertSame('bot', $env['data']['tokens'][0]['name']); + } + + public function testTokensRevokeRemovesFromList(): void + { + $this->loginAsAdmin(); + $created = $this->api('account.tokens_create', ['name' => 'bot', 'expires_days' => 0]); + $id = (int) $created['data']['id']; + $env = $this->api('account.tokens_revoke', ['id' => $id]); + $this->assertTrue($env['ok'] ?? false, json_encode($env)); + $list = $this->api('account.tokens_list'); + $this->assertSame([], $list['data']['tokens']); + } + + public function testTokensCreateRejectsAnonymous(): void + { + $env = $this->api('account.tokens_create', ['name' => 'bot']); + $this->assertEnvelopeError($env, 'forbidden'); + } + + public function testTokensCreateRejectsEmptyName(): void + { + $this->loginAsAdmin(); + $env = $this->api('account.tokens_create', ['name' => '']); + $this->assertEnvelopeError($env, 'validation'); + $this->assertSame('name', $env['error']['field'] ?? null); + } } diff --git a/web/tests/api/PermissionMatrixTest.php b/web/tests/api/PermissionMatrixTest.php index 2dbee77eb..de68310c0 100644 --- a/web/tests/api/PermissionMatrixTest.php +++ b/web/tests/api/PermissionMatrixTest.php @@ -49,6 +49,9 @@ public static function expectedMatrix(): array 'account.check_srv_password' => ['perm' => 0, 'requireAdmin' => false, 'public' => false], 'account.change_srv_password' => ['perm' => 0, 'requireAdmin' => false, 'public' => false], 'account.change_email' => ['perm' => 0, 'requireAdmin' => false, 'public' => false], + 'account.tokens_list' => ['perm' => 0, 'requireAdmin' => false, 'public' => false], + 'account.tokens_create' => ['perm' => 0, 'requireAdmin' => false, 'public' => false], + 'account.tokens_revoke' => ['perm' => 0, 'requireAdmin' => false, 'public' => false], // -- admins. 'admins.add' => ['perm' => ADMIN_OWNER | ADMIN_ADD_ADMINS, 'requireAdmin' => false, 'public' => false], diff --git a/web/tests/api/RestAdminsTest.php b/web/tests/api/RestAdminsTest.php new file mode 100644 index 000000000..81643c046 --- /dev/null +++ b/web/tests/api/RestAdminsTest.php @@ -0,0 +1,179 @@ +mintToken(); + $response = $this->rest('PUT', '/admins/' . self::NEW_STEAM64, [ + 'name' => 'RestBot', + ], $token); + $this->assertSame(201, $response->status, json_encode($response->payload)); + $data = $response->payload['data']; + $this->assertSame('RestBot', $data['name']); + $this->assertSame(self::NEW_STEAM64, $data['steam64']); + $this->assertTrue($data['enabled']); + $this->assertArrayHasKey('rehash', $response->payload['meta']); + $this->assertArrayHasKey('attempted', $response->payload['meta']['rehash']); + } + + public function testPutSteam64UpdatesExisting(): void + { + $token = $this->mintToken(); + $this->rest('PUT', '/admins/' . self::NEW_STEAM64, ['name' => 'RestBot'], $token); + $response = $this->rest('PUT', '/admins/' . self::NEW_STEAM64, [ + 'name' => 'RestBotUpdated', + 'immunity' => 12, + ], $token); + $this->assertSame(200, $response->status, json_encode($response->payload)); + $this->assertSame('RestBotUpdated', $response->payload['data']['name']); + $this->assertSame(12, $response->payload['data']['immunity']); + } + + public function testPutAidMissingIs404(): void + { + $token = $this->mintToken(); + $response = $this->rest('PUT', '/admins/999999', ['name' => 'Nope'], $token); + $this->assertRestError($response, 404, 'not_found'); + } + + public function testGetByAidAndSteam64(): void + { + $token = $this->mintToken(); + $created = $this->rest('PUT', '/admins/' . self::NEW_STEAM64, ['name' => 'RestBot'], $token); + $aid = (int) $created->payload['data']['id']; + + $byAid = $this->rest('GET', '/admins/' . $aid, token: $token); + $this->assertSame(200, $byAid->status); + $this->assertSame(self::NEW_STEAM64, $byAid->payload['data']['steam64']); + + $bySteam = $this->rest('GET', '/admins/' . self::NEW_STEAM64, token: $token); + $this->assertSame(200, $bySteam->status); + $this->assertSame($aid, $bySteam->payload['data']['id']); + } + + public function testSteam2PathIs400(): void + { + $token = $this->mintToken(); + $response = $this->rest('GET', '/admins/STEAM_0:0:1', token: $token); + $this->assertRestError($response, 400, 'validation'); + $this->assertSame('id', $response->payload['error']['field'] ?? null); + } + + public function testSteam3PathIs400(): void + { + $token = $this->mintToken(); + $response = $this->rest('GET', '/admins/[U:1:1]', token: $token); + $this->assertRestError($response, 400, 'validation'); + } + + public function testSteam64BelowUniverseIs400(): void + { + $token = $this->mintToken(); + $response = $this->rest('PUT', '/admins/70001788202945420', [ + 'name' => 'RestBot', + ], $token); + $this->assertRestError($response, 400, 'validation'); + $this->assertSame('id', $response->payload['error']['field'] ?? null); + } + + public function testBodySteamMismatchIs409(): void + { + $token = $this->mintToken(); + $response = $this->rest('PUT', '/admins/' . self::NEW_STEAM64, [ + 'name' => 'RestBot', + 'steam' => 'STEAM_0:0:0', + ], $token); + $this->assertRestError($response, 409, 'conflict'); + $this->assertSame('steam', $response->payload['error']['field'] ?? null); + } + + public function testDeactivateAndReactivate(): void + { + $token = $this->mintToken(); + $created = $this->rest('PUT', '/admins/' . self::NEW_STEAM64, ['name' => 'RestBot'], $token); + $this->assertSame(201, $created->status, json_encode($created->payload)); + + $deact = $this->rest('POST', '/admins/' . self::NEW_STEAM64 . '/deactivate', [ + 'reason' => 'demote from hub', + ], $token); + $this->assertSame(200, $deact->status, json_encode($deact->payload)); + $this->assertFalse($deact->payload['data']['enabled']); + $this->assertArrayHasKey('rehash', $deact->payload['meta']); + + $got = $this->rest('GET', '/admins/' . self::NEW_STEAM64, token: $token); + $this->assertFalse($got->payload['data']['enabled']); + + $react = $this->rest('POST', '/admins/' . self::NEW_STEAM64 . '/reactivate', [ + 'reason' => 'restore', + ], $token); + $this->assertSame(200, $react->status, json_encode($react->payload)); + $this->assertTrue($react->payload['data']['enabled']); + } + + public function testPutSteam64ReactivatesInactiveAdmin(): void + { + $token = $this->mintToken(); + $this->rest('PUT', '/admins/' . self::NEW_STEAM64, ['name' => 'RestBot'], $token); + $this->rest('POST', '/admins/' . self::NEW_STEAM64 . '/deactivate', ['reason' => 'off'], $token); + + $response = $this->rest('PUT', '/admins/' . self::NEW_STEAM64, [ + 'name' => 'RestBot', + ], $token); + $this->assertSame(200, $response->status, json_encode($response->payload)); + $this->assertTrue($response->payload['data']['enabled']); + } + + public function testCannotDeactivateSelf(): void + { + $token = $this->mintToken(); + $aid = Fixture::adminAid(); + $response = $this->rest('POST', '/admins/' . $aid . '/deactivate', [ + 'reason' => 'oops', + ], $token); + $this->assertRestError($response, 400, 'validation'); + } + + public function testAdminsListHasMeta(): void + { + $token = $this->mintToken(); + $response = $this->rest('GET', '/admins', token: $token, query: ['page' => 1, 'per_page' => 10]); + $this->assertSame(200, $response->status); + $this->assertIsArray($response->payload['data']); + $this->assertSame(1, $response->payload['meta']['page']); + $this->assertSame(10, $response->payload['meta']['per_page']); + $this->assertGreaterThanOrEqual(1, $response->payload['meta']['total']); + } + + public function testGroupsList(): void + { + $token = $this->mintToken(); + $response = $this->rest('GET', '/groups', token: $token); + $this->assertSame(200, $response->status, json_encode($response->payload)); + $this->assertArrayHasKey('web', $response->payload['data']); + $this->assertArrayHasKey('server', $response->payload['data']); + $this->assertIsArray($response->payload['data']['web']); + $this->assertIsArray($response->payload['data']['server']); + } + + public function testSystemRehash(): void + { + $token = $this->mintToken(); + $response = $this->rest('POST', '/system/rehash', [], $token); + $this->assertSame(200, $response->status, json_encode($response->payload)); + $this->assertArrayHasKey('rehash', $response->payload['data']); + $this->assertArrayHasKey('attempted', $response->payload['data']['rehash']); + } + + public function testAdminsListRequiresAuth(): void + { + $response = $this->rest('GET', '/admins'); + $this->assertRestError($response, 401, 'unauthorized'); + } +} diff --git a/web/tests/api/RestAuthTest.php b/web/tests/api/RestAuthTest.php new file mode 100644 index 000000000..c723bf2f9 --- /dev/null +++ b/web/tests/api/RestAuthTest.php @@ -0,0 +1,107 @@ +rest('GET', '/me'); + $this->assertRestError($response, 401, 'unauthorized'); + } + + public function testCookieJwtDoesNotAuthenticateRest(): void + { + $this->loginAsAdmin(); + $response = $this->rest('GET', '/me'); + $this->assertRestError($response, 401, 'unauthorized'); + } + + public function testMeReturnsCallerAdmin(): void + { + $token = $this->mintToken(); + $response = $this->rest('GET', '/me', token: $token); + $this->assertSame(200, $response->status, json_encode($response->payload)); + $data = $response->payload['data']; + $this->assertSame(Fixture::adminAid(), $data['id']); + $this->assertSame('admin', $data['name']); + $this->assertSame('STEAM_0:0:0', $data['steam']); + $this->assertSame('76561197960265728', $data['steam64']); + $this->assertIsString($data['steam64']); + $this->assertArrayNotHasKey('password', $data); + $this->assertArrayNotHasKey('validate', $data); + $this->assertArrayNotHasKey('attempts', $data); + $this->assertArrayNotHasKey('lockout_until', $data); + $this->assertArrayNotHasKey('srv_password', $data); + } + + public function testMalformedSecretIsUnauthorized(): void + { + $response = $this->rest('GET', '/me', token: 'not-a-pat'); + $this->assertRestError($response, 401, 'unauthorized'); + } + + public function testUnknownWellFormedSecretIsUnauthorized(): void + { + $secret = PatAuthenticator::SECRET_PREFIX . str_repeat('ab', 32); + $response = $this->rest('GET', '/me', token: $secret); + $this->assertRestError($response, 401, 'unauthorized'); + } + + public function testRevokedTokenIsUnauthorized(): void + { + $minted = PatAuthenticator::mint(Fixture::adminAid(), 'revoke-me', null); + $this->assertTrue(PatAuthenticator::revoke(Fixture::adminAid(), $minted['id'])); + $response = $this->rest('GET', '/me', token: $minted['secret']); + $this->assertRestError($response, 401, 'unauthorized'); + } + + public function testExpiredTokenIsUnauthorized(): void + { + $token = $this->mintToken(expiresAt: time() - 10); + $response = $this->rest('GET', '/me', token: $token); + $this->assertRestError($response, 401, 'unauthorized'); + } + + public function testDisabledAdminTokenIsUnauthorized(): void + { + $token = $this->mintToken(); + $pdo = Fixture::rawPdo(); + $pdo->prepare(sprintf('UPDATE `%s_admins` SET enabled = 0 WHERE aid = ?', DB_PREFIX)) + ->execute([Fixture::adminAid()]); + $response = $this->rest('GET', '/me', token: $token); + $this->assertRestError($response, 401, 'unauthorized'); + } + + public function testOpenapiIsPublic(): void + { + $response = $this->rest('GET', '/openapi.yaml'); + $this->assertSame(200, $response->status); + $this->assertNotNull($response->rawBody); + $this->assertStringContainsString('openapi:', $response->rawBody); + $this->assertStringContainsString('application/yaml', $response->contentType); + } + + public function testRateLimitReturns429(): void + { + RateLimiter::resetForTests(); + RateLimiter::setLimitForTests(1); + $token = $this->mintToken(); + $first = $this->rest('GET', '/me', token: $token); + $this->assertSame(200, $first->status); + $second = $this->rest('GET', '/me', token: $token); + $this->assertRestError($second, 429, 'rate_limited'); + $this->assertArrayHasKey('Retry-After', $second->headers); + } + + public function testResolveReturnsNullForGarbage(): void + { + $this->assertNull(PatAuthenticator::resolve('')); + $this->assertNull(PatAuthenticator::resolve('sbpp_pat_short')); + $this->assertSame(64, strlen(PatAuthenticator::hash('anything'))); + } +} diff --git a/web/tests/api/RestPermissionMatrixTest.php b/web/tests/api/RestPermissionMatrixTest.php new file mode 100644 index 000000000..a0085845e --- /dev/null +++ b/web/tests/api/RestPermissionMatrixTest.php @@ -0,0 +1,64 @@ + + */ + public static function expectedRoutes(): array + { + $readAdmins = ADMIN_OWNER | ADMIN_LIST_ADMINS | ADMIN_ADD_ADMINS | ADMIN_EDIT_ADMINS; + $writeAdmins = ADMIN_OWNER | ADMIN_ADD_ADMINS | ADMIN_EDIT_ADMINS; + $deleteAdmins = ADMIN_OWNER | ADMIN_DELETE_ADMINS; + $readGroups = ADMIN_OWNER | ADMIN_LIST_GROUPS | ADMIN_ADD_ADMINS | ADMIN_EDIT_ADMINS; + $rehash = ADMIN_OWNER | ADMIN_EDIT_ADMINS | ADMIN_EDIT_GROUPS | ADMIN_ADD_ADMINS; + + return [ + ['method' => 'GET', 'path' => '/openapi.yaml', 'auth' => false, 'perm' => 0], + ['method' => 'GET', 'path' => '/me', 'auth' => true, 'perm' => 0], + ['method' => 'GET', 'path' => '/admins', 'auth' => true, 'perm' => $readAdmins], + ['method' => 'GET', 'path' => '/admins/{id}', 'auth' => true, 'perm' => $readAdmins], + ['method' => 'PUT', 'path' => '/admins/{id}', 'auth' => true, 'perm' => $writeAdmins], + ['method' => 'PATCH', 'path' => '/admins/{id}', 'auth' => true, 'perm' => ADMIN_OWNER | ADMIN_EDIT_ADMINS], + ['method' => 'POST', 'path' => '/admins/{id}/deactivate', 'auth' => true, 'perm' => $deleteAdmins], + ['method' => 'POST', 'path' => '/admins/{id}/reactivate', 'auth' => true, 'perm' => $deleteAdmins], + ['method' => 'DELETE', 'path' => '/admins/{id}', 'auth' => true, 'perm' => $deleteAdmins], + ['method' => 'GET', 'path' => '/groups', 'auth' => true, 'perm' => $readGroups], + ['method' => 'POST', 'path' => '/system/rehash', 'auth' => true, 'perm' => $rehash], + ]; + } + + public function testRegisteredRoutesMatchExpectedMatrix(): void + { + $actual = []; + foreach (Routes::all() as $route) { + $actual[] = [ + 'method' => $route['method'], + 'path' => $route['path'], + 'auth' => $route['auth'], + 'perm' => $route['perm'], + ]; + } + $this->assertSame(self::expectedRoutes(), $actual); + } + + public function testEveryWriteRouteRequiresAuthAndAPermission(): void + { + foreach (Routes::all() as $route) { + if (!in_array($route['method'], ['POST', 'PUT', 'PATCH', 'DELETE'], true)) { + continue; + } + $label = $route['method'] . ' ' . $route['path']; + $this->assertTrue($route['auth'], $label . ' must require a PAT'); + $this->assertNotSame(0, $route['perm'], $label . ' must declare a permission mask'); + } + } +} diff --git a/web/tests/api/__snapshots__/account/tokens_create.json b/web/tests/api/__snapshots__/account/tokens_create.json new file mode 100644 index 000000000..0820ef182 --- /dev/null +++ b/web/tests/api/__snapshots__/account/tokens_create.json @@ -0,0 +1,11 @@ +{ + "ok": true, + "data": { + "id": "<*>", + "name": "bot", + "token": "<*>", + "token_prefix": "<*>", + "created": "<*>", + "expires_at": null + } +} diff --git a/web/tests/api/__snapshots__/views/youraccount_owner.json b/web/tests/api/__snapshots__/views/youraccount_owner.json index fb4ed4b6b..feaa2fcd4 100644 --- a/web/tests/api/__snapshots__/views/youraccount_owner.json +++ b/web/tests/api/__snapshots__/views/youraccount_owner.json @@ -80,6 +80,7 @@ } ], "server_permissions": false, - "min_pass_len": 6 + "min_pass_len": 6, + "api_tokens": [] } } diff --git a/web/tests/bootstrap.php b/web/tests/bootstrap.php index 4df9cf3f8..f25bdf092 100644 --- a/web/tests/bootstrap.php +++ b/web/tests/bootstrap.php @@ -95,6 +95,7 @@ require_once __DIR__ . '/Fixture.php'; require_once __DIR__ . '/ApiTestCase.php'; +require_once __DIR__ . '/RestTestCase.php'; require_once __DIR__ . '/QueryCountAssertions.php'; // DB bring-up is lazy: ApiTestCase::setUp() calls Fixture::reset(), diff --git a/web/tests/e2e/pages/admin/MyAccount.ts b/web/tests/e2e/pages/admin/MyAccount.ts index 610694997..e82f24d88 100644 --- a/web/tests/e2e/pages/admin/MyAccount.ts +++ b/web/tests/e2e/pages/admin/MyAccount.ts @@ -25,4 +25,20 @@ export class MyAccountPage extends BasePage { async goto(): Promise { await super.goto(this.path); } + + get tokensCard(): Locator { + return this.page.locator('[data-testid="account-tokens"]'); + } + + get tokenName(): Locator { + return this.page.locator('[data-testid="account-token-name"]'); + } + + get tokenCreate(): Locator { + return this.page.locator('[data-testid="account-token-create"]'); + } + + get tokenSecret(): Locator { + return this.page.locator('[data-testid="account-token-secret"]'); + } } diff --git a/web/tests/e2e/specs/flows/rest-api.spec.ts b/web/tests/e2e/specs/flows/rest-api.spec.ts new file mode 100644 index 000000000..cfb66e1d1 --- /dev/null +++ b/web/tests/e2e/specs/flows/rest-api.spec.ts @@ -0,0 +1,61 @@ +/** + * REST v1 end-to-end: mint a PAT on Your Account, then call `/api/v1.php`. + * + * PATH_INFO (`/api/v1.php/me`) is used so the spec works without the + * Apache rewrite that needs a web-image rebuild. + * + * Pin to chromium. The flow mutates `:prefix_admins` and + * `:prefix_api_tokens`. Mobile coverage does not add value. + */ +import { test, expect } from '../../fixtures/auth.ts'; +import { MyAccountPage } from '../../pages/admin/MyAccount.ts'; + +test.describe('REST API v1', () => { + test.skip(({ isMobile }) => isMobile, 'flow spec runs only on desktop chromium'); + + test('mints a token, GET /me, PUT admin by Steam64, deactivate', async ({ page, request }) => { + const account = new MyAccountPage(page); + await account.goto(); + await expect(account.pageMounted).toBeVisible(); + await expect(account.tokensCard).toBeVisible(); + + const tokenName = `e2e-rest-${Date.now()}`; + await account.tokenName.fill(tokenName); + await account.tokenCreate.click(); + await expect(account.tokenSecret).toHaveText(/^sbpp_pat_[0-9a-f]{64}$/); + const secret = (await account.tokenSecret.textContent()) ?? ''; + + const me = await request.get('/api/v1.php/me', { + headers: { Authorization: `Bearer ${secret}` }, + }); + expect(me.status()).toBe(200); + const meBody = await me.json(); + expect(meBody.data.name).toBe('admin'); + expect(typeof meBody.data.steam64).toBe('string'); + + const steam64 = `76561198${String(Date.now()).padStart(9, '0').slice(-9)}`; + const put = await request.put(`/api/v1.php/admins/${steam64}`, { + headers: { + Authorization: `Bearer ${secret}`, + 'Content-Type': 'application/json', + }, + data: { name: `E2E Rest ${Date.now()}` }, + }); + expect(put.status(), await put.text()).toBe(201); + const putBody = await put.json(); + expect(putBody.data.steam64).toBe(steam64); + expect(putBody.data.enabled).toBe(true); + expect(putBody.meta.rehash).toBeTruthy(); + + const deact = await request.post(`/api/v1.php/admins/${steam64}/deactivate`, { + headers: { + Authorization: `Bearer ${secret}`, + 'Content-Type': 'application/json', + }, + data: { reason: 'e2e deactivate' }, + }); + expect(deact.status(), await deact.text()).toBe(200); + const deactBody = await deact.json(); + expect(deactBody.data.enabled).toBe(false); + }); +}); diff --git a/web/tests/integration/YourAccountViewTest.php b/web/tests/integration/YourAccountViewTest.php index 9cf699412..63c242297 100644 --- a/web/tests/integration/YourAccountViewTest.php +++ b/web/tests/integration/YourAccountViewTest.php @@ -68,6 +68,7 @@ public function testSeededAdminProducesGroupedPermissionsSnapshot(): void web_permissions_grouped: PermissionCatalog::groupedDisplayFromMask($extraflags), server_permissions: false, min_pass_len: 6, + api_tokens: [], ); // Pull the published shape exactly the way `Renderer::render` @@ -107,6 +108,7 @@ public function testWebPermissionsGroupedShapeMatchesCatalog(): void web_permissions_grouped: PermissionCatalog::groupedDisplayFromMask($extraflags), server_permissions: false, min_pass_len: 6, + api_tokens: [], ); $this->assertCount( @@ -142,6 +144,7 @@ public function testEmptyMaskPublishesEmptyList(): void web_permissions_grouped: PermissionCatalog::groupedDisplayFromMask(0), server_permissions: false, min_pass_len: 6, + api_tokens: [], ); $this->assertSame([], $view->web_permissions_grouped); diff --git a/web/themes/default/page_youraccount.tpl b/web/themes/default/page_youraccount.tpl index 0ffd00bce..4450080ce 100644 --- a/web/themes/default/page_youraccount.tpl +++ b/web/themes/default/page_youraccount.tpl @@ -36,7 +36,7 @@

Your account

-

Permissions, password, server password, and email.

+

Permissions, password, server password, email, and API tokens.

{* @@ -304,6 +304,82 @@ +
+
+
+

API tokens

+

Personal access tokens for the REST API. The secret is shown once when you create it.

+
+
+
+ +
+ {csrf_field} +
+ + + +
+
+ + +
+
+ +
+
+ {if $api_tokens} +
+ + + + + + + + + + + + {foreach from=$api_tokens item=token} + + + + + + + + {/foreach} + +
NamePrefixLast usedExpires
{$token.name}{$token.token_prefix}{if $token.last_used}{$token.last_used|date_format:"%Y-%m-%d"}{else}Never{/if}{if $token.expires_at}{$token.expires_at|date_format:"%Y-%m-%d"}{else}Never{/if} + +
+
+ {else} +

No tokens yet.

+ {/if} +
+
+ {* @@ -587,6 +663,82 @@ }); }); } + + var tokenForm = document.getElementById('account-token-create-form'); + if (tokenForm) { + tokenForm.addEventListener('submit', function (ev) { + ev.preventDefault(); + setMsg('account-token-name-msg', ''); + var name = val('account-token-name'); + var expiryEl = document.getElementById('account-token-expiry'); + var days = expiryEl && 'value' in expiryEl ? parseInt(String(expiryEl.value), 10) : 0; + if (name.length === 0) { + setMsg('account-token-name-msg', 'Give this token a name.'); + return; + } + var createBtn = tokenForm.querySelector('[data-testid="account-token-create"]'); + setBusy(createBtn, true); + sb.api.call(Actions.AccountTokensCreate, { + name: name, + expires_days: days + }).then(function (env) { + setBusy(createBtn, false); + if (env && env.redirect) return; + if (showFieldError('account-token-', env && env.error, { name: 'name' })) return; + if (!env || !env.ok || !env.data) { + flashFailure(env); + return; + } + var wrap = document.getElementById('account-token-secret-wrap'); + var secretEl = document.querySelector('[data-testid="account-token-secret"]'); + var copyBtn = document.querySelector('[data-testid="account-token-copy"]'); + if (wrap && secretEl) { + secretEl.textContent = env.data.token || ''; + wrap.hidden = false; + } + if (copyBtn) copyBtn.setAttribute('data-copy', env.data.token || ''); + if (window.SBPP && typeof window.SBPP.showToast === 'function') { + window.SBPP.showToast({ + kind: 'success', + title: 'Token created', + body: 'Copy the secret now. It will not be shown again.' + }); + } + }); + }); + } + + document.addEventListener('click', function (ev) { + var target = ev.target; + if (!target || !target.closest) return; + var btn = target.closest('[data-action="account-token-revoke"]'); + if (!btn) return; + ev.preventDefault(); + var id = parseInt(btn.getAttribute('data-id') || '0', 10); + var tokenName = btn.getAttribute('data-name') || 'this token'; + var confirmFn = window.SBPP && typeof window.SBPP.confirm === 'function' + ? window.SBPP.confirm + : null; + if (!confirmFn) return; + confirmFn({ + title: 'Revoke token', + body: 'Revoke "' + tokenName + '"? Scripts using it will stop working.', + confirmLabel: 'Revoke', + danger: true + }).then(function (ok) { + if (!ok) return; + setBusy(btn, true); + sb.api.call(Actions.AccountTokensRevoke, { id: id }).then(function (env) { + if (env && env.ok) { + var row = btn.closest('tr'); + if (row) row.remove(); + return; + } + setBusy(btn, false); + flashFailure(env); + }); + }); + }); })(); {/literal} diff --git a/web/updater/data/812.php b/web/updater/data/812.php new file mode 100644 index 000000000..77483f55f --- /dev/null +++ b/web/updater/data/812.php @@ -0,0 +1,30 @@ +dbs` +// reads below are suppressed inline. + +// @phpstan-ignore variable.undefined +$this->dbs->query( + 'CREATE TABLE IF NOT EXISTS `:prefix_api_tokens` (' + . '`id` int(10) UNSIGNED NOT NULL auto_increment,' + . '`aid` int(6) NOT NULL,' + . '`name` varchar(64) NOT NULL,' + . '`token_hash` char(64) NOT NULL,' + . '`token_prefix` varchar(16) NOT NULL,' + . '`created` int(11) NOT NULL,' + . '`last_used` int(11) NULL default NULL,' + . '`expires_at` int(11) NULL default NULL,' + . '`revoked_at` int(11) NULL default NULL,' + . 'PRIMARY KEY (`id`),' + . 'UNIQUE KEY `token_hash` (`token_hash`),' + . 'KEY `aid` (`aid`)' + . ') ENGINE=InnoDB DEFAULT CHARSET=utf8mb4' +); +// @phpstan-ignore variable.undefined +$this->dbs->execute(); + +return true; diff --git a/web/updater/store.json b/web/updater/store.json index 0378c8f28..77bbdce6c 100644 --- a/web/updater/store.json +++ b/web/updater/store.json @@ -49,5 +49,6 @@ "808": "808.php", "809": "809.php", "810": "810.php", - "811": "811.php" + "811": "811.php", + "812": "812.php" } From b4ab131ae4c9ceed704351fc5b3c87acedcc8e08 Mon Sep 17 00:00:00 2001 From: Maximiliano Jabase Date: Mon, 31 Aug 2026 17:51:56 -0300 Subject: [PATCH 02/27] add REST bans and comms for PAT clients --- AGENTS.md | 13 +- ARCHITECTURE.md | 20 +- .../src/content/docs/configuring/rest-api.mdx | 29 +- web/api/openapi-v1.yaml | 443 ++++++++++++++++++ web/includes/Rest/BansService.php | 321 +++++++++++++ web/includes/Rest/CommsService.php | 365 +++++++++++++++ web/includes/Rest/Envelope.php | 4 + web/includes/Rest/FrontController.php | 17 + web/includes/Rest/Kicker.php | 69 +++ web/includes/Rest/PublicVisibility.php | 35 ++ web/includes/Rest/Routes.php | 177 ++++++- web/tests/api/RestAuthTest.php | 13 + web/tests/api/RestBansTest.php | 143 ++++++ web/tests/api/RestCommsTest.php | 134 ++++++ web/tests/api/RestPermissionMatrixTest.php | 12 + web/tests/e2e/specs/flows/rest-api.spec.ts | 48 ++ 16 files changed, 1828 insertions(+), 15 deletions(-) create mode 100644 web/includes/Rest/BansService.php create mode 100644 web/includes/Rest/CommsService.php create mode 100644 web/includes/Rest/Kicker.php create mode 100644 web/includes/Rest/PublicVisibility.php create mode 100644 web/tests/api/RestBansTest.php create mode 100644 web/tests/api/RestCommsTest.php diff --git a/AGENTS.md b/AGENTS.md index 598b69bba..076bbbefa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -968,9 +968,9 @@ fallback). This is a **separate product** from `POST /api.php`. - Tokens inherit the admin's web flags. No extra scopes. Soft-retired (`enabled = 0`) → 401. Password `lockout_until` does not apply. - Writes reuse `Api::invoke()` where the RPC handler already exists - (deactivate/reactivate/remove/rehash). List/get and Steam64 upsert - are dedicated `Sbpp\Rest\*` queries. Discard `__redirect` / chrome - envelopes. + (deactivate/reactivate/remove/rehash, bans.add/unban, comms.add/ + unblock/delete). List/get and Steam64 upsert 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 @@ -978,6 +978,13 @@ fallback). This is a **separate product** from `POST /api.php`. (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`. A + well-formed PAT that fails to resolve is 401. Cookie JWT never + authenticates REST (would leak IPs on public GET). +- POST `/bans` and `/comms` `length` is minutes (0 = permanent). GET + `length` is seconds. Optional `kick: true` on POST `/bans` fans + RCON (`meta.kick`). Unban/unblock require non-empty `ureason`. - 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`, diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e527325f5..704b9ef19 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -87,7 +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) +│ ├── Rest/ Sbpp\Rest\* — REST /api/v1 (FrontController, Router, PatAuthenticator, RateLimiter, Envelope, AdminsService, BansService, CommsService, 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 @@ -323,15 +323,23 @@ GET /api/v1/… -> │ api/v1.php │ -> │ FrontController │ -> │ ignored. 3. Rate limit (file under `SB_CACHE/rest-rl/`, 60 req/min). Authenticated by token id, anonymous by IP. -4. `Router` matches method + path from `Routes::all()`. Writes that +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. -5. Envelope `{data, meta}` / `{error: {code, message, field?}}`. HTTP +6. Envelope `{data, meta}` / `{error: {code, message, field?}}`. HTTP status is load-bearing. -Slice 0 resources: `/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). +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). + +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. Writes require a PAT. POST `/bans` `length` is minutes; +GET `length` is seconds. Optional `kick: true` fans RCON via +`kickit.kick_player` and records `meta.kick`. ### Auth (`includes/Auth/` — `Sbpp\Auth\*`) diff --git a/docs/src/content/docs/configuring/rest-api.mdx b/docs/src/content/docs/configuring/rest-api.mdx index d30508708..64e130fff 100644 --- a/docs/src/content/docs/configuring/rest-api.mdx +++ b/docs/src/content/docs/configuring/rest-api.mdx @@ -114,11 +114,11 @@ define('SB_REST_CORS_ORIGINS', 'https://staff.example.com'); ## Rate limit -Default 60 requests per minute. Anonymous callers (only `GET /openapi.yaml` -in this version) are keyed by IP. Token callers are keyed by token. +Default 60 requests per minute. Anonymous callers (OpenAPI spec, public +ban/comms GET) are keyed by IP. Token callers are keyed by token. A 429 includes `Retry-After`. -## Slice 0 routes +## Slice 0 and 1 routes | Method | Path | Notes | | --- | --- | --- | @@ -132,10 +132,29 @@ A 429 includes `Retry-After`. | DELETE | `/admins/{id}` | Hard delete | | GET | `/groups` | Web + SourceMod groups | | POST | `/system/rehash` | Optional `{ "sids": [1,2] }` | +| GET | `/bans`, `/bans/{bid}` | Public. Hide IP / admin name like the panel | +| POST | `/bans` | `length` is minutes. Optional `kick: true` | +| POST | `/bans/{bid}/unban` | Requires `ureason` | +| GET | `/comms`, `/comms/{cid}` | Public. Hide admin name like the panel | +| POST | `/comms` | `kind`: mute, gag, or silence | +| POST | `/comms/{cid}/unblock` | Requires `ureason` | +| DELETE | `/comms/{cid}` | Hard delete | | GET | `/openapi.yaml` | This spec | -Bans, comms, servers, notes, mods, protests, and settings are later -slices. They are not in this version. +Servers, notes, mods, protests, and settings are later slices. + +GET `/bans` and `/comms` work without a token. The same hide-* settings as +the public banlist apply (`banlist.hideplayerips`, `banlist.hideadminname`). +Send a valid PAT to see IPs and admin names. A well-formed token that is +revoked, expired, or unknown is 401 even on those GETs. + +POST `/bans` `length` is minutes (0 = permanent), matching the panel form. +GET responses use `length` in **seconds** (what is stored). Steam64 in JSON +is always a string. + +POST `/bans` with `"kick": true` runs RCON kicks on enabled servers and +puts a summary in `meta.kick`. Without it, SourceMod still blocks the +player on their next connect. ## nginx snippet diff --git a/web/api/openapi-v1.yaml b/web/api/openapi-v1.yaml index fdfe01543..381281667 100644 --- a/web/api/openapi-v1.yaml +++ b/web/api/openapi-v1.yaml @@ -17,6 +17,8 @@ tags: - name: me - name: admins - name: groups + - name: bans + - name: comms - name: system - name: meta paths: @@ -264,6 +266,228 @@ paths: $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" + /bans: + get: + tags: [bans] + security: [] + summary: List bans + description: > + Public. Anonymous callers follow `banlist.hideplayerips` and + `banlist.hideadminname`. A valid PAT of a web admin sees IPs and + admin names. A well-formed but invalid PAT is 401. + `length` is seconds. Filter with `state`, `search`, `server`. + parameters: + - $ref: "#/components/parameters/page" + - $ref: "#/components/parameters/perPage" + - $ref: "#/components/parameters/search" + - $ref: "#/components/parameters/banState" + - $ref: "#/components/parameters/serverId" + responses: + "200": + description: Paginated ban list + content: + application/json: + schema: + $ref: "#/components/schemas/BanListEnvelope" + "401": + $ref: "#/components/responses/Unauthorized" + post: + tags: [bans] + summary: Create a ban + description: > + `length` is minutes (0 = permanent), same as the panel form. + Optional `kick: true` fans RCON kicks to enabled servers + (`meta.kick`). The plugin still blocks on next connect without it. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/BanWrite" + responses: + "201": + description: Ban created + content: + application/json: + schema: + $ref: "#/components/schemas/BanEnvelope" + "400": + $ref: "#/components/responses/Error" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "409": + $ref: "#/components/responses/Error" + /bans/{bid}: + parameters: + - $ref: "#/components/parameters/banId" + get: + tags: [bans] + security: [] + summary: Get one ban + responses: + "200": + description: Ban resource + content: + application/json: + schema: + $ref: "#/components/schemas/BanEnvelope" + "400": + $ref: "#/components/responses/Error" + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + /bans/{bid}/unban: + parameters: + - $ref: "#/components/parameters/banId" + post: + tags: [bans] + summary: Lift an active ban + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [ureason] + properties: + ureason: + type: string + responses: + "200": + description: Ban after lift + content: + application/json: + schema: + $ref: "#/components/schemas/BanEnvelope" + "400": + $ref: "#/components/responses/Error" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "409": + $ref: "#/components/responses/Error" + /comms: + get: + tags: [comms] + security: [] + summary: List mute and gag rows + description: > + Public. Same hide-admin-name rule as bans. `kind` on the resource + is `mute` (type 1) or `gag` (type 2). Silence is two rows. + parameters: + - $ref: "#/components/parameters/page" + - $ref: "#/components/parameters/perPage" + - $ref: "#/components/parameters/search" + - $ref: "#/components/parameters/commState" + - $ref: "#/components/parameters/serverId" + - name: kind + in: query + schema: + type: string + enum: [mute, gag] + responses: + "200": + description: Paginated comms list + content: + application/json: + schema: + $ref: "#/components/schemas/CommListEnvelope" + "401": + $ref: "#/components/responses/Unauthorized" + post: + tags: [comms] + summary: Create a mute, gag, or silence + description: > + `length` is minutes. `kind` is `mute`, `gag`, or `silence` + (or `type` 1 / 2 / 3). Silence returns both rows. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CommWrite" + responses: + "201": + description: Block created + "400": + $ref: "#/components/responses/Error" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "409": + $ref: "#/components/responses/Error" + /comms/{cid}: + parameters: + - $ref: "#/components/parameters/commId" + get: + tags: [comms] + security: [] + summary: Get one mute or gag row + responses: + "200": + description: Comm resource + content: + application/json: + schema: + $ref: "#/components/schemas/CommEnvelope" + "400": + $ref: "#/components/responses/Error" + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + delete: + tags: [comms] + summary: Hard-delete a mute or gag row + responses: + "200": + description: Deleted + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + /comms/{cid}/unblock: + parameters: + - $ref: "#/components/parameters/commId" + post: + tags: [comms] + summary: Lift an active mute or gag + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [ureason] + properties: + ureason: + type: string + responses: + "200": + description: Block after lift + content: + application/json: + schema: + $ref: "#/components/schemas/CommEnvelope" + "400": + $ref: "#/components/responses/Error" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "409": + $ref: "#/components/responses/Error" components: securitySchemes: bearerAuth: @@ -293,6 +517,44 @@ components: minimum: 1 maximum: 100 default: 30 + search: + name: search + in: query + schema: + type: string + description: Match name, SteamID, reason (and IP when the caller can see IPs). + banState: + name: state + in: query + schema: + type: string + enum: [permanent, active, expired, unbanned] + commState: + name: state + in: query + schema: + type: string + enum: [permanent, active, expired, unmuted] + serverId: + name: server + in: query + schema: + type: integer + minimum: 1 + banId: + name: bid + in: path + required: true + schema: + type: integer + minimum: 1 + commId: + name: cid + in: path + required: true + schema: + type: integer + minimum: 1 schemas: Error: type: object @@ -430,6 +692,187 @@ components: type: string immunity: type: integer + Ban: + type: object + properties: + id: + type: integer + player_name: + type: string + type: + type: string + enum: [steam, ip] + steam: + type: string + nullable: true + steam64: + type: string + nullable: true + description: Decimal string. Never a JSON number. + ip: + type: string + nullable: true + reason: + type: string + created: + type: integer + ends: + type: integer + length: + type: integer + description: Seconds stored on the row. + state: + type: string + enum: [permanent, active, expired, unbanned] + admin_name: + type: string + nullable: true + server_id: + type: integer + nullable: true + unban_reason: + type: string + nullable: true + removed_at: + type: integer + nullable: true + BanWrite: + type: object + properties: + steam: + type: string + type: + description: 0 / steam or 1 / ip + oneOf: + - type: integer + enum: [0, 1] + - type: string + enum: [steam, ip] + ip: + type: string + name: + type: string + reason: + type: string + length: + type: integer + minimum: 0 + description: Minutes. 0 is permanent. + kick: + type: boolean + description: Fan RCON kicks to enabled servers after insert. + BanEnvelope: + type: object + required: [data] + properties: + data: + $ref: "#/components/schemas/Ban" + meta: + type: object + properties: + kick: + type: object + BanListEnvelope: + type: object + required: [data, meta] + properties: + data: + type: array + items: + $ref: "#/components/schemas/Ban" + meta: + type: object + properties: + page: + type: integer + per_page: + type: integer + total: + type: integer + Comm: + type: object + properties: + id: + type: integer + player_name: + type: string + kind: + type: string + enum: [mute, gag, unknown] + steam: + type: string + nullable: true + steam64: + type: string + nullable: true + reason: + type: string + created: + type: integer + ends: + type: integer + length: + type: integer + description: Seconds stored on the row. + state: + type: string + enum: [permanent, active, expired, unmuted] + admin_name: + type: string + nullable: true + server_id: + type: integer + nullable: true + unblock_reason: + type: string + nullable: true + removed_at: + type: integer + nullable: true + CommWrite: + type: object + required: [steam] + properties: + steam: + type: string + kind: + type: string + enum: [mute, gag, silence] + type: + type: integer + enum: [1, 2, 3] + description: 1 mute, 2 gag, 3 silence. Prefer kind. + name: + type: string + reason: + type: string + length: + type: integer + minimum: 0 + description: Minutes. 0 is permanent. + CommEnvelope: + type: object + required: [data] + properties: + data: + $ref: "#/components/schemas/Comm" + CommListEnvelope: + type: object + required: [data, meta] + properties: + data: + type: array + items: + $ref: "#/components/schemas/Comm" + meta: + type: object + properties: + page: + type: integer + per_page: + type: integer + total: + type: integer responses: Error: description: Structured error diff --git a/web/includes/Rest/BansService.php b/web/includes/Rest/BansService.php new file mode 100644 index 000000000..00f8cfb6f --- /dev/null +++ b/web/includes/Rest/BansService.php @@ -0,0 +1,321 @@ + $query + * @return array{data: list>, meta: array{page: int, per_page: int, total: int}} + */ + public function list(array $query): array + { + PruneBans(); + $page = max(1, (int) ($query['page'] ?? 1)); + $perPage = (int) ($query['per_page'] ?? 30); + if ($perPage < 1) { + $perPage = 30; + } + $perPage = min(100, $perPage); + $offset = ($page - 1) * $perPage; + + [$whereSql, $binds] = $this->filters($query); + $pdo = $this->db(); + + $countSql = 'SELECT COUNT(*) AS c FROM `:prefix_bans` AS BA WHERE ' . $whereSql; + $pdo->query($countSql); + foreach ($binds as $name => $value) { + $pdo->bind($name, $value); + } + $countRow = $pdo->single(); + $total = is_array($countRow) ? (int) ($countRow['c'] ?? 0) : 0; + + $pdo->query( + $this->selectSql() + . ' WHERE ' . $whereSql + . ' ORDER BY BA.created DESC, BA.bid DESC' + . ' LIMIT :lim OFFSET :off' + ); + foreach ($binds as $name => $value) { + $pdo->bind($name, $value); + } + $pdo->bind(':lim', $perPage); + $pdo->bind(':off', $offset); + $rows = $pdo->resultset(); + + $data = []; + foreach ($rows as $row) { + $data[] = $this->toResource($row); + } + + return [ + 'data' => $data, + 'meta' => ['page' => $page, 'per_page' => $perPage, 'total' => $total], + ]; + } + + /** + * @return array + */ + public function get(int $bid): array + { + PruneBans(); + if ($bid <= 0) { + throw new ApiError('validation', 'Ban id must be a positive integer.', 'bid', 400); + } + $pdo = $this->db(); + $pdo->query($this->selectSql() . ' WHERE BA.bid = :bid'); + $pdo->bind(':bid', $bid); + $row = $pdo->single(); + if (!is_array($row)) { + throw new ApiError('not_found', 'Ban not found.', null, 404); + } + return $this->toResource($row); + } + + /** + * @param array $body + * @return array{ban: array, kick: array|null} + */ + public function create(array $body): array + { + $banType = $this->bodyBanType($body); + $params = [ + 'nickname' => (string) ($body['name'] ?? $body['nickname'] ?? ''), + 'type' => $banType->value, + 'steam' => trim((string) ($body['steam'] ?? '')), + 'ip' => (string) ($body['ip'] ?? ''), + 'length' => (int) ($body['length'] ?? 0), + 'reason' => (string) ($body['reason'] ?? ''), + 'dfile' => '', + 'dname' => '', + 'fromsub' => 0, + ]; + $out = Api::invoke('bans.add', $params); + $bid = (int) ($out['bid'] ?? 0); + if ($bid <= 0) { + throw new ApiError('server_error', 'Ban was not created.', null, 500); + } + + $kickMeta = null; + if ($this->wantsKick($body)) { + $kickit = is_array($out['kickit'] ?? null) ? $out['kickit'] : []; + $check = (string) ($kickit['check'] ?? ''); + if ($check === '') { + $row = $this->get($bid); + $check = $banType === BanType::Steam + ? (string) ($row['steam'] ?? '') + : (string) ($row['ip'] ?? ''); + } + if ($check !== '') { + try { + $kickMeta = Kicker::fanOut($check, $banType->value); + } catch (ApiError $e) { + $kickMeta = ['error' => $e->errorCode, 'message' => $e->getMessage()]; + } + } + } + + return ['ban' => $this->get($bid), 'kick' => $kickMeta]; + } + + /** + * @return array + */ + public function unban(int $bid, string $ureason): array + { + if ($bid <= 0) { + throw new ApiError('validation', 'Ban id must be a positive integer.', 'bid', 400); + } + Api::invoke('bans.unban', ['bid' => $bid, 'ureason' => $ureason]); + return $this->get($bid); + } + + /** + * @param array $query + * @return array{0: string, 1: array} + */ + private function filters(array $query): array + { + $where = ['1=1']; + $binds = []; + + $state = (string) ($query['state'] ?? ''); + $fragment = $this->stateFragment($state); + if ($fragment !== null) { + $where[] = $fragment; + } + + $sid = (int) ($query['server'] ?? 0); + if ($sid > 0) { + $where[] = 'BA.sid = :filter_sid'; + $binds[':filter_sid'] = $sid; + } + + $searchText = trim((string) ($query['search'] ?? '')); + if ($searchText !== '') { + $authidPattern = SteamID::toSearchPattern($searchText); + try { + if (SteamID::isValidID($searchText)) { + $converted = SteamID::toSteam2($searchText); + if (is_string($converted) && $converted !== '') { + $searchText = $converted; + } + } + } catch (\Exception) { + } + $like = '%' . $searchText . '%'; + $parts = []; + if (!PublicVisibility::hidePlayerIps()) { + $parts[] = 'BA.ip LIKE :search_ip'; + $binds[':search_ip'] = $like; + } + if ($authidPattern !== null) { + $parts[] = 'BA.authid REGEXP :search_auth'; + $binds[':search_auth'] = $authidPattern; + } else { + $parts[] = 'BA.authid LIKE :search_auth'; + $binds[':search_auth'] = $like; + } + $parts[] = 'BA.name LIKE :search_name'; + $binds[':search_name'] = $like; + $parts[] = 'BA.reason LIKE :search_reason'; + $binds[':search_reason'] = $like; + $where[] = '(' . implode(' OR ', $parts) . ')'; + } + + return [implode(' AND ', $where), $binds]; + } + + private function stateFragment(string $state): ?string + { + return match ($state) { + 'permanent' => '(BA.RemoveType IS NULL AND BA.RemovedOn IS NULL AND BA.length = 0)', + 'active' => '(BA.RemoveType IS NULL AND BA.RemovedOn IS NULL AND (BA.length = 0 OR BA.ends > UNIX_TIMESTAMP()))', + 'expired' => "(BA.RemoveType = 'E'" + . ' OR (BA.RemoveType IS NULL AND BA.length > 0 AND BA.ends < UNIX_TIMESTAMP() AND BA.RemovedOn IS NULL)' + . ' OR (BA.RemoveType IS NULL AND BA.RemovedOn IS NOT NULL AND BA.length > 0 AND (BA.RemovedBy IS NULL OR BA.RemovedBy = 0)))', + 'unbanned' => "(BA.RemoveType IN ('D', 'U') OR (BA.RemovedOn IS NOT NULL AND BA.RemoveType IS NULL AND BA.RemovedBy IS NOT NULL AND BA.RemovedBy > 0))", + default => null, + }; + } + + private function selectSql(): string + { + return 'SELECT BA.bid, BA.type, BA.ip, BA.authid, BA.name, BA.created, BA.ends, BA.length,' + . ' BA.reason, BA.sid, BA.RemovedOn, BA.RemovedBy, BA.RemoveType, BA.ureason,' + . ' COALESCE(NULLIF(BA.admin_name, \'\'), AD.user) AS admin_name' + . ' FROM `:prefix_bans` AS BA' + . ' LEFT JOIN `:prefix_admins` AS AD ON BA.aid = AD.aid'; + } + + /** + * @param array $row + * @return array + */ + private function toResource(array $row): array + { + $banType = BanType::tryFrom((int) $row['type']) ?? BanType::Steam; + $authid = (string) ($row['authid'] ?? ''); + $banIp = (string) ($row['ip'] ?? ''); + $created = (int) $row['created']; + $length = (int) $row['length']; + $ends = (int) $row['ends']; + $removedOn = $row['RemovedOn'] !== null ? (int) $row['RemovedOn'] : null; + $removedByInt = $row['RemovedBy'] !== null ? (int) $row['RemovedBy'] : 0; + $removal = BanRemoval::tryFrom((string) ($row['RemoveType'] ?? '')); + $isPre2AdminLift = $removal === null && $removedOn !== null && $removedByInt > 0; + + $state = match (true) { + $removal === BanRemoval::Unbanned, $removal === BanRemoval::Deleted => 'unbanned', + $removal === BanRemoval::Expired => 'expired', + $isPre2AdminLift => 'unbanned', + $length === 0 => 'permanent', + $ends > 0 && $ends < time() => 'expired', + default => 'active', + }; + + $steam2 = ($authid !== '' && SteamID::isValidID($authid)) ? $authid : null; + $steam64 = null; + if ($steam2 !== null) { + $converted = SteamID::toSteam64($steam2); + if ($converted !== false && $converted !== null && $converted !== '') { + $steam64 = (string) $converted; + } + } + + $hideIps = PublicVisibility::hidePlayerIps(); + $hideAdmin = PublicVisibility::hideAdminName(); + $sid = (int) ($row['sid'] ?? 0); + + $unbanReason = null; + if ($state === 'unbanned' && !$hideAdmin) { + $raw = trim((string) ($row['ureason'] ?? '')); + $unbanReason = $raw !== '' ? $raw : null; + } + + return [ + 'id' => (int) $row['bid'], + 'player_name' => (string) ($row['name'] ?? ''), + 'type' => $banType === BanType::Ip ? 'ip' : 'steam', + 'steam' => $steam2, + 'steam64' => $steam64, + 'ip' => $hideIps || $banIp === '' ? null : $banIp, + 'reason' => (string) ($row['reason'] ?? ''), + 'created' => $created, + 'ends' => $ends, + 'length' => $length, + 'state' => $state, + 'admin_name' => $hideAdmin ? null : (string) ($row['admin_name'] ?? ''), + 'server_id' => $sid > 0 ? $sid : null, + 'unban_reason' => $unbanReason, + 'removed_at' => $hideAdmin ? null : $removedOn, + ]; + } + + /** + * @param array $body + */ + private function bodyBanType(array $body): BanType + { + $raw = $body['type'] ?? 0; + if (is_string($raw)) { + return strtolower($raw) === 'ip' ? BanType::Ip : BanType::Steam; + } + return BanType::tryFrom((int) $raw) ?? BanType::Steam; + } + + /** + * @param array $body + */ + private function wantsKick(array $body): bool + { + $kick = $body['kick'] ?? false; + return $kick === true || $kick === 1; + } + + private function db(): Database + { + $pdo = $GLOBALS['PDO'] ?? null; + if ($pdo instanceof Database) { + return $pdo; + } + return new Database(DB_HOST, (int) DB_PORT, DB_NAME, DB_USER, DB_PASS, DB_PREFIX, DB_CHARSET); + } +} diff --git a/web/includes/Rest/CommsService.php b/web/includes/Rest/CommsService.php new file mode 100644 index 000000000..5e1da0045 --- /dev/null +++ b/web/includes/Rest/CommsService.php @@ -0,0 +1,365 @@ + $query + * @return array{data: list>, meta: array{page: int, per_page: int, total: int}} + */ + public function list(array $query): array + { + PruneComms(); + $page = max(1, (int) ($query['page'] ?? 1)); + $perPage = (int) ($query['per_page'] ?? 30); + if ($perPage < 1) { + $perPage = 30; + } + $perPage = min(100, $perPage); + $offset = ($page - 1) * $perPage; + + [$whereSql, $binds] = $this->filters($query); + $pdo = $this->db(); + + $pdo->query('SELECT COUNT(*) AS c FROM `:prefix_comms` AS C WHERE ' . $whereSql); + foreach ($binds as $name => $value) { + $pdo->bind($name, $value); + } + $countRow = $pdo->single(); + $total = is_array($countRow) ? (int) ($countRow['c'] ?? 0) : 0; + + $pdo->query( + $this->selectSql() + . ' WHERE ' . $whereSql + . ' ORDER BY C.created DESC, C.bid DESC' + . ' LIMIT :lim OFFSET :off' + ); + foreach ($binds as $name => $value) { + $pdo->bind($name, $value); + } + $pdo->bind(':lim', $perPage); + $pdo->bind(':off', $offset); + $rows = $pdo->resultset(); + + $data = []; + foreach ($rows as $row) { + $data[] = $this->toResource($row); + } + + return [ + 'data' => $data, + 'meta' => ['page' => $page, 'per_page' => $perPage, 'total' => $total], + ]; + } + + /** + * @return array + */ + public function get(int $cid): array + { + PruneComms(); + if ($cid <= 0) { + throw new ApiError('validation', 'Block id must be a positive integer.', 'cid', 400); + } + $pdo = $this->db(); + $pdo->query($this->selectSql() . ' WHERE C.bid = :cid'); + $pdo->bind(':cid', $cid); + $row = $pdo->single(); + if (!is_array($row)) { + throw new ApiError('not_found', 'Block not found.', null, 404); + } + return $this->toResource($row); + } + + /** + * @param array $body + * @return array + */ + public function create(array $body): array + { + $type = $this->bodyType($body); + $steam = trim((string) ($body['steam'] ?? '')); + $params = [ + 'nickname' => (string) ($body['name'] ?? $body['nickname'] ?? ''), + 'type' => $type, + 'steam' => $steam, + 'length' => (int) ($body['length'] ?? 0), + 'reason' => (string) ($body['reason'] ?? ''), + ]; + + $pdo = $this->db(); + $beforeRow = $pdo->query('SELECT MAX(bid) AS m FROM `:prefix_comms`')->single(); + $before = is_array($beforeRow) ? (int) ($beforeRow['m'] ?? 0) : 0; + + Api::invoke('comms.add', $params); + + $created = $this->rowsAfter($before, $steam); + if ($created === []) { + throw new ApiError('server_error', 'Block was not created.', null, 500); + } + if (count($created) === 1) { + return $created[0]; + } + $byKind = []; + foreach ($created as $block) { + $byKind[(string) $block['kind']] = $block; + } + return [ + 'kind' => 'silence', + 'blocks' => $created, + 'mute' => $byKind['mute'] ?? null, + 'gag' => $byKind['gag'] ?? null, + ]; + } + + /** + * @return array + */ + public function unblock(int $cid, string $ureason): array + { + if ($cid <= 0) { + throw new ApiError('validation', 'Block id must be a positive integer.', 'cid', 400); + } + Api::invoke('comms.unblock', ['bid' => $cid, 'ureason' => $ureason]); + return $this->get($cid); + } + + /** + * @return array{id: int, deleted: true} + */ + public function delete(int $cid): array + { + if ($cid <= 0) { + throw new ApiError('validation', 'Block id must be a positive integer.', 'cid', 400); + } + Api::invoke('comms.delete', ['bid' => $cid]); + return ['id' => $cid, 'deleted' => true]; + } + + /** + * @param array $query + * @return array{0: string, 1: array} + */ + private function filters(array $query): array + { + $where = ['1=1']; + $binds = []; + + $state = (string) ($query['state'] ?? ''); + $fragment = $this->stateFragment($state); + if ($fragment !== null) { + $where[] = $fragment; + } + + $sid = (int) ($query['server'] ?? 0); + if ($sid > 0) { + $where[] = 'C.sid = :filter_sid'; + $binds[':filter_sid'] = $sid; + } + + $kind = strtolower(trim((string) ($query['kind'] ?? ''))); + if ($kind === 'mute') { + $where[] = 'C.type = 1'; + } elseif ($kind === 'gag') { + $where[] = 'C.type = 2'; + } + + $searchText = trim((string) ($query['search'] ?? '')); + if ($searchText !== '') { + $authidPattern = SteamID::toSearchPattern($searchText); + try { + if (SteamID::isValidID($searchText)) { + $converted = SteamID::toSteam2($searchText); + if (is_string($converted) && $converted !== '') { + $searchText = $converted; + } + } + } catch (\Exception) { + } + $like = '%' . $searchText . '%'; + $parts = []; + if ($authidPattern !== null) { + $parts[] = 'C.authid REGEXP :search_auth'; + $binds[':search_auth'] = $authidPattern; + } else { + $parts[] = 'C.authid LIKE :search_auth'; + $binds[':search_auth'] = $like; + } + $parts[] = 'C.name LIKE :search_name'; + $binds[':search_name'] = $like; + $parts[] = 'C.reason LIKE :search_reason'; + $binds[':search_reason'] = $like; + $where[] = '(' . implode(' OR ', $parts) . ')'; + } + + return [implode(' AND ', $where), $binds]; + } + + private function stateFragment(string $state): ?string + { + return match ($state) { + 'permanent' => '(C.RemoveType IS NULL AND C.RemovedOn IS NULL AND C.length = 0)', + 'active' => '(C.RemoveType IS NULL AND C.RemovedOn IS NULL AND (C.length = 0 OR C.ends > UNIX_TIMESTAMP()))', + 'expired' => "(C.RemoveType = 'E'" + . ' OR (C.RemoveType IS NULL AND C.length > 0 AND C.ends < UNIX_TIMESTAMP() AND C.RemovedOn IS NULL)' + . ' OR (C.RemoveType IS NULL AND C.RemovedOn IS NOT NULL AND C.length > 0 AND (C.RemovedBy IS NULL OR C.RemovedBy = 0)))', + 'unmuted', 'unbanned' => "(C.RemoveType IN ('D', 'U') OR (C.RemovedOn IS NOT NULL AND C.RemoveType IS NULL AND C.RemovedBy IS NOT NULL AND C.RemovedBy > 0))", + default => null, + }; + } + + private function selectSql(): string + { + return 'SELECT C.bid, C.type, C.authid, C.name, C.created, C.ends, C.length,' + . ' C.reason, C.sid, C.RemovedOn, C.RemovedBy, C.RemoveType, C.ureason,' + . ' COALESCE(NULLIF(C.admin_name, \'\'), AD.user) AS admin_name' + . ' FROM `:prefix_comms` AS C' + . ' LEFT JOIN `:prefix_admins` AS AD ON C.aid = AD.aid'; + } + + /** + * @param array $row + * @return array + */ + private function toResource(array $row): array + { + $type = (int) $row['type']; + $kind = match ($type) { + 1 => 'mute', + 2 => 'gag', + default => 'unknown', + }; + $authid = (string) ($row['authid'] ?? ''); + $created = (int) $row['created']; + $length = (int) $row['length']; + $ends = (int) $row['ends']; + $removedOn = $row['RemovedOn'] !== null ? (int) $row['RemovedOn'] : null; + $removedByInt = $row['RemovedBy'] !== null ? (int) $row['RemovedBy'] : 0; + $removal = BanRemoval::tryFrom((string) ($row['RemoveType'] ?? '')); + $isPre2AdminLift = $removal === null && $removedOn !== null && $removedByInt > 0; + + $state = match (true) { + $removal === BanRemoval::Unbanned, $removal === BanRemoval::Deleted => 'unmuted', + $removal === BanRemoval::Expired => 'expired', + $isPre2AdminLift => 'unmuted', + $length === 0 => 'permanent', + $ends > 0 && $ends < time() => 'expired', + default => 'active', + }; + + $steam2 = ($authid !== '' && SteamID::isValidID($authid)) ? $authid : null; + $steam64 = null; + if ($steam2 !== null) { + $converted = SteamID::toSteam64($steam2); + if ($converted !== false && $converted !== null && $converted !== '') { + $steam64 = (string) $converted; + } + } + + $hideAdmin = PublicVisibility::hideAdminName(); + $sid = (int) ($row['sid'] ?? 0); + $unblockReason = null; + if ($state === 'unmuted' && !$hideAdmin) { + $raw = trim((string) ($row['ureason'] ?? '')); + $unblockReason = $raw !== '' ? $raw : null; + } + + return [ + 'id' => (int) $row['bid'], + 'player_name' => (string) ($row['name'] ?? ''), + 'kind' => $kind, + 'steam' => $steam2, + 'steam64' => $steam64, + 'reason' => (string) ($row['reason'] ?? ''), + 'created' => $created, + 'ends' => $ends, + 'length' => $length, + 'state' => $state, + 'admin_name' => $hideAdmin ? null : (string) ($row['admin_name'] ?? ''), + 'server_id' => $sid > 0 ? $sid : null, + 'unblock_reason' => $unblockReason, + 'removed_at' => $hideAdmin ? null : $removedOn, + ]; + } + + /** + * @param array $body + */ + private function bodyType(array $body): int + { + $kind = strtolower(trim((string) ($body['kind'] ?? ''))); + if ($kind !== '') { + return match ($kind) { + 'mute' => 1, + 'gag' => 2, + 'silence' => 3, + default => throw new ApiError('validation', 'kind must be mute, gag, or silence.', 'kind', 400), + }; + } + $type = (int) ($body['type'] ?? 0); + if (!in_array($type, [1, 2, 3], true)) { + throw new ApiError('validation', 'type must be 1 (mute), 2 (gag), or 3 (silence).', 'type', 400); + } + return $type; + } + + /** + * @return list> + */ + private function rowsAfter(int $before, string $rawSteam): array + { + $steam2 = $rawSteam; + if ($rawSteam !== '' && SteamID::isValidID($rawSteam)) { + $converted = SteamID::toSteam2($rawSteam); + if (is_string($converted) && $converted !== '') { + $steam2 = $converted; + } + } + /** @var UserManager $userbank */ + $userbank = $GLOBALS['userbank']; + $aid = $userbank->GetAid(); + $pdo = $this->db(); + $pdo->query( + $this->selectSql() + . ' WHERE C.bid > :before AND C.authid = :authid AND C.aid = :aid' + . ' ORDER BY C.bid ASC' + ); + $pdo->bind(':before', $before); + $pdo->bind(':authid', $steam2); + $pdo->bind(':aid', $aid); + $rows = $pdo->resultset(); + $out = []; + foreach ($rows as $row) { + $out[] = $this->toResource($row); + } + return $out; + } + + private function db(): Database + { + $pdo = $GLOBALS['PDO'] ?? null; + if ($pdo instanceof Database) { + return $pdo; + } + return new Database(DB_HOST, (int) DB_PORT, DB_NAME, DB_USER, DB_PASS, DB_PREFIX, DB_CHARSET); + } +} diff --git a/web/includes/Rest/Envelope.php b/web/includes/Rest/Envelope.php index 02ac8b7a4..f76bd9c86 100644 --- a/web/includes/Rest/Envelope.php +++ b/web/includes/Rest/Envelope.php @@ -79,6 +79,10 @@ private static function statusForCode(string $code): int 'cannot_deactivate_owner', 'already_inactive', 'already_active', + 'already_banned', + 'already_blocked', + 'not_active', + 'immune', 'conflict' => 409, default => 400, }; diff --git a/web/includes/Rest/FrontController.php b/web/includes/Rest/FrontController.php index e6796d9fa..66ce6b0db 100644 --- a/web/includes/Rest/FrontController.php +++ b/web/includes/Rest/FrontController.php @@ -71,6 +71,10 @@ public static function dispatch(?string $rawBody = null): Response $route = $matched['route']; $params = $matched['params']; + if ($identity === null && self::presentedWellFormedPat()) { + return Envelope::error('unauthorized', 'A valid API token is required.', 401, null, $rlHeaders); + } + if ($route['auth']) { /** @var UserManager $userbank */ $userbank = $GLOBALS['userbank']; @@ -109,6 +113,19 @@ public static function dispatch(?string $rawBody = null): Response } } + /** + * A well-formed PAT that did not bind is 401 on every route, including + * public GET. Missing or junk Authorization stays anonymous. + */ + private static function presentedWellFormedPat(): bool + { + $header = PatAuthenticator::authorizationHeader(); + if (preg_match('/^Bearer\s+(\S+)/i', trim($header), $m) !== 1) { + return false; + } + return PatAuthenticator::isWellFormedSecret($m[1]); + } + public static function requestPath(): string { $pathInfo = $_SERVER['PATH_INFO'] ?? ''; diff --git a/web/includes/Rest/Kicker.php b/web/includes/Rest/Kicker.php new file mode 100644 index 000000000..8661b1518 --- /dev/null +++ b/web/includes/Rest/Kicker.php @@ -0,0 +1,69 @@ +} + */ + public static function fanOut(string $check, int $type): array + { + $loaded = Api::invoke('kickit.load_servers', []); + $servers = $loaded['servers'] ?? []; + if (!is_array($servers)) { + return ['attempted' => 0, 'results' => []]; + } + + $results = []; + $attempted = 0; + foreach ($servers as $server) { + if (!is_array($server)) { + continue; + } + $sid = (int) ($server['sid'] ?? 0); + if ($sid <= 0) { + continue; + } + if (empty($server['has_rcon'])) { + $results[] = ['sid' => $sid, 'status' => 'no_rcon']; + continue; + } + $attempted++; + try { + $kick = Api::invoke('kickit.kick_player', [ + 'check' => $check, + 'sid' => $sid, + 'num' => (int) ($server['num'] ?? 0), + 'type' => $type, + 'mode' => 'ban', + ]); + $results[] = [ + 'sid' => $sid, + 'status' => (string) ($kick['status'] ?? 'unknown'), + ]; + } catch (ApiError $e) { + $results[] = [ + 'sid' => $sid, + 'status' => 'error', + 'code' => $e->errorCode, + ]; + } + } + + return ['attempted' => $attempted, 'results' => $results]; + } +} diff --git a/web/includes/Rest/PublicVisibility.php b/web/includes/Rest/PublicVisibility.php new file mode 100644 index 000000000..c01f25998 --- /dev/null +++ b/web/includes/Rest/PublicVisibility.php @@ -0,0 +1,35 @@ +is_admin(); + } +} diff --git a/web/includes/Rest/Routes.php b/web/includes/Rest/Routes.php index 993cc614e..de4f580a0 100644 --- a/web/includes/Rest/Routes.php +++ b/web/includes/Rest/Routes.php @@ -12,7 +12,7 @@ use WebPermission; /** - * Slice 0 REST route table. Reviewable like `api/handlers/_register.php`. + * REST v1 route table. Reviewable like `api/handlers/_register.php`. * * @phpstan-import-type Route from Router */ @@ -28,6 +28,9 @@ public static function all(): array $deleteAdmins = ADMIN_OWNER | ADMIN_DELETE_ADMINS; $readGroups = ADMIN_OWNER | ADMIN_LIST_GROUPS | ADMIN_ADD_ADMINS | ADMIN_EDIT_ADMINS; $rehash = ADMIN_OWNER | ADMIN_EDIT_ADMINS | ADMIN_EDIT_GROUPS | ADMIN_ADD_ADMINS; + $addBan = ADMIN_OWNER | ADMIN_ADD_BAN; + $unban = ADMIN_OWNER | ADMIN_UNBAN | ADMIN_UNBAN_OWN_BANS | ADMIN_UNBAN_GROUP_BANS; + $deleteBan = ADMIN_OWNER | ADMIN_DELETE_BAN; return [ [ @@ -107,6 +110,69 @@ public static function all(): array 'perm' => $rehash, 'handler' => self::systemRehash(...), ], + [ + 'method' => 'GET', + 'path' => '/bans', + 'auth' => false, + 'perm' => 0, + 'handler' => self::bansList(...), + ], + [ + 'method' => 'POST', + 'path' => '/bans', + 'auth' => true, + 'perm' => $addBan, + 'handler' => self::bansCreate(...), + ], + [ + 'method' => 'GET', + 'path' => '/bans/{bid}', + 'auth' => false, + 'perm' => 0, + 'handler' => self::bansGet(...), + ], + [ + 'method' => 'POST', + 'path' => '/bans/{bid}/unban', + 'auth' => true, + 'perm' => $unban, + 'handler' => self::bansUnban(...), + ], + [ + 'method' => 'GET', + 'path' => '/comms', + 'auth' => false, + 'perm' => 0, + 'handler' => self::commsList(...), + ], + [ + 'method' => 'POST', + 'path' => '/comms', + 'auth' => true, + 'perm' => $addBan, + 'handler' => self::commsCreate(...), + ], + [ + 'method' => 'GET', + 'path' => '/comms/{cid}', + 'auth' => false, + 'perm' => 0, + 'handler' => self::commsGet(...), + ], + [ + 'method' => 'POST', + 'path' => '/comms/{cid}/unblock', + 'auth' => true, + 'perm' => $unban, + 'handler' => self::commsUnblock(...), + ], + [ + 'method' => 'DELETE', + 'path' => '/comms/{cid}', + 'auth' => true, + 'perm' => $deleteBan, + 'handler' => self::commsDelete(...), + ], ]; } @@ -253,6 +319,115 @@ private static function systemRehash(array $params, array $body, array $query): return Envelope::ok(['rehash' => Rehasher::run($sids)]); } + /** + * @param array $params + * @param array $body + * @param array $query + */ + private static function bansList(array $params, array $body, array $query): Response + { + $result = (new BansService())->list($query); + return Envelope::ok($result['data'], $result['meta']); + } + + /** + * @param array $params + * @param array $body + * @param array $query + */ + private static function bansGet(array $params, array $body, array $query): Response + { + return Envelope::ok((new BansService())->get(self::positiveId($params['bid'] ?? '', 'bid'))); + } + + /** + * @param array $params + * @param array $body + * @param array $query + */ + private static function bansCreate(array $params, array $body, array $query): Response + { + $result = (new BansService())->create($body); + $meta = []; + if ($result['kick'] !== null) { + $meta['kick'] = $result['kick']; + } + return Envelope::ok($result['ban'], $meta, 201); + } + + /** + * @param array $params + * @param array $body + * @param array $query + */ + private static function bansUnban(array $params, array $body, array $query): Response + { + $bid = self::positiveId($params['bid'] ?? '', 'bid'); + $ureason = trim((string) ($body['ureason'] ?? '')); + return Envelope::ok((new BansService())->unban($bid, $ureason)); + } + + /** + * @param array $params + * @param array $body + * @param array $query + */ + private static function commsList(array $params, array $body, array $query): Response + { + $result = (new CommsService())->list($query); + return Envelope::ok($result['data'], $result['meta']); + } + + /** + * @param array $params + * @param array $body + * @param array $query + */ + private static function commsGet(array $params, array $body, array $query): Response + { + return Envelope::ok((new CommsService())->get(self::positiveId($params['cid'] ?? '', 'cid'))); + } + + /** + * @param array $params + * @param array $body + * @param array $query + */ + private static function commsCreate(array $params, array $body, array $query): Response + { + return Envelope::ok((new CommsService())->create($body), [], 201); + } + + /** + * @param array $params + * @param array $body + * @param array $query + */ + private static function commsUnblock(array $params, array $body, array $query): Response + { + $cid = self::positiveId($params['cid'] ?? '', 'cid'); + $ureason = trim((string) ($body['ureason'] ?? '')); + return Envelope::ok((new CommsService())->unblock($cid, $ureason)); + } + + /** + * @param array $params + * @param array $body + * @param array $query + */ + private static function commsDelete(array $params, array $body, array $query): Response + { + return Envelope::ok((new CommsService())->delete(self::positiveId($params['cid'] ?? '', 'cid'))); + } + + private static function positiveId(string $raw, string $field): int + { + if (preg_match('/^[1-9][0-9]*$/D', $raw) !== 1) { + throw new ApiError('validation', $field . ' must be a positive integer.', $field, 400); + } + return (int) $raw; + } + /** * PUT create requires ADD_ADMINS. PUT update requires EDIT_ADMINS. */ diff --git a/web/tests/api/RestAuthTest.php b/web/tests/api/RestAuthTest.php index c723bf2f9..a67bb794a 100644 --- a/web/tests/api/RestAuthTest.php +++ b/web/tests/api/RestAuthTest.php @@ -98,6 +98,19 @@ public function testRateLimitReturns429(): void $this->assertArrayHasKey('Retry-After', $second->headers); } + public function testUnknownWellFormedPatIs401OnPublicGet(): void + { + $secret = PatAuthenticator::SECRET_PREFIX . str_repeat('cd', 32); + $response = $this->rest('GET', '/bans', token: $secret); + $this->assertRestError($response, 401, 'unauthorized'); + } + + public function testMalformedBearerStaysAnonymousOnPublicGet(): void + { + $response = $this->rest('GET', '/bans', token: 'not-a-pat'); + $this->assertSame(200, $response->status, json_encode($response->payload)); + } + public function testResolveReturnsNullForGarbage(): void { $this->assertNull(PatAuthenticator::resolve('')); diff --git a/web/tests/api/RestBansTest.php b/web/tests/api/RestBansTest.php new file mode 100644 index 000000000..adcdb5fa2 --- /dev/null +++ b/web/tests/api/RestBansTest.php @@ -0,0 +1,143 @@ +seedBan('STEAM_0:1:9001', '1.2.3.4'); + $response = $this->rest('GET', '/bans/' . $bid); + $this->assertSame(200, $response->status, json_encode($response->payload)); + $data = $response->payload['data']; + $this->assertSame($bid, $data['id']); + $this->assertSame('steam', $data['type']); + $this->assertSame('STEAM_0:1:9001', $data['steam']); + $this->assertIsString($data['steam64']); + $this->assertNull($data['ip']); + $this->assertNull($data['admin_name']); + $this->assertSame('permanent', $data['state']); + $this->assertSame(0, $data['length']); + } + + public function testPatGetShowsIpAndAdminName(): void + { + $bid = $this->seedBan('STEAM_0:1:9002', '5.6.7.8'); + $token = $this->mintToken(); + $response = $this->rest('GET', '/bans/' . $bid, token: $token); + $this->assertSame(200, $response->status, json_encode($response->payload)); + $data = $response->payload['data']; + $this->assertSame('5.6.7.8', $data['ip']); + $this->assertSame('admin', $data['admin_name']); + } + + public function testCookieJwtDoesNotBypassHideOnPublicGet(): void + { + $bid = $this->seedBan('STEAM_0:1:9003', '9.9.9.9'); + $this->loginAsAdmin(); + $response = $this->rest('GET', '/bans/' . $bid); + $this->assertSame(200, $response->status); + $this->assertNull($response->payload['data']['ip']); + $this->assertNull($response->payload['data']['admin_name']); + } + + public function testCreateRequiresToken(): void + { + $response = $this->rest('POST', '/bans', [ + 'steam' => 'STEAM_0:1:9100', + 'name' => 'Rest', + 'reason' => 'cheat', + 'length' => 0, + ]); + $this->assertRestError($response, 401, 'unauthorized'); + } + + public function testCreateAndListAndUnban(): void + { + $token = $this->mintToken(); + $created = $this->rest('POST', '/bans', [ + 'steam' => 'STEAM_0:1:9101', + 'name' => 'RestBan', + 'reason' => 'rest-unique-reason', + 'length' => 60, + ], $token); + $this->assertSame(201, $created->status, json_encode($created->payload)); + $ban = $created->payload['data']; + $this->assertSame('RestBan', $ban['player_name']); + $this->assertSame('STEAM_0:1:9101', $ban['steam']); + $this->assertSame(3600, $ban['length']); + $this->assertSame('active', $ban['state']); + $this->assertArrayNotHasKey('kick', $created->payload['meta'] ?? []); + + $list = $this->rest('GET', '/bans', query: ['search' => 'rest-unique-reason']); + $this->assertSame(200, $list->status); + $ids = array_column($list->payload['data'], 'id'); + $this->assertContains($ban['id'], $ids); + + $state = $this->rest('GET', '/bans', query: ['state' => 'active']); + $this->assertContains($ban['id'], array_column($state->payload['data'], 'id')); + + $empty = $this->rest('POST', '/bans/' . $ban['id'] . '/unban', [], $token); + $this->assertRestError($empty, 400, 'validation'); + $this->assertSame('ureason', $empty->payload['error']['field'] ?? null); + + $unban = $this->rest('POST', '/bans/' . $ban['id'] . '/unban', [ + 'ureason' => 'served time', + ], $token); + $this->assertSame(200, $unban->status, json_encode($unban->payload)); + $this->assertSame('unbanned', $unban->payload['data']['state']); + $this->assertSame('served time', $unban->payload['data']['unban_reason']); + } + + public function testDuplicateCreateIs409(): void + { + $token = $this->mintToken(); + $body = [ + 'steam' => 'STEAM_0:1:9102', + 'name' => 'Dup', + 'reason' => 'x', + 'length' => 0, + ]; + $first = $this->rest('POST', '/bans', $body, $token); + $this->assertSame(201, $first->status, json_encode($first->payload)); + $second = $this->rest('POST', '/bans', $body, $token); + $this->assertRestError($second, 409, 'already_banned'); + } + + public function testInvalidSteamIs400(): void + { + $token = $this->mintToken(); + $response = $this->rest('POST', '/bans', [ + 'steam' => 'garbage', + 'length' => 0, + 'reason' => 'x', + ], $token); + $this->assertRestError($response, 400, 'validation'); + $this->assertSame('steam', $response->payload['error']['field'] ?? null); + } + + public function testMissingBanIs404(): void + { + $response = $this->rest('GET', '/bans/999999'); + $this->assertRestError($response, 404, 'not_found'); + } + + public function testNonNumericBidIs400(): void + { + $response = $this->rest('GET', '/bans/STEAM_0:1:1'); + $this->assertRestError($response, 400, 'validation'); + } + + private function seedBan(string $steam, string $ip): int + { + $pdo = Fixture::rawPdo(); + $pdo->prepare(sprintf( + 'INSERT INTO `%s_bans` (created, type, ip, authid, name, ends, length, reason, aid, adminIp, admin_name) + VALUES (UNIX_TIMESTAMP(), 0, ?, ?, ?, UNIX_TIMESTAMP(), 0, ?, ?, "127.0.0.1", ?)', + DB_PREFIX + ))->execute([$ip, $steam, 'Cheater', 'test', Fixture::adminAid(), 'admin']); + return (int) $pdo->lastInsertId(); + } +} diff --git a/web/tests/api/RestCommsTest.php b/web/tests/api/RestCommsTest.php new file mode 100644 index 000000000..0717e031a --- /dev/null +++ b/web/tests/api/RestCommsTest.php @@ -0,0 +1,134 @@ +seedComm('STEAM_0:1:9201', 1); + $response = $this->rest('GET', '/comms/' . $cid); + $this->assertSame(200, $response->status, json_encode($response->payload)); + $data = $response->payload['data']; + $this->assertSame($cid, $data['id']); + $this->assertSame('mute', $data['kind']); + $this->assertSame('STEAM_0:1:9201', $data['steam']); + $this->assertIsString($data['steam64']); + $this->assertNull($data['admin_name']); + $this->assertSame('permanent', $data['state']); + } + + public function testPatGetShowsAdminName(): void + { + $cid = $this->seedComm('STEAM_0:1:9202', 2); + $token = $this->mintToken(); + $response = $this->rest('GET', '/comms/' . $cid, token: $token); + $this->assertSame(200, $response->status); + $this->assertSame('gag', $response->payload['data']['kind']); + $this->assertSame('admin', $response->payload['data']['admin_name']); + } + + public function testCreateRequiresToken(): void + { + $response = $this->rest('POST', '/comms', [ + 'steam' => 'STEAM_0:1:9300', + 'kind' => 'mute', + 'reason' => 'spam', + 'length' => 0, + ]); + $this->assertRestError($response, 401, 'unauthorized'); + } + + public function testCreateMuteUnblockAndDelete(): void + { + $token = $this->mintToken(); + $created = $this->rest('POST', '/comms', [ + 'steam' => 'STEAM_0:1:9301', + 'kind' => 'mute', + 'name' => 'RestComm', + 'reason' => 'rest-comm-reason', + 'length' => 30, + ], $token); + $this->assertSame(201, $created->status, json_encode($created->payload)); + $block = $created->payload['data']; + $this->assertSame('mute', $block['kind']); + $this->assertSame(1800, $block['length']); + $this->assertSame('active', $block['state']); + + $empty = $this->rest('POST', '/comms/' . $block['id'] . '/unblock', [], $token); + $this->assertRestError($empty, 400, 'validation'); + + $unblock = $this->rest('POST', '/comms/' . $block['id'] . '/unblock', [ + 'ureason' => 'apology', + ], $token); + $this->assertSame(200, $unblock->status, json_encode($unblock->payload)); + $this->assertSame('unmuted', $unblock->payload['data']['state']); + $this->assertSame('apology', $unblock->payload['data']['unblock_reason']); + + $delete = $this->rest('DELETE', '/comms/' . $block['id'], token: $token); + $this->assertSame(200, $delete->status, json_encode($delete->payload)); + $this->assertTrue($delete->payload['data']['deleted']); + $gone = $this->rest('GET', '/comms/' . $block['id']); + $this->assertRestError($gone, 404, 'not_found'); + } + + public function testSilenceCreatesTwoRows(): void + { + $token = $this->mintToken(); + $created = $this->rest('POST', '/comms', [ + 'steam' => 'STEAM_0:1:9302', + 'kind' => 'silence', + 'name' => 'Quiet', + 'reason' => 'both', + 'length' => 0, + ], $token); + $this->assertSame(201, $created->status, json_encode($created->payload)); + $data = $created->payload['data']; + $this->assertSame('silence', $data['kind']); + $this->assertCount(2, $data['blocks']); + $this->assertNotNull($data['mute']); + $this->assertNotNull($data['gag']); + $this->assertSame('mute', $data['mute']['kind']); + $this->assertSame('gag', $data['gag']['kind']); + } + + public function testDuplicateCreateIs409(): void + { + $token = $this->mintToken(); + $body = [ + 'steam' => 'STEAM_0:1:9303', + 'kind' => 'gag', + 'reason' => 'x', + 'length' => 0, + ]; + $first = $this->rest('POST', '/comms', $body, $token); + $this->assertSame(201, $first->status, json_encode($first->payload)); + $second = $this->rest('POST', '/comms', $body, $token); + $this->assertRestError($second, 409, 'already_blocked'); + } + + public function testInvalidKindIs400(): void + { + $token = $this->mintToken(); + $response = $this->rest('POST', '/comms', [ + 'steam' => 'STEAM_0:1:9304', + 'kind' => 'ban', + 'length' => 0, + ], $token); + $this->assertRestError($response, 400, 'validation'); + $this->assertSame('kind', $response->payload['error']['field'] ?? null); + } + + private function seedComm(string $steam, int $type): int + { + $pdo = Fixture::rawPdo(); + $pdo->prepare(sprintf( + 'INSERT INTO `%s_comms` (created, type, authid, name, ends, length, reason, aid, adminIp, admin_name) + VALUES (UNIX_TIMESTAMP(), ?, ?, ?, UNIX_TIMESTAMP(), 0, ?, ?, "127.0.0.1", ?)', + DB_PREFIX + ))->execute([$type, $steam, 'Player', 'test', Fixture::adminAid(), 'admin']); + return (int) $pdo->lastInsertId(); + } +} diff --git a/web/tests/api/RestPermissionMatrixTest.php b/web/tests/api/RestPermissionMatrixTest.php index a0085845e..1a2e61057 100644 --- a/web/tests/api/RestPermissionMatrixTest.php +++ b/web/tests/api/RestPermissionMatrixTest.php @@ -20,6 +20,9 @@ public static function expectedRoutes(): array $deleteAdmins = ADMIN_OWNER | ADMIN_DELETE_ADMINS; $readGroups = ADMIN_OWNER | ADMIN_LIST_GROUPS | ADMIN_ADD_ADMINS | ADMIN_EDIT_ADMINS; $rehash = ADMIN_OWNER | ADMIN_EDIT_ADMINS | ADMIN_EDIT_GROUPS | ADMIN_ADD_ADMINS; + $addBan = ADMIN_OWNER | ADMIN_ADD_BAN; + $unban = ADMIN_OWNER | ADMIN_UNBAN | ADMIN_UNBAN_OWN_BANS | ADMIN_UNBAN_GROUP_BANS; + $deleteBan = ADMIN_OWNER | ADMIN_DELETE_BAN; return [ ['method' => 'GET', 'path' => '/openapi.yaml', 'auth' => false, 'perm' => 0], @@ -33,6 +36,15 @@ public static function expectedRoutes(): array ['method' => 'DELETE', 'path' => '/admins/{id}', 'auth' => true, 'perm' => $deleteAdmins], ['method' => 'GET', 'path' => '/groups', 'auth' => true, 'perm' => $readGroups], ['method' => 'POST', 'path' => '/system/rehash', 'auth' => true, 'perm' => $rehash], + ['method' => 'GET', 'path' => '/bans', 'auth' => false, 'perm' => 0], + ['method' => 'POST', 'path' => '/bans', 'auth' => true, 'perm' => $addBan], + ['method' => 'GET', 'path' => '/bans/{bid}', 'auth' => false, 'perm' => 0], + ['method' => 'POST', 'path' => '/bans/{bid}/unban', 'auth' => true, 'perm' => $unban], + ['method' => 'GET', 'path' => '/comms', 'auth' => false, 'perm' => 0], + ['method' => 'POST', 'path' => '/comms', 'auth' => true, 'perm' => $addBan], + ['method' => 'GET', 'path' => '/comms/{cid}', 'auth' => false, 'perm' => 0], + ['method' => 'POST', 'path' => '/comms/{cid}/unblock', 'auth' => true, 'perm' => $unban], + ['method' => 'DELETE', 'path' => '/comms/{cid}', 'auth' => true, 'perm' => $deleteBan], ]; } diff --git a/web/tests/e2e/specs/flows/rest-api.spec.ts b/web/tests/e2e/specs/flows/rest-api.spec.ts index cfb66e1d1..cff1ba414 100644 --- a/web/tests/e2e/specs/flows/rest-api.spec.ts +++ b/web/tests/e2e/specs/flows/rest-api.spec.ts @@ -58,4 +58,52 @@ test.describe('REST API v1', () => { const deactBody = await deact.json(); expect(deactBody.data.enabled).toBe(false); }); + + test('POST /bans, anonymous GET, unban', async ({ page, request }) => { + const account = new MyAccountPage(page); + await account.goto(); + await expect(account.tokensCard).toBeVisible(); + + const tokenName = `e2e-rest-ban-${Date.now()}`; + await account.tokenName.fill(tokenName); + await account.tokenCreate.click(); + await expect(account.tokenSecret).toHaveText(/^sbpp_pat_[0-9a-f]{64}$/); + const secret = (await account.tokenSecret.textContent()) ?? ''; + + const steam = `STEAM_0:1:${Date.now()}`; + const created = await request.post('/api/v1.php/bans', { + headers: { + Authorization: `Bearer ${secret}`, + 'Content-Type': 'application/json', + }, + data: { + steam, + name: 'E2E Rest Ban', + reason: 'e2e rest ban', + length: 0, + }, + }); + expect(created.status(), await created.text()).toBe(201); + const createdBody = await created.json(); + const bid = createdBody.data.id; + expect(createdBody.data.steam).toBe(steam); + expect(createdBody.data.state).toBe('permanent'); + expect(typeof createdBody.data.steam64).toBe('string'); + + const anon = await request.get(`/api/v1.php/bans/${bid}`); + expect(anon.status()).toBe(200); + const anonBody = await anon.json(); + expect(anonBody.data.admin_name).toBeNull(); + + const unban = await request.post(`/api/v1.php/bans/${bid}/unban`, { + headers: { + Authorization: `Bearer ${secret}`, + 'Content-Type': 'application/json', + }, + data: { ureason: 'e2e unban' }, + }); + expect(unban.status(), await unban.text()).toBe(200); + const unbanBody = await unban.json(); + expect(unbanBody.data.state).toBe('unbanned'); + }); }); From 26515686d3c8b08e4c893c99f265e078caec4d2a Mon Sep 17 00:00:00 2001 From: Maximiliano Jabase Date: Mon, 31 Aug 2026 18:14:44 -0300 Subject: [PATCH 03/27] add REST servers notes and mods for PAT clients --- AGENTS.md | 16 +- ARCHITECTURE.md | 9 +- .../src/content/docs/configuring/rest-api.mdx | 27 +- web/api/openapi-v1.yaml | 488 ++++++++++++++++++ web/includes/Rest/Envelope.php | 3 + web/includes/Rest/FrontController.php | 4 +- web/includes/Rest/ModsService.php | 134 +++++ web/includes/Rest/NotesService.php | 133 +++++ web/includes/Rest/Router.php | 2 +- web/includes/Rest/Routes.php | 235 +++++++++ web/includes/Rest/ServersService.php | 366 +++++++++++++ web/tests/api/RestModsTest.php | 57 ++ web/tests/api/RestNotesTest.php | 89 ++++ web/tests/api/RestPermissionMatrixTest.php | 22 +- web/tests/api/RestServersTest.php | 167 ++++++ web/tests/e2e/specs/flows/rest-api.spec.ts | 50 ++ 16 files changed, 1785 insertions(+), 17 deletions(-) create mode 100644 web/includes/Rest/ModsService.php create mode 100644 web/includes/Rest/NotesService.php create mode 100644 web/includes/Rest/ServersService.php create mode 100644 web/tests/api/RestModsTest.php create mode 100644 web/tests/api/RestNotesTest.php create mode 100644 web/tests/api/RestServersTest.php diff --git a/AGENTS.md b/AGENTS.md index 076bbbefa..2867ef936 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -969,8 +969,10 @@ fallback). This is a **separate product** from `POST /api.php`. (`enabled = 0`) → 401. Password `lockout_until` does not apply. - Writes reuse `Api::invoke()` where the RPC handler already exists (deactivate/reactivate/remove/rehash, bans.add/unban, comms.add/ - unblock/delete). List/get and Steam64 upsert are dedicated - `Sbpp\Rest\*` queries. Discard `__redirect` / chrome envelopes. + unblock/delete, servers.add/remove/send_rcon, notes.add/delete, + mods.add/remove). List/get, Steam64 upsert, and PATCH `/servers` 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 @@ -979,12 +981,16 @@ fallback). This is a **separate product** from `POST /api.php`. - 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`. A - well-formed PAT that fails to resolve is 401. Cookie JWT never - authenticates REST (would leak IPs on public GET). + same `is_admin()` + `banlist.hide*` gate as `api_bans_detail`. GET + `/servers` is public with a trimmed A2S `query` and **never** returns + `rcon`. A well-formed PAT that fails to resolve is 401. Cookie JWT + never authenticates REST (would leak IPs on public GET). - POST `/bans` and `/comms` `length` is minutes (0 = permanent). GET `length` is 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. - 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`, diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 704b9ef19..17d3cdead 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -87,7 +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, Kicker) +│ ├── Rest/ Sbpp\Rest\* — REST /api/v1 (FrontController, Router, PatAuthenticator, RateLimiter, Envelope, AdminsService, BansService, CommsService, ServersService, NotesService, ModsService, 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 @@ -341,6 +341,13 @@ as the panel. Writes require a PAT. POST `/bans` `length` is minutes; GET `length` is seconds. Optional `kick: true` fans RCON via `kickit.kick_player` and records `meta.kick`. +Slice 2: `/servers` (public GET with A2S `query`, never `rcon`; 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). + ### Auth (`includes/Auth/` — `Sbpp\Auth\*`) - `Sbpp\Auth\Auth::login(aid, maxlife)` mints a JWT and stores it in diff --git a/docs/src/content/docs/configuring/rest-api.mdx b/docs/src/content/docs/configuring/rest-api.mdx index 64e130fff..e4c702c83 100644 --- a/docs/src/content/docs/configuring/rest-api.mdx +++ b/docs/src/content/docs/configuring/rest-api.mdx @@ -115,10 +115,10 @@ define('SB_REST_CORS_ORIGINS', 'https://staff.example.com'); ## Rate limit Default 60 requests per minute. Anonymous callers (OpenAPI spec, public -ban/comms GET) are keyed by IP. Token callers are keyed by token. +ban/comms/servers GET) are keyed by IP. Token callers are keyed by token. A 429 includes `Retry-After`. -## Slice 0 and 1 routes +## Routes | Method | Path | Notes | | --- | --- | --- | @@ -139,14 +139,27 @@ A 429 includes `Retry-After`. | POST | `/comms` | `kind`: mute, gag, or silence | | POST | `/comms/{cid}/unblock` | Requires `ureason` | | DELETE | `/comms/{cid}` | Hard delete | +| GET | `/servers`, `/servers/{sid}` | Public. A2S in `query`. Never returns `rcon` | +| POST | `/servers` | `ip` / `address`, `port`, `mod`. `enabled` defaults to true | +| PATCH | `/servers/{sid}` | Merge. Omit `rcon` to keep the stored password | +| DELETE | `/servers/{sid}` | Hard delete | +| POST | `/servers/{sid}/rcon` | SourceMod RCON or Root, plus per-server mapping | +| GET | `/notes` | Requires `?steam=`. Any web admin | +| POST | `/notes` | `steam` + `body` | +| DELETE | `/notes/{nid}` | Author or Owner | +| GET | `/mods`, `/mods/{mid}` | List / get | +| POST | `/mods` | `name` + `folder` | +| DELETE | `/mods/{mid}` | Optional `ureason` | | GET | `/openapi.yaml` | This spec | -Servers, notes, mods, protests, and settings are later slices. +Protests, submissions, comments, and settings are a later slice. -GET `/bans` and `/comms` work without a token. The same hide-* settings as -the public banlist apply (`banlist.hideplayerips`, `banlist.hideadminname`). -Send a valid PAT to see IPs and admin names. A well-formed token that is -revoked, expired, or unknown is 401 even on those GETs. +GET `/bans`, `/comms`, and `/servers` work without a token. Ban and comm +GET apply the same hide-* settings as the public lists +(`banlist.hideplayerips`, `banlist.hideadminname`). Send a valid PAT to +see IPs and admin names. GET `/servers` never includes `rcon`, even with +a PAT. A well-formed token that is revoked, expired, or unknown is 401 +even on those GETs. POST `/bans` `length` is minutes (0 = permanent), matching the panel form. GET responses use `length` in **seconds** (what is stored). Steam64 in JSON diff --git a/web/api/openapi-v1.yaml b/web/api/openapi-v1.yaml index 381281667..c318424e6 100644 --- a/web/api/openapi-v1.yaml +++ b/web/api/openapi-v1.yaml @@ -19,6 +19,9 @@ tags: - name: groups - name: bans - name: comms + - name: servers + - name: notes + - name: mods - name: system - name: meta paths: @@ -488,6 +491,289 @@ paths: $ref: "#/components/responses/NotFound" "409": $ref: "#/components/responses/Error" + /servers: + get: + tags: [servers] + security: [] + summary: List game servers + description: > + Public. A2S summary is in `query` (null when the probe fails). + Never includes `rcon`. Filter with `enabled=true|false`. + parameters: + - $ref: "#/components/parameters/page" + - $ref: "#/components/parameters/perPage" + - name: enabled + in: query + schema: + type: boolean + responses: + "200": + description: Paginated server list + content: + application/json: + schema: + $ref: "#/components/schemas/ServerListEnvelope" + "401": + $ref: "#/components/responses/Unauthorized" + post: + tags: [servers] + summary: Add a game server + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ServerWrite" + responses: + "201": + description: Server created + content: + application/json: + schema: + $ref: "#/components/schemas/ServerEnvelope" + "400": + $ref: "#/components/responses/Error" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "409": + $ref: "#/components/responses/Error" + /servers/{sid}: + parameters: + - $ref: "#/components/parameters/serverSid" + get: + tags: [servers] + security: [] + summary: Get one game server + responses: + "200": + description: Server resource + content: + application/json: + schema: + $ref: "#/components/schemas/ServerEnvelope" + "400": + $ref: "#/components/responses/Error" + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + patch: + tags: [servers] + summary: Update a game server + description: > + Omit `rcon` to keep the stored password. Sending `rcon` replaces it. + `group_ids` replaces membership when present. + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/ServerWrite" + responses: + "200": + description: Server after update + content: + application/json: + schema: + $ref: "#/components/schemas/ServerEnvelope" + "400": + $ref: "#/components/responses/Error" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "409": + $ref: "#/components/responses/Error" + delete: + tags: [servers] + summary: Delete a game server + responses: + "200": + description: Deleted id + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + /servers/{sid}/rcon: + parameters: + - $ref: "#/components/parameters/serverSid" + post: + tags: [servers] + summary: Send an RCON command + description: > + Requires SourceMod RCON or Root on the token admin, plus per-server + mapping. The stored RCON password is never returned. + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + command: + type: string + responses: + "200": + description: Command result (`kind` is append, error, noop, or clear) + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + /notes: + get: + tags: [notes] + summary: List notes for a Steam ID + parameters: + - name: steam + in: query + required: true + schema: + type: string + description: Steam2, Steam3, or Steam64. Canonicalised to Steam2. + responses: + "200": + description: Notes newest first + content: + application/json: + schema: + $ref: "#/components/schemas/NoteListEnvelope" + "400": + $ref: "#/components/responses/Error" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + post: + tags: [notes] + summary: Add a note + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [steam, body] + properties: + steam: + type: string + body: + type: string + maxLength: 4000 + responses: + "201": + description: Note created + content: + application/json: + schema: + $ref: "#/components/schemas/NoteEnvelope" + "400": + $ref: "#/components/responses/Error" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /notes/{nid}: + delete: + tags: [notes] + summary: Delete a note + description: Author or Owner only. + parameters: + - $ref: "#/components/parameters/noteId" + responses: + "200": + description: Deleted id + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + /mods: + get: + tags: [mods] + summary: List game mods + parameters: + - $ref: "#/components/parameters/page" + - $ref: "#/components/parameters/perPage" + responses: + "200": + description: Paginated mod list + content: + application/json: + schema: + $ref: "#/components/schemas/ModListEnvelope" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + post: + tags: [mods] + summary: Add a game mod + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ModWrite" + responses: + "201": + description: Mod created + content: + application/json: + schema: + $ref: "#/components/schemas/ModEnvelope" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "409": + $ref: "#/components/responses/Error" + /mods/{mid}: + parameters: + - $ref: "#/components/parameters/modId" + get: + tags: [mods] + summary: Get one game mod + responses: + "200": + description: Mod resource + content: + application/json: + schema: + $ref: "#/components/schemas/ModEnvelope" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + delete: + tags: [mods] + summary: Delete a game mod + requestBody: + content: + application/json: + schema: + type: object + properties: + ureason: + type: string + responses: + "200": + description: Deleted id + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" components: securitySchemes: bearerAuth: @@ -555,6 +841,27 @@ components: schema: type: integer minimum: 1 + serverSid: + name: sid + in: path + required: true + schema: + type: integer + minimum: 1 + noteId: + name: nid + in: path + required: true + schema: + type: integer + minimum: 1 + modId: + name: mid + in: path + required: true + schema: + type: integer + minimum: 1 schemas: Error: type: object @@ -873,6 +1180,187 @@ components: type: integer total: type: integer + Server: + type: object + properties: + id: + type: integer + ip: + type: string + port: + type: integer + enabled: + type: boolean + mod: + type: object + properties: + id: + type: integer + name: + type: string + folder: + type: string + group_ids: + type: array + items: + type: integer + query: + type: object + nullable: true + description: A2S summary. Null when the probe fails. Never includes rcon. + properties: + hostname: + type: string + map: + type: string + players: + type: integer + maxplayers: + type: integer + secure: + type: boolean + ServerWrite: + type: object + properties: + ip: + type: string + description: IPv4, IPv6, or hostname. Alias `address`. + port: + type: integer + minimum: 1 + maximum: 65535 + rcon: + type: string + description: On create, stored password. On PATCH, omit to keep the stored value. + rcon2: + type: string + description: Must match `rcon` when both are sent on create. + mod: + type: integer + description: Game mod id. Alias `mod_id`. + enabled: + type: boolean + description: Defaults to true on create when omitted. + group_ids: + type: array + items: + type: integer + description: On PATCH, replaces membership when present. + ServerEnvelope: + type: object + required: [data] + properties: + data: + $ref: "#/components/schemas/Server" + ServerListEnvelope: + type: object + required: [data, meta] + properties: + data: + type: array + items: + $ref: "#/components/schemas/Server" + meta: + type: object + properties: + page: + type: integer + per_page: + type: integer + total: + type: integer + Note: + type: object + properties: + id: + type: integer + steam: + type: string + steam64: + type: string + nullable: true + description: Decimal string. Never a JSON number. + body: + type: string + created: + type: integer + author: + type: string + nullable: true + author_aid: + type: integer + NoteEnvelope: + type: object + required: [data] + properties: + data: + $ref: "#/components/schemas/Note" + NoteListEnvelope: + type: object + required: [data, meta] + properties: + data: + type: array + items: + $ref: "#/components/schemas/Note" + meta: + type: object + properties: + total: + type: integer + Mod: + type: object + properties: + id: + type: integer + name: + type: string + folder: + type: string + icon: + type: string + steam_universe: + type: integer + enabled: + type: boolean + ModWrite: + type: object + required: [name, folder] + properties: + name: + type: string + folder: + type: string + description: Alias `modfolder`. + icon: + type: string + steam_universe: + type: integer + enabled: + type: boolean + ModEnvelope: + type: object + required: [data] + properties: + data: + $ref: "#/components/schemas/Mod" + ModListEnvelope: + type: object + required: [data, meta] + properties: + data: + type: array + items: + $ref: "#/components/schemas/Mod" + meta: + type: object + properties: + page: + type: integer + per_page: + type: integer + total: + type: integer responses: Error: description: Structured error diff --git a/web/includes/Rest/Envelope.php b/web/includes/Rest/Envelope.php index f76bd9c86..c1c2f627e 100644 --- a/web/includes/Rest/Envelope.php +++ b/web/includes/Rest/Envelope.php @@ -83,7 +83,10 @@ private static function statusForCode(string $code): int 'already_blocked', 'not_active', 'immune', + 'duplicate', + 'mod_exists', 'conflict' => 409, + 'delete_failed' => 500, default => 400, }; } diff --git a/web/includes/Rest/FrontController.php b/web/includes/Rest/FrontController.php index 66ce6b0db..b3369636f 100644 --- a/web/includes/Rest/FrontController.php +++ b/web/includes/Rest/FrontController.php @@ -67,7 +67,7 @@ public static function dispatch(?string $rawBody = null): Response return Envelope::error($code, $message, $matched['error'], null, $headers); } - /** @var array{route: array{method: string, path: string, auth: bool, perm: int, handler: callable}, params: array} $matched */ + /** @var array{route: array{method: string, path: string, auth: bool, perm: int|string, handler: callable}, params: array} $matched */ $route = $matched['route']; $params = $matched['params']; @@ -81,7 +81,7 @@ public static function dispatch(?string $rawBody = null): Response if (!$userbank->is_logged_in()) { return Envelope::error('unauthorized', 'A valid API token is required.', 401, null, $rlHeaders); } - if ($route['perm'] !== 0 && !$userbank->HasAccess($route['perm'])) { + if ($route['perm'] !== 0 && $route['perm'] !== '' && !$userbank->HasAccess($route['perm'])) { return Envelope::error('forbidden', 'No access', 403, null, $rlHeaders); } } diff --git a/web/includes/Rest/ModsService.php b/web/includes/Rest/ModsService.php new file mode 100644 index 000000000..1bcb31b6e --- /dev/null +++ b/web/includes/Rest/ModsService.php @@ -0,0 +1,134 @@ + $query + * @return array{data: list>, meta: array{page: int, per_page: int, total: int}} + */ + public function list(array $query): array + { + $page = max(1, (int) ($query['page'] ?? 1)); + $perPage = (int) ($query['per_page'] ?? 30); + if ($perPage < 1) { + $perPage = 30; + } + $perPage = min(100, $perPage); + $offset = ($page - 1) * $perPage; + + $pdo = $this->db(); + $countRow = $pdo->query('SELECT COUNT(*) AS c FROM `:prefix_mods`')->single(); + $total = is_array($countRow) ? (int) ($countRow['c'] ?? 0) : 0; + + $pdo->query( + 'SELECT mid, name, icon, modfolder, steam_universe, enabled' + . ' FROM `:prefix_mods` ORDER BY mid ASC LIMIT :lim OFFSET :off' + ); + $pdo->bind(':lim', $perPage); + $pdo->bind(':off', $offset); + $rows = $pdo->resultset(); + + $data = []; + foreach ($rows as $row) { + $data[] = $this->toResource($row); + } + + return [ + 'data' => $data, + 'meta' => ['page' => $page, 'per_page' => $perPage, 'total' => $total], + ]; + } + + /** + * @return array + */ + public function get(int $mid): array + { + $pdo = $this->db(); + $pdo->query( + 'SELECT mid, name, icon, modfolder, steam_universe, enabled FROM `:prefix_mods` WHERE mid = :mid' + ); + $pdo->bind(':mid', $mid); + $row = $pdo->single(); + if (!is_array($row)) { + throw new ApiError('not_found', 'Mod not found.', null, 404); + } + return $this->toResource($row); + } + + /** + * @param array $body + * @return array + */ + public function create(array $body): array + { + $folder = (string) ($body['folder'] ?? $body['modfolder'] ?? ''); + $name = (string) ($body['name'] ?? ''); + Api::invoke('mods.add', [ + 'name' => $name, + 'folder' => $folder, + 'icon' => (string) ($body['icon'] ?? ''), + 'steam_universe' => (int) ($body['steam_universe'] ?? 0), + 'enabled' => $body['enabled'] ?? true, + ]); + $pdo = $this->db(); + $pdo->query('SELECT mid FROM `:prefix_mods` WHERE modfolder = :folder OR name = :name ORDER BY mid DESC'); + $pdo->bind(':folder', $folder); + $pdo->bind(':name', $name); + $row = $pdo->single(); + if (!is_array($row)) { + throw new ApiError('server_error', 'Mod was not created.', null, 500); + } + return $this->get((int) $row['mid']); + } + + /** + * @return array + */ + public function delete(int $mid, string $ureason): array + { + $this->get($mid); + Api::invoke('mods.remove', ['mid' => $mid, 'ureason' => $ureason]); + return ['id' => $mid]; + } + + /** + * @param array $row + * @return array + */ + private function toResource(array $row): array + { + return [ + 'id' => (int) $row['mid'], + 'name' => (string) $row['name'], + 'folder' => (string) $row['modfolder'], + 'icon' => (string) $row['icon'], + 'steam_universe' => (int) $row['steam_universe'], + 'enabled' => (int) $row['enabled'] === 1, + ]; + } + + private function db(): Database + { + $pdo = $GLOBALS['PDO'] ?? null; + if ($pdo instanceof Database) { + return $pdo; + } + return new Database(DB_HOST, (int) DB_PORT, DB_NAME, DB_USER, DB_PASS, DB_PREFIX, DB_CHARSET); + } +} diff --git a/web/includes/Rest/NotesService.php b/web/includes/Rest/NotesService.php new file mode 100644 index 000000000..eddcbabff --- /dev/null +++ b/web/includes/Rest/NotesService.php @@ -0,0 +1,133 @@ + $query + * @return array{data: list>, meta: array{total: int}} + */ + public function list(array $query): array + { + $steam = $this->canonicalSteam((string) ($query['steam'] ?? $query['steam_id'] ?? '')); + $pdo = $this->db(); + $pdo->query( + 'SELECT N.nid, N.steam_id, N.body, N.created, N.aid,' + . ' (SELECT user FROM `:prefix_admins` WHERE aid = N.aid) AS author' + . ' FROM `:prefix_notes` AS N' + . ' WHERE N.steam_id = :steam' + . ' ORDER BY N.created DESC, N.nid DESC' + ); + $pdo->bind(':steam', $steam); + $rows = $pdo->resultset(); + $data = []; + foreach ($rows as $row) { + $data[] = $this->toResource($row); + } + return ['data' => $data, 'meta' => ['total' => count($data)]]; + } + + /** + * @param array $body + * @return array + */ + public function create(array $body): array + { + $steam = $this->canonicalSteam((string) ($body['steam'] ?? $body['steam_id'] ?? '')); + $out = Api::invoke('notes.add', [ + 'steam_id' => $steam, + 'body' => (string) ($body['body'] ?? ''), + ]); + $nid = (int) ($out['nid'] ?? 0); + if ($nid <= 0) { + throw new ApiError('server_error', 'Note was not created.', null, 500); + } + return $this->get($nid); + } + + /** + * @return array + */ + public function get(int $nid): array + { + $pdo = $this->db(); + $pdo->query( + 'SELECT N.nid, N.steam_id, N.body, N.created, N.aid,' + . ' (SELECT user FROM `:prefix_admins` WHERE aid = N.aid) AS author' + . ' FROM `:prefix_notes` AS N WHERE N.nid = :nid' + ); + $pdo->bind(':nid', $nid); + $row = $pdo->single(); + if (!is_array($row)) { + throw new ApiError('not_found', 'Note not found.', null, 404); + } + return $this->toResource($row); + } + + /** + * @return array + */ + public function delete(int $nid): array + { + Api::invoke('notes.delete', ['nid' => $nid]); + return ['id' => $nid]; + } + + /** + * @param array $row + * @return array + */ + private function toResource(array $row): array + { + $steam2 = (string) $row['steam_id']; + $steam64 = null; + if ($steam2 !== '' && SteamID::isValidID($steam2)) { + $steam64 = SteamID::toSteam64($steam2); + } + return [ + 'id' => (int) $row['nid'], + 'steam' => $steam2, + 'steam64' => $steam64, + 'body' => (string) $row['body'], + 'created' => (int) $row['created'], + 'author' => $row['author'] !== null ? (string) $row['author'] : null, + 'author_aid' => (int) $row['aid'], + ]; + } + + private function canonicalSteam(string $raw): string + { + $raw = trim($raw); + if ($raw === '') { + throw new ApiError('validation', 'steam is required.', 'steam', 400); + } + if (!preg_match(SteamID::HANDLER_STRICT_REGEX, $raw)) { + throw new ApiError('validation', 'Please enter a valid Steam ID or Community ID', 'steam', 400); + } + return SteamID::toSteam2($raw); + } + + private function db(): Database + { + $pdo = $GLOBALS['PDO'] ?? null; + if ($pdo instanceof Database) { + return $pdo; + } + return new Database(DB_HOST, (int) DB_PORT, DB_NAME, DB_USER, DB_PASS, DB_PREFIX, DB_CHARSET); + } +} diff --git a/web/includes/Rest/Router.php b/web/includes/Rest/Router.php index 9f19aea73..2b2e67024 100644 --- a/web/includes/Rest/Router.php +++ b/web/includes/Rest/Router.php @@ -14,7 +14,7 @@ * method: string, * path: string, * auth: bool, - * perm: int, + * perm: int|string, * handler: callable * } */ diff --git a/web/includes/Rest/Routes.php b/web/includes/Rest/Routes.php index de4f580a0..c641fbb20 100644 --- a/web/includes/Rest/Routes.php +++ b/web/includes/Rest/Routes.php @@ -31,6 +31,13 @@ public static function all(): array $addBan = ADMIN_OWNER | ADMIN_ADD_BAN; $unban = ADMIN_OWNER | ADMIN_UNBAN | ADMIN_UNBAN_OWN_BANS | ADMIN_UNBAN_GROUP_BANS; $deleteBan = ADMIN_OWNER | ADMIN_DELETE_BAN; + $addServer = ADMIN_OWNER | ADMIN_ADD_SERVER; + $editServer = ADMIN_OWNER | ADMIN_EDIT_SERVERS; + $deleteServer = ADMIN_OWNER | ADMIN_DELETE_SERVERS; + $anyAdmin = ALL_WEB; + $readMods = ADMIN_OWNER | ADMIN_LIST_MODS | ADMIN_ADD_MODS | ADMIN_EDIT_MODS; + $addMod = ADMIN_OWNER | ADMIN_ADD_MODS; + $deleteMod = ADMIN_OWNER | ADMIN_DELETE_MODS; return [ [ @@ -173,6 +180,97 @@ public static function all(): array 'perm' => $deleteBan, 'handler' => self::commsDelete(...), ], + [ + 'method' => 'GET', + 'path' => '/servers', + 'auth' => false, + 'perm' => 0, + 'handler' => self::serversList(...), + ], + [ + 'method' => 'POST', + 'path' => '/servers', + 'auth' => true, + 'perm' => $addServer, + 'handler' => self::serversCreate(...), + ], + [ + 'method' => 'GET', + 'path' => '/servers/{sid}', + 'auth' => false, + 'perm' => 0, + 'handler' => self::serversGet(...), + ], + [ + 'method' => 'PATCH', + 'path' => '/servers/{sid}', + 'auth' => true, + 'perm' => $editServer, + 'handler' => self::serversPatch(...), + ], + [ + 'method' => 'DELETE', + 'path' => '/servers/{sid}', + 'auth' => true, + 'perm' => $deleteServer, + 'handler' => self::serversDelete(...), + ], + [ + 'method' => 'POST', + 'path' => '/servers/{sid}/rcon', + 'auth' => true, + 'perm' => SM_RCON . SM_ROOT, + 'handler' => self::serversRcon(...), + ], + [ + 'method' => 'GET', + 'path' => '/notes', + 'auth' => true, + 'perm' => $anyAdmin, + 'handler' => self::notesList(...), + ], + [ + 'method' => 'POST', + 'path' => '/notes', + 'auth' => true, + 'perm' => $anyAdmin, + 'handler' => self::notesCreate(...), + ], + [ + 'method' => 'DELETE', + 'path' => '/notes/{nid}', + 'auth' => true, + 'perm' => $anyAdmin, + 'handler' => self::notesDelete(...), + ], + [ + 'method' => 'GET', + 'path' => '/mods', + 'auth' => true, + 'perm' => $readMods, + 'handler' => self::modsList(...), + ], + [ + 'method' => 'POST', + 'path' => '/mods', + 'auth' => true, + 'perm' => $addMod, + 'handler' => self::modsCreate(...), + ], + [ + 'method' => 'GET', + 'path' => '/mods/{mid}', + 'auth' => true, + 'perm' => $readMods, + 'handler' => self::modsGet(...), + ], + [ + 'method' => 'DELETE', + 'path' => '/mods/{mid}', + 'auth' => true, + 'perm' => $deleteMod, + 'handler' => self::modsDelete(...), + ], ]; } @@ -420,6 +518,143 @@ private static function commsDelete(array $params, array $body, array $query): R return Envelope::ok((new CommsService())->delete(self::positiveId($params['cid'] ?? '', 'cid'))); } + /** + * @param array $params + * @param array $body + * @param array $query + */ + private static function serversList(array $params, array $body, array $query): Response + { + $result = (new ServersService())->list($query); + return Envelope::ok($result['data'], $result['meta']); + } + + /** + * @param array $params + * @param array $body + * @param array $query + */ + private static function serversGet(array $params, array $body, array $query): Response + { + return Envelope::ok((new ServersService())->get(self::positiveId($params['sid'] ?? '', 'sid'))); + } + + /** + * @param array $params + * @param array $body + * @param array $query + */ + private static function serversCreate(array $params, array $body, array $query): Response + { + return Envelope::ok((new ServersService())->create($body), [], 201); + } + + /** + * @param array $params + * @param array $body + * @param array $query + */ + private static function serversPatch(array $params, array $body, array $query): Response + { + return Envelope::ok((new ServersService())->update(self::positiveId($params['sid'] ?? '', 'sid'), $body)); + } + + /** + * @param array $params + * @param array $body + * @param array $query + */ + private static function serversDelete(array $params, array $body, array $query): Response + { + return Envelope::ok((new ServersService())->remove(self::positiveId($params['sid'] ?? '', 'sid'))); + } + + /** + * @param array $params + * @param array $body + * @param array $query + */ + private static function serversRcon(array $params, array $body, array $query): Response + { + $sid = self::positiveId($params['sid'] ?? '', 'sid'); + $command = (string) ($body['command'] ?? ''); + return Envelope::ok((new ServersService())->rcon($sid, $command)); + } + + /** + * @param array $params + * @param array $body + * @param array $query + */ + private static function notesList(array $params, array $body, array $query): Response + { + $result = (new NotesService())->list($query); + return Envelope::ok($result['data'], $result['meta']); + } + + /** + * @param array $params + * @param array $body + * @param array $query + */ + private static function notesCreate(array $params, array $body, array $query): Response + { + return Envelope::ok((new NotesService())->create($body), [], 201); + } + + /** + * @param array $params + * @param array $body + * @param array $query + */ + private static function notesDelete(array $params, array $body, array $query): Response + { + return Envelope::ok((new NotesService())->delete(self::positiveId($params['nid'] ?? '', 'nid'))); + } + + /** + * @param array $params + * @param array $body + * @param array $query + */ + private static function modsList(array $params, array $body, array $query): Response + { + $result = (new ModsService())->list($query); + return Envelope::ok($result['data'], $result['meta']); + } + + /** + * @param array $params + * @param array $body + * @param array $query + */ + private static function modsGet(array $params, array $body, array $query): Response + { + return Envelope::ok((new ModsService())->get(self::positiveId($params['mid'] ?? '', 'mid'))); + } + + /** + * @param array $params + * @param array $body + * @param array $query + */ + private static function modsCreate(array $params, array $body, array $query): Response + { + return Envelope::ok((new ModsService())->create($body), [], 201); + } + + /** + * @param array $params + * @param array $body + * @param array $query + */ + private static function modsDelete(array $params, array $body, array $query): Response + { + $mid = self::positiveId($params['mid'] ?? '', 'mid'); + $ureason = trim((string) ($body['ureason'] ?? '')); + return Envelope::ok((new ModsService())->delete($mid, $ureason)); + } + private static function positiveId(string $raw, string $field): int { if (preg_match('/^[1-9][0-9]*$/D', $raw) !== 1) { diff --git a/web/includes/Rest/ServersService.php b/web/includes/Rest/ServersService.php new file mode 100644 index 000000000..ebb771af6 --- /dev/null +++ b/web/includes/Rest/ServersService.php @@ -0,0 +1,366 @@ + $query + * @return array{data: list>, meta: array{page: int, per_page: int, total: int}} + */ + public function list(array $query): array + { + $page = max(1, (int) ($query['page'] ?? 1)); + $perPage = (int) ($query['per_page'] ?? 30); + if ($perPage < 1) { + $perPage = 30; + } + $perPage = min(100, $perPage); + $offset = ($page - 1) * $perPage; + + $where = '1=1'; + $binds = []; + if (array_key_exists('enabled', $query) && $query['enabled'] !== '' && $query['enabled'] !== null) { + $enabled = $query['enabled']; + $flag = $enabled === true || $enabled === 'true' || $enabled === '1' || $enabled === 1; + $where .= ' AND S.enabled = :enabled'; + $binds[':enabled'] = $flag ? 1 : 0; + } + + $pdo = $this->db(); + $pdo->query('SELECT COUNT(*) AS c FROM `:prefix_servers` S WHERE ' . $where); + foreach ($binds as $name => $value) { + $pdo->bind($name, $value); + } + $countRow = $pdo->single(); + $total = is_array($countRow) ? (int) ($countRow['c'] ?? 0) : 0; + + $pdo->query( + $this->selectSql() + . ' WHERE ' . $where + . ' ORDER BY S.sid ASC' + . ' LIMIT :lim OFFSET :off' + ); + foreach ($binds as $name => $value) { + $pdo->bind($name, $value); + } + $pdo->bind(':lim', $perPage); + $pdo->bind(':off', $offset); + $rows = $pdo->resultset(); + + $data = []; + foreach ($rows as $row) { + $data[] = $this->toResource($row); + } + + return [ + 'data' => $data, + 'meta' => ['page' => $page, 'per_page' => $perPage, 'total' => $total], + ]; + } + + /** + * @return array + */ + public function get(int $sid): array + { + $row = $this->find($sid); + if ($row === null) { + throw new ApiError('not_found', 'Server not found.', null, 404); + } + return $this->toResource($row); + } + + /** + * @param array $body + * @return array + */ + public function create(array $body): array + { + $ip = trim((string) ($body['ip'] ?? $body['address'] ?? '')); + $port = (string) ($body['port'] ?? ''); + $rcon = (string) ($body['rcon'] ?? ''); + $rcon2 = array_key_exists('rcon2', $body) ? (string) $body['rcon2'] : $rcon; + $mod = (int) ($body['mod'] ?? $body['mod_id'] ?? -2); + $enabled = array_key_exists('enabled', $body) + ? ($body['enabled'] === true || $body['enabled'] === 'true' || $body['enabled'] === 1 || $body['enabled'] === '1') + : true; + $groupIds = $this->intList($body['group_ids'] ?? null); + $group = $groupIds === [] ? '0' : implode(',', $groupIds); + + $out = Api::invoke('servers.add', [ + 'ip' => $ip, + 'port' => $port, + 'rcon' => $rcon, + 'rcon2' => $rcon2, + 'mod' => $mod, + 'enabled' => $enabled, + 'group' => $group, + ]); + $sid = (int) ($out['sid'] ?? 0); + if ($sid <= 0) { + throw new ApiError('server_error', 'Server was not created.', null, 500); + } + return $this->get($sid); + } + + /** + * @param array $body + * @return array + */ + public function update(int $sid, array $body): array + { + $row = $this->find($sid); + if ($row === null) { + throw new ApiError('not_found', 'Server not found.', null, 404); + } + + $ip = array_key_exists('ip', $body) || array_key_exists('address', $body) + ? trim((string) ($body['ip'] ?? $body['address'] ?? '')) + : (string) $row['ip']; + $port = array_key_exists('port', $body) + ? (int) $body['port'] + : (int) $row['port']; + $mod = array_key_exists('mod', $body) || array_key_exists('mod_id', $body) + ? (int) ($body['mod'] ?? $body['mod_id'] ?? 0) + : (int) $row['modid']; + $enabled = array_key_exists('enabled', $body) + ? ($body['enabled'] === true || $body['enabled'] === 'true' || $body['enabled'] === 1 || $body['enabled'] === '1') + : ((int) $row['enabled'] === 1); + + if ($ip === '') { + throw new ApiError('validation', 'You must type the server address.', 'address', 400); + } + if (!filter_var($ip, FILTER_VALIDATE_IP) && !filter_var($ip, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME)) { + throw new ApiError('validation', 'You must type a valid IP or hostname.', 'address', 400); + } + if (strlen($ip) > 64) { + throw new ApiError('validation', 'Server address must be at most 64 characters.', 'address', 400); + } + if ($port < 1 || $port > 65535) { + throw new ApiError('validation', 'You must type a valid port number (1-65535).', 'port', 400); + } + + $pdo = $this->db(); + $pdo->query( + 'SELECT sid FROM `:prefix_servers` WHERE ip = :ip AND port = :port AND sid != :sid' + ); + $pdo->bind(':ip', $ip); + $pdo->bind(':port', $port); + $pdo->bind(':sid', $sid); + $clash = $pdo->single(); + if (is_array($clash)) { + throw new ApiError('duplicate', 'There already is a server with that IP:Port combination.', 'address', 409); + } + + $pdo->query( + 'UPDATE `:prefix_servers` + SET ip = :ip, port = :port, modid = :modid, enabled = :enabled + WHERE sid = :sid' + ); + $pdo->bind(':ip', $ip); + $pdo->bind(':port', $port); + $pdo->bind(':modid', $mod); + $pdo->bind(':enabled', $enabled ? 1 : 0); + $pdo->bind(':sid', $sid); + $pdo->execute(); + + if (array_key_exists('rcon', $body)) { + $pdo->query('UPDATE `:prefix_servers` SET rcon = :rcon WHERE sid = :sid_rcon'); + $pdo->bind(':rcon', (string) $body['rcon']); + $pdo->bind(':sid_rcon', $sid); + $pdo->execute(); + } + + if (array_key_exists('group_ids', $body)) { + $this->replaceGroups($sid, $this->intList($body['group_ids'])); + } + + Log::add(LogType::Message, 'Server Updated', "Server ({$ip}:{$port}) has been updated."); + return $this->get($sid); + } + + /** + * @return array + */ + public function remove(int $sid): array + { + $this->get($sid); + Api::invoke('servers.remove', ['sid' => $sid]); + return ['id' => $sid]; + } + + /** + * @return array + */ + public function rcon(int $sid, string $command): array + { + $this->get($sid); + $out = Api::invoke('servers.send_rcon', [ + 'sid' => $sid, + 'command' => $command, + 'output' => true, + ]); + unset($out['message'], $out['reload'], $out['__redirect']); + return $out; + } + + /** + * @param array $row + * @return array + */ + private function toResource(array $row): array + { + $sid = (int) $row['sid']; + $ip = (string) $row['ip']; + $port = (int) $row['port']; + return [ + 'id' => $sid, + 'ip' => $ip, + 'port' => $port, + 'enabled' => (int) $row['enabled'] === 1, + 'mod' => [ + 'id' => (int) $row['modid'], + 'name' => (string) ($row['mod_name'] ?? ''), + 'folder' => (string) ($row['modfolder'] ?? ''), + ], + 'group_ids' => $this->groupIds($sid), + 'query' => $this->liveQuery($ip, $port), + ]; + } + + /** + * @return array|null + */ + private function liveQuery(string $ip, int $port): ?array + { + $cached = SourceQueryCache::fetch($ip, $port); + if ($cached === null) { + return null; + } + $info = $cached['info']; + $hostname = (string) preg_replace('/[\x00-\x1f]/', '', (string) ($info['HostName'] ?? '')); + return [ + 'hostname' => $hostname, + 'map' => basename((string) ($info['Map'] ?? '')), + 'players' => (int) ($info['Players'] ?? 0), + 'maxplayers' => (int) ($info['MaxPlayers'] ?? 0), + 'secure' => (bool) ($info['Secure'] ?? false), + ]; + } + + /** + * @return list + */ + private function groupIds(int $sid): array + { + $pdo = $this->db(); + $pdo->query('SELECT group_id FROM `:prefix_servers_groups` WHERE server_id = :sid ORDER BY group_id ASC'); + $pdo->bind(':sid', $sid); + $ids = []; + foreach ($pdo->resultset() as $row) { + $gid = (int) ($row['group_id'] ?? 0); + if ($gid > 0) { + $ids[] = $gid; + } + } + return $ids; + } + + /** + * @param list $groupIds + */ + private function replaceGroups(int $sid, array $groupIds): void + { + $pdo = $this->db(); + $pdo->beginTransaction(); + try { + $pdo->query('DELETE FROM `:prefix_servers_groups` WHERE server_id = :sid'); + $pdo->bind(':sid', $sid); + $pdo->execute(); + foreach ($groupIds as $gid) { + if ($gid <= 0) { + continue; + } + $pdo->query( + 'INSERT INTO `:prefix_servers_groups` (server_id, group_id) VALUES (:sid_ins, :gid)' + ); + $pdo->bind(':sid_ins', $sid); + $pdo->bind(':gid', $gid); + $pdo->execute(); + } + $pdo->endTransaction(); + } catch (\Throwable $e) { + $pdo->cancelTransaction(); + throw $e; + } + } + + /** + * @return array|null + */ + private function find(int $sid): ?array + { + if ($sid <= 0) { + return null; + } + $pdo = $this->db(); + $pdo->query($this->selectSql() . ' WHERE S.sid = :sid'); + $pdo->bind(':sid', $sid); + $row = $pdo->single(); + return is_array($row) ? $row : null; + } + + private function selectSql(): string + { + return 'SELECT S.sid, S.ip, S.port, S.modid, S.enabled, M.name AS mod_name, M.modfolder' + . ' FROM `:prefix_servers` S' + . ' LEFT JOIN `:prefix_mods` M ON S.modid = M.mid'; + } + + /** + * @param mixed $raw + * @return list + */ + private function intList(mixed $raw): array + { + if (!is_array($raw)) { + return []; + } + $out = []; + foreach ($raw as $v) { + if (is_numeric($v)) { + $n = (int) $v; + if ($n > 0) { + $out[] = $n; + } + } + } + return array_values(array_unique($out)); + } + + private function db(): Database + { + $pdo = $GLOBALS['PDO'] ?? null; + if ($pdo instanceof Database) { + return $pdo; + } + return new Database(DB_HOST, (int) DB_PORT, DB_NAME, DB_USER, DB_PASS, DB_PREFIX, DB_CHARSET); + } +} diff --git a/web/tests/api/RestModsTest.php b/web/tests/api/RestModsTest.php new file mode 100644 index 000000000..b192c7d0c --- /dev/null +++ b/web/tests/api/RestModsTest.php @@ -0,0 +1,57 @@ +rest('GET', '/mods'); + $this->assertRestError($response, 401, 'unauthorized'); + } + + public function testListCreateGetDelete(): void + { + $token = $this->mintToken(); + $list = $this->rest('GET', '/mods', token: $token); + $this->assertSame(200, $list->status, json_encode($list->payload)); + $this->assertGreaterThan(0, $list->payload['meta']['total']); + $this->assertArrayNotHasKey('rcon', $list->payload['data'][0]); + + $created = $this->rest('POST', '/mods', [ + 'name' => 'Rest Test Mod', + 'folder' => 'resttmod', + 'enabled' => true, + ], $token); + $this->assertSame(201, $created->status, json_encode($created->payload)); + $mod = $created->payload['data']; + $this->assertSame('Rest Test Mod', $mod['name']); + $this->assertSame('resttmod', $mod['folder']); + $this->assertTrue($mod['enabled']); + + $got = $this->rest('GET', '/mods/' . $mod['id'], token: $token); + $this->assertSame(200, $got->status); + $this->assertSame($mod['id'], $got->payload['data']['id']); + + $deleted = $this->rest('DELETE', '/mods/' . $mod['id'], ['ureason' => 'retired'], $token); + $this->assertSame(200, $deleted->status, json_encode($deleted->payload)); + $missing = $this->rest('GET', '/mods/' . $mod['id'], token: $token); + $this->assertRestError($missing, 404, 'not_found'); + } + + public function testDuplicateIs409(): void + { + $token = $this->mintToken(); + $body = ['name' => 'Dup Mod', 'folder' => 'dupmodrest']; + $first = $this->rest('POST', '/mods', $body, $token); + $this->assertSame(201, $first->status, json_encode($first->payload)); + $second = $this->rest('POST', '/mods', $body, $token); + $this->assertRestError($second, 409, 'mod_exists'); + } + + public function testCreateRequiresToken(): void + { + $response = $this->rest('POST', '/mods', ['name' => 'x', 'folder' => 'y']); + $this->assertRestError($response, 401, 'unauthorized'); + } +} diff --git a/web/tests/api/RestNotesTest.php b/web/tests/api/RestNotesTest.php new file mode 100644 index 000000000..f88766f9b --- /dev/null +++ b/web/tests/api/RestNotesTest.php @@ -0,0 +1,89 @@ +rest('GET', '/notes', query: ['steam' => 'STEAM_0:1:8801']); + $this->assertRestError($response, 401, 'unauthorized'); + } + + public function testCreateListDelete(): void + { + $token = $this->mintToken(); + $created = $this->rest('POST', '/notes', [ + 'steam' => 'STEAM_0:1:8802', + 'body' => 'rest note body', + ], $token); + $this->assertSame(201, $created->status, json_encode($created->payload)); + $note = $created->payload['data']; + $this->assertSame('STEAM_0:1:8802', $note['steam']); + $this->assertIsString($note['steam64']); + $this->assertSame('rest note body', $note['body']); + $this->assertSame('admin', $note['author']); + + $list = $this->rest('GET', '/notes', token: $token, query: ['steam' => 'STEAM_0:1:8802']); + $this->assertSame(200, $list->status); + $this->assertSame(1, $list->payload['meta']['total']); + $this->assertSame($note['id'], $list->payload['data'][0]['id']); + + $by64 = $this->rest('GET', '/notes', token: $token, query: ['steam' => $note['steam64']]); + $this->assertSame(200, $by64->status); + $this->assertSame($note['id'], $by64->payload['data'][0]['id']); + + $deleted = $this->rest('DELETE', '/notes/' . $note['id'], [], $token); + $this->assertSame(200, $deleted->status, json_encode($deleted->payload)); + $empty = $this->rest('GET', '/notes', token: $token, query: ['steam' => 'STEAM_0:1:8802']); + $this->assertSame(0, $empty->payload['meta']['total']); + } + + public function testEmptyBodyIs400(): void + { + $token = $this->mintToken(); + $response = $this->rest('POST', '/notes', [ + 'steam' => 'STEAM_0:1:8803', + 'body' => ' ', + ], $token); + $this->assertRestError($response, 400, 'validation'); + $this->assertSame('body', $response->payload['error']['field'] ?? null); + } + + public function testInvalidSteamIs400(): void + { + $token = $this->mintToken(); + $response = $this->rest('POST', '/notes', [ + 'steam' => 'garbage', + 'body' => 'x', + ], $token); + $this->assertRestError($response, 400, 'validation'); + $this->assertSame('steam', $response->payload['error']['field'] ?? null); + } + + public function testDeleteOthersNoteIs403(): void + { + $ownerToken = $this->mintToken(); + $created = $this->rest('POST', '/notes', [ + 'steam' => 'STEAM_0:1:8804', + 'body' => 'owner note', + ], $ownerToken); + $this->assertSame(201, $created->status, json_encode($created->payload)); + $nid = $created->payload['data']['id']; + + $pdo = Fixture::rawPdo(); + $hash = password_hash('other', PASSWORD_BCRYPT); + $pdo->prepare(sprintf( + 'INSERT INTO `%s_admins` (user, authid, password, gid, email, extraflags, immunity, enabled) + VALUES (?, ?, ?, -1, ?, ?, 0, 1)', + DB_PREFIX + ))->execute(['noter', 'STEAM_0:0:8804', $hash, 'noter@example.test', ADMIN_ADD_BAN]); + $aid = (int) $pdo->lastInsertId(); + $otherToken = $this->mintToken($aid); + + $denied = $this->rest('DELETE', '/notes/' . $nid, [], $otherToken); + $this->assertRestError($denied, 403, 'forbidden'); + } +} diff --git a/web/tests/api/RestPermissionMatrixTest.php b/web/tests/api/RestPermissionMatrixTest.php index 1a2e61057..fad2e8a21 100644 --- a/web/tests/api/RestPermissionMatrixTest.php +++ b/web/tests/api/RestPermissionMatrixTest.php @@ -11,7 +11,7 @@ final class RestPermissionMatrixTest extends TestCase { /** - * @return list + * @return list */ public static function expectedRoutes(): array { @@ -23,6 +23,13 @@ public static function expectedRoutes(): array $addBan = ADMIN_OWNER | ADMIN_ADD_BAN; $unban = ADMIN_OWNER | ADMIN_UNBAN | ADMIN_UNBAN_OWN_BANS | ADMIN_UNBAN_GROUP_BANS; $deleteBan = ADMIN_OWNER | ADMIN_DELETE_BAN; + $addServer = ADMIN_OWNER | ADMIN_ADD_SERVER; + $editServer = ADMIN_OWNER | ADMIN_EDIT_SERVERS; + $deleteServer = ADMIN_OWNER | ADMIN_DELETE_SERVERS; + $anyAdmin = ALL_WEB; + $readMods = ADMIN_OWNER | ADMIN_LIST_MODS | ADMIN_ADD_MODS | ADMIN_EDIT_MODS; + $addMod = ADMIN_OWNER | ADMIN_ADD_MODS; + $deleteMod = ADMIN_OWNER | ADMIN_DELETE_MODS; return [ ['method' => 'GET', 'path' => '/openapi.yaml', 'auth' => false, 'perm' => 0], @@ -45,6 +52,19 @@ public static function expectedRoutes(): array ['method' => 'GET', 'path' => '/comms/{cid}', 'auth' => false, 'perm' => 0], ['method' => 'POST', 'path' => '/comms/{cid}/unblock', 'auth' => true, 'perm' => $unban], ['method' => 'DELETE', 'path' => '/comms/{cid}', 'auth' => true, 'perm' => $deleteBan], + ['method' => 'GET', 'path' => '/servers', 'auth' => false, 'perm' => 0], + ['method' => 'POST', 'path' => '/servers', 'auth' => true, 'perm' => $addServer], + ['method' => 'GET', 'path' => '/servers/{sid}', 'auth' => false, 'perm' => 0], + ['method' => 'PATCH', 'path' => '/servers/{sid}', 'auth' => true, 'perm' => $editServer], + ['method' => 'DELETE', 'path' => '/servers/{sid}', 'auth' => true, 'perm' => $deleteServer], + ['method' => 'POST', 'path' => '/servers/{sid}/rcon', 'auth' => true, 'perm' => SM_RCON . SM_ROOT], + ['method' => 'GET', 'path' => '/notes', 'auth' => true, 'perm' => $anyAdmin], + ['method' => 'POST', 'path' => '/notes', 'auth' => true, 'perm' => $anyAdmin], + ['method' => 'DELETE', 'path' => '/notes/{nid}', 'auth' => true, 'perm' => $anyAdmin], + ['method' => 'GET', 'path' => '/mods', 'auth' => true, 'perm' => $readMods], + ['method' => 'POST', 'path' => '/mods', 'auth' => true, 'perm' => $addMod], + ['method' => 'GET', 'path' => '/mods/{mid}', 'auth' => true, 'perm' => $readMods], + ['method' => 'DELETE', 'path' => '/mods/{mid}', 'auth' => true, 'perm' => $deleteMod], ]; } diff --git a/web/tests/api/RestServersTest.php b/web/tests/api/RestServersTest.php new file mode 100644 index 000000000..492737197 --- /dev/null +++ b/web/tests/api/RestServersTest.php @@ -0,0 +1,167 @@ + [ + 'HostName' => 'Rest Query Host', + 'Players' => 3, + 'MaxPlayers' => 24, + 'Map' => 'de_dust2', + 'Secure' => true, + ], + 'players' => [], + ]; + }); + } + + protected function tearDown(): void + { + SourceQueryCache::setProbeOverrideForTesting(null); + parent::tearDown(); + } + + public function testAnonymousGetOmitsRcon(): void + { + $sid = $this->seedServer('secret-rcon-rest'); + $response = $this->rest('GET', '/servers/' . $sid); + $this->assertSame(200, $response->status, json_encode($response->payload)); + $data = $response->payload['data']; + $this->assertSame($sid, $data['id']); + $this->assertSame('203.0.113.50', $data['ip']); + $this->assertArrayNotHasKey('rcon', $data); + $this->assertStringNotContainsString('secret-rcon-rest', json_encode($response->payload)); + $this->assertSame('Rest Query Host', $data['query']['hostname']); + $this->assertSame('de_dust2', $data['query']['map']); + } + + public function testCookieJwtDoesNotAuthenticate(): void + { + $this->loginAsAdmin(); + $response = $this->rest('POST', '/servers', [ + 'ip' => '203.0.113.51', + 'port' => 27015, + 'mod' => 1, + ]); + $this->assertRestError($response, 401, 'unauthorized'); + } + + public function testCreateRequiresToken(): void + { + $response = $this->rest('POST', '/servers', [ + 'ip' => '203.0.113.52', + 'port' => 27015, + 'mod' => 1, + ]); + $this->assertRestError($response, 401, 'unauthorized'); + } + + public function testCreateGetPatchDelete(): void + { + $token = $this->mintToken(); + $created = $this->rest('POST', '/servers', [ + 'ip' => '203.0.113.53', + 'port' => 27020, + 'mod' => 1, + 'enabled' => true, + ], $token); + $this->assertSame(201, $created->status, json_encode($created->payload)); + $sid = $created->payload['data']['id']; + $this->assertSame('203.0.113.53', $created->payload['data']['ip']); + $this->assertTrue($created->payload['data']['enabled']); + $this->assertArrayNotHasKey('rcon', $created->payload['data']); + + $list = $this->rest('GET', '/servers'); + $this->assertContains($sid, array_column($list->payload['data'], 'id')); + + $patched = $this->rest('PATCH', '/servers/' . $sid, [ + 'port' => 27021, + 'enabled' => false, + ], $token); + $this->assertSame(200, $patched->status, json_encode($patched->payload)); + $this->assertSame(27021, $patched->payload['data']['port']); + $this->assertFalse($patched->payload['data']['enabled']); + + $deleted = $this->rest('DELETE', '/servers/' . $sid, [], $token); + $this->assertSame(200, $deleted->status, json_encode($deleted->payload)); + $this->assertSame($sid, $deleted->payload['data']['id']); + + $missing = $this->rest('GET', '/servers/' . $sid); + $this->assertRestError($missing, 404, 'not_found'); + } + + public function testDuplicateCreateIs409(): void + { + $token = $this->mintToken(); + $body = ['ip' => '203.0.113.54', 'port' => 27015, 'mod' => 1]; + $first = $this->rest('POST', '/servers', $body, $token); + $this->assertSame(201, $first->status, json_encode($first->payload)); + $second = $this->rest('POST', '/servers', $body, $token); + $this->assertRestError($second, 409, 'duplicate'); + } + + public function testInvalidAddressIs400(): void + { + $token = $this->mintToken(); + $response = $this->rest('POST', '/servers', [ + 'ip' => 'not a host', + 'port' => 27015, + 'mod' => 1, + ], $token); + $this->assertRestError($response, 400, 'validation'); + $this->assertSame('address', $response->payload['error']['field'] ?? null); + } + + public function testNonNumericSidIs400(): void + { + $response = $this->rest('GET', '/servers/STEAM_0:1:1'); + $this->assertRestError($response, 400, 'validation'); + } + + public function testRconRequiresSmFlagAndServerMapping(): void + { + $sid = $this->seedServer(); + $token = $this->mintToken(); + $denied = $this->rest('POST', '/servers/' . $sid . '/rcon', [ + 'command' => 'status', + ], $token); + $this->assertRestError($denied, 403, 'forbidden'); + + Fixture::rawPdo()->prepare(sprintf( + "UPDATE `%s_admins` SET srv_flags = 'mz' WHERE aid = ?", + DB_PREFIX + ))->execute([Fixture::adminAid()]); + Fixture::rawPdo()->prepare(sprintf( + 'INSERT INTO `%s_admins_servers_groups` (admin_id, group_id, srv_group_id, server_id) + VALUES (?, 0, -1, ?)', + DB_PREFIX + ))->execute([Fixture::adminAid(), $sid]); + + $token2 = $this->mintToken(); + $blocked = $this->rest('POST', '/servers/' . $sid . '/rcon', [ + 'command' => 'rcon_password', + ], $token2); + $this->assertSame(200, $blocked->status, json_encode($blocked->payload)); + $this->assertSame('error', $blocked->payload['data']['kind']); + $this->assertStringContainsString("Don't try to cheat", $blocked->payload['data']['error']); + } + + private function seedServer(string $rcon = ''): int + { + $pdo = Fixture::rawPdo(); + $pdo->prepare(sprintf( + 'INSERT INTO `%s_servers` (ip, port, rcon, modid, enabled) VALUES (?, ?, ?, 1, 1)', + DB_PREFIX + ))->execute(['203.0.113.50', 27015, $rcon]); + return (int) $pdo->lastInsertId(); + } +} diff --git a/web/tests/e2e/specs/flows/rest-api.spec.ts b/web/tests/e2e/specs/flows/rest-api.spec.ts index cff1ba414..6c3df5b2b 100644 --- a/web/tests/e2e/specs/flows/rest-api.spec.ts +++ b/web/tests/e2e/specs/flows/rest-api.spec.ts @@ -106,4 +106,54 @@ test.describe('REST API v1', () => { const unbanBody = await unban.json(); expect(unbanBody.data.state).toBe('unbanned'); }); + + test('POST /servers, anonymous GET omits rcon, DELETE', async ({ page, request }) => { + const account = new MyAccountPage(page); + await account.goto(); + await expect(account.tokensCard).toBeVisible(); + + const tokenName = `e2e-rest-srv-${Date.now()}`; + await account.tokenName.fill(tokenName); + await account.tokenCreate.click(); + await expect(account.tokenSecret).toHaveText(/^sbpp_pat_[0-9a-f]{64}$/); + const secret = (await account.tokenSecret.textContent()) ?? ''; + + const mods = await request.get('/api/v1.php/mods', { + headers: { Authorization: `Bearer ${secret}` }, + }); + expect(mods.status(), await mods.text()).toBe(200); + const modsBody = await mods.json(); + const modId = modsBody.data.find((m: { id: number }) => m.id >= 1)?.id; + expect(modId).toBeGreaterThan(0); + + const octet = (Date.now() % 200) + 10; + const ip = `203.0.113.${octet}`; + const port = 27000 + (Date.now() % 500); + const created = await request.post('/api/v1.php/servers', { + headers: { + Authorization: `Bearer ${secret}`, + 'Content-Type': 'application/json', + }, + data: { ip, port, mod: modId, enabled: true }, + }); + expect(created.status(), await created.text()).toBe(201); + const createdBody = await created.json(); + const sid = createdBody.data.id; + expect(createdBody.data.ip).toBe(ip); + expect(createdBody.data).not.toHaveProperty('rcon'); + + const anon = await request.get(`/api/v1.php/servers/${sid}`); + expect(anon.status()).toBe(200); + const anonBody = await anon.json(); + expect(anonBody.data.ip).toBe(ip); + expect(anonBody.data).not.toHaveProperty('rcon'); + expect(JSON.stringify(anonBody)).not.toMatch(/rcon/i); + + const deleted = await request.delete(`/api/v1.php/servers/${sid}`, { + headers: { Authorization: `Bearer ${secret}` }, + }); + expect(deleted.status(), await deleted.text()).toBe(200); + const deletedBody = await deleted.json(); + expect(deletedBody.data.id).toBe(sid); + }); }); From c01557ff24a92c8e4027cef0ed2715e426299596 Mon Sep 17 00:00:00 2001 From: Maximiliano Jabase Date: Mon, 31 Aug 2026 18:29:36 -0300 Subject: [PATCH 04/27] add REST protests comments and settings --- AGENTS.md | 13 +- ARCHITECTURE.md | 10 +- .../src/content/docs/configuring/rest-api.mdx | 16 +- web/api/openapi-v1.yaml | 462 ++++++++++++++++++ web/includes/Rest/CommentsService.php | 255 ++++++++++ web/includes/Rest/Envelope.php | 4 +- web/includes/Rest/ProtestsService.php | 136 ++++++ web/includes/Rest/PublicVisibility.php | 2 +- web/includes/Rest/Routes.php | 261 ++++++++++ web/includes/Rest/SettingsService.php | 126 +++++ web/includes/Rest/SubmissionsService.php | 161 ++++++ web/tests/api/RestCommentsTest.php | 155 ++++++ web/tests/api/RestPermissionMatrixTest.php | 17 + web/tests/api/RestProtestsTest.php | 71 +++ web/tests/api/RestSettingsTest.php | 100 ++++ web/tests/api/RestSubmissionsTest.php | 60 +++ web/tests/e2e/specs/flows/rest-api.spec.ts | 12 + 17 files changed, 1852 insertions(+), 9 deletions(-) create mode 100644 web/includes/Rest/CommentsService.php create mode 100644 web/includes/Rest/ProtestsService.php create mode 100644 web/includes/Rest/SettingsService.php create mode 100644 web/includes/Rest/SubmissionsService.php create mode 100644 web/tests/api/RestCommentsTest.php create mode 100644 web/tests/api/RestProtestsTest.php create mode 100644 web/tests/api/RestSettingsTest.php create mode 100644 web/tests/api/RestSubmissionsTest.php diff --git a/AGENTS.md b/AGENTS.md index 2867ef936..5dbaded35 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -970,9 +970,10 @@ fallback). This is a **separate product** from `POST /api.php`. - 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). List/get, Steam64 upsert, and PATCH `/servers` are - dedicated `Sbpp\Rest\*` queries. Discard `__redirect` / chrome - envelopes. + 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 @@ -991,6 +992,12 @@ fallback). This is a **separate product** from `POST /api.php`. - 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. + 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). 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`, diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 17d3cdead..940679711 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -87,7 +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, Kicker) +│ ├── 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 @@ -348,6 +348,14 @@ DELETE; any web admin), `/mods` (GET / POST / DELETE). Writes reuse `notes.delete`, `mods.add` / `mods.remove`. PATCH `/servers` is dedicated (no RPC handler). +Slice 3: `/protests` and `/submissions` (GET list/get, DELETE hard-delete +via `protests.remove` / `submissions.remove` with `archiv=0`), nested +comments on `/bans/{bid}/comments` and `/comms/{cid}/comments` (public GET +honours `config.enablepubliccomments` and `banlist.hideadminname`; POST / +PATCH reuse `bans.add_comment` / `bans.edit_comment`; 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 diff --git a/docs/src/content/docs/configuring/rest-api.mdx b/docs/src/content/docs/configuring/rest-api.mdx index e4c702c83..5756752f7 100644 --- a/docs/src/content/docs/configuring/rest-api.mdx +++ b/docs/src/content/docs/configuring/rest-api.mdx @@ -150,16 +150,26 @@ A 429 includes `Retry-After`. | GET | `/mods`, `/mods/{mid}` | List / get | | POST | `/mods` | `name` + `folder` | | DELETE | `/mods/{mid}` | Optional `ureason` | +| GET | `/protests`, `/protests/{pid}` | Current queue. `archived=true` for the archive | +| DELETE | `/protests/{pid}` | Hard delete | +| GET | `/submissions`, `/submissions/{sid}` | Current queue. `archived=true` for the archive | +| DELETE | `/submissions/{sid}` | Hard delete | +| GET | `/bans/{bid}/comments`, `/comms/{cid}/comments` | Public. Empty when public comments are off | +| POST | `/bans/{bid}/comments`, `/comms/{cid}/comments` | `body`. Any web admin | +| PATCH | `/comments/{cid}` | `body` | +| DELETE | `/comments/{cid}` | Owner only | +| GET | `/settings` | Flat key/value map. Never `smtp.pass` or `telemetry.instance_id` | +| PATCH | `/settings` | Existing keys only. Same forbidden keys | | GET | `/openapi.yaml` | This spec | -Protests, submissions, comments, and settings are a later slice. - GET `/bans`, `/comms`, and `/servers` work without a token. Ban and comm GET apply the same hide-* settings as the public lists (`banlist.hideplayerips`, `banlist.hideadminname`). Send a valid PAT to see IPs and admin names. GET `/servers` never includes `rcon`, even with a PAT. A well-formed token that is revoked, expired, or unknown is 401 -even on those GETs. +even on those GETs. GET comments on a ban or comm is also public (empty +when public comments are off). GET `/protests`, `/submissions`, and +`/settings` need a PAT. POST `/bans` `length` is minutes (0 = permanent), matching the panel form. GET responses use `length` in **seconds** (what is stored). Steam64 in JSON diff --git a/web/api/openapi-v1.yaml b/web/api/openapi-v1.yaml index c318424e6..6e8ee2e70 100644 --- a/web/api/openapi-v1.yaml +++ b/web/api/openapi-v1.yaml @@ -22,6 +22,10 @@ tags: - name: servers - name: notes - name: mods + - name: protests + - name: submissions + - name: comments + - name: settings - name: system - name: meta paths: @@ -375,6 +379,50 @@ paths: $ref: "#/components/responses/NotFound" "409": $ref: "#/components/responses/Error" + /bans/{bid}/comments: + parameters: + - $ref: "#/components/parameters/banId" + get: + tags: [comments] + security: [] + summary: Comments on a ban + description: > + Public. Empty when public comments are off and the caller is not a + token admin. Author fields follow `banlist.hideadminname`. + parameters: + - $ref: "#/components/parameters/page" + - $ref: "#/components/parameters/perPage" + responses: + "200": + description: Comment list + content: + application/json: + schema: + $ref: "#/components/schemas/CommentListEnvelope" + "404": + $ref: "#/components/responses/NotFound" + post: + tags: [comments] + summary: Add a comment on a ban + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CommentWrite" + responses: + "201": + description: Created comment + content: + application/json: + schema: + $ref: "#/components/schemas/CommentEnvelope" + "400": + $ref: "#/components/responses/Error" + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" /comms: get: tags: [comms] @@ -491,6 +539,48 @@ paths: $ref: "#/components/responses/NotFound" "409": $ref: "#/components/responses/Error" + /comms/{cid}/comments: + parameters: + - $ref: "#/components/parameters/commId" + get: + tags: [comments] + security: [] + summary: Comments on a mute or gag + description: Same visibility rules as GET `/bans/{bid}/comments`. + parameters: + - $ref: "#/components/parameters/page" + - $ref: "#/components/parameters/perPage" + responses: + "200": + description: Comment list + content: + application/json: + schema: + $ref: "#/components/schemas/CommentListEnvelope" + "404": + $ref: "#/components/responses/NotFound" + post: + tags: [comments] + summary: Add a comment on a mute or gag + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CommentWrite" + responses: + "201": + description: Created comment + content: + application/json: + schema: + $ref: "#/components/schemas/CommentEnvelope" + "400": + $ref: "#/components/responses/Error" + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" /servers: get: tags: [servers] @@ -774,6 +864,210 @@ paths: $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" + /protests: + get: + tags: [protests] + summary: List ban protests + description: Current queue by default. Pass `archived=true` for the archive. + parameters: + - $ref: "#/components/parameters/page" + - $ref: "#/components/parameters/perPage" + - name: archived + in: query + schema: + type: boolean + responses: + "200": + description: Paginated protest list + content: + application/json: + schema: + $ref: "#/components/schemas/ProtestListEnvelope" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /protests/{pid}: + parameters: + - name: pid + in: path + required: true + schema: + type: integer + get: + tags: [protests] + summary: Get one protest + responses: + "200": + description: Protest resource + content: + application/json: + schema: + $ref: "#/components/schemas/ProtestEnvelope" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + delete: + tags: [protests] + summary: Hard-delete a protest + responses: + "200": + description: Deleted id + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + /submissions: + get: + tags: [submissions] + summary: List ban submissions + description: Current queue by default. Pass `archived=true` for the archive. + parameters: + - $ref: "#/components/parameters/page" + - $ref: "#/components/parameters/perPage" + - name: archived + in: query + schema: + type: boolean + responses: + "200": + description: Paginated submission list + content: + application/json: + schema: + $ref: "#/components/schemas/SubmissionListEnvelope" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + /submissions/{sid}: + parameters: + - name: sid + in: path + required: true + schema: + type: integer + description: Submission id (`subid`). + get: + tags: [submissions] + summary: Get one submission + responses: + "200": + description: Submission resource + content: + application/json: + schema: + $ref: "#/components/schemas/SubmissionEnvelope" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + delete: + tags: [submissions] + summary: Hard-delete a submission + responses: + "200": + description: Deleted id + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + /comments/{cid}: + parameters: + - name: cid + in: path + required: true + schema: + type: integer + description: Comment id. + patch: + tags: [comments] + summary: Edit a comment + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CommentWrite" + responses: + "200": + description: Updated comment + content: + application/json: + schema: + $ref: "#/components/schemas/CommentEnvelope" + "400": + $ref: "#/components/responses/Error" + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + delete: + tags: [comments] + summary: Delete a comment + description: Owner only. + responses: + "200": + description: Deleted id + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + /settings: + get: + tags: [settings] + summary: Panel settings + description: > + Flat key/value map. Never includes `smtp.pass` or + `telemetry.instance_id`. + responses: + "200": + description: Settings object + content: + application/json: + schema: + $ref: "#/components/schemas/SettingsEnvelope" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + patch: + tags: [settings] + summary: Update existing settings + description: > + Only keys that already exist. Unknown or forbidden keys are 400. + Never writes `smtp.pass` or `telemetry.instance_id`. + requestBody: + required: true + content: + application/json: + schema: + type: object + additionalProperties: true + responses: + "200": + description: Settings after update + content: + application/json: + schema: + $ref: "#/components/schemas/SettingsEnvelope" + "400": + $ref: "#/components/responses/Error" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" components: securitySchemes: bearerAuth: @@ -1361,6 +1655,174 @@ components: type: integer total: type: integer + Protest: + type: object + properties: + id: + type: integer + ban_id: + type: integer + submitted: + type: integer + reason: + type: string + email: + type: string + ip: + type: string + archived: + type: boolean + archived_by: + type: integer + nullable: true + ProtestEnvelope: + type: object + required: [data] + properties: + data: + $ref: "#/components/schemas/Protest" + ProtestListEnvelope: + type: object + required: [data, meta] + properties: + data: + type: array + items: + $ref: "#/components/schemas/Protest" + meta: + type: object + properties: + page: + type: integer + per_page: + type: integer + total: + type: integer + Submission: + type: object + properties: + id: + type: integer + steam: + type: string + nullable: true + steam64: + type: string + nullable: true + player_name: + type: string + email: + type: string + reason: + type: string + ip: + type: string + submitter_name: + type: string + nullable: true + submitter_ip: + type: string + nullable: true + server_id: + type: integer + nullable: true + mod_id: + type: integer + nullable: true + submitted: + type: integer + archived: + type: boolean + archived_by: + type: integer + nullable: true + SubmissionEnvelope: + type: object + required: [data] + properties: + data: + $ref: "#/components/schemas/Submission" + SubmissionListEnvelope: + type: object + required: [data, meta] + properties: + data: + type: array + items: + $ref: "#/components/schemas/Submission" + meta: + type: object + properties: + page: + type: integer + per_page: + type: integer + total: + type: integer + Comment: + type: object + properties: + id: + type: integer + parent_id: + type: integer + type: + type: string + enum: [ban, comm, submission, protest] + body: + type: string + created: + type: integer + author: + type: string + nullable: true + author_aid: + type: integer + nullable: true + edited_at: + type: integer + nullable: true + editor_aid: + type: integer + nullable: true + CommentWrite: + type: object + required: [body] + properties: + body: + type: string + CommentEnvelope: + type: object + required: [data] + properties: + data: + $ref: "#/components/schemas/Comment" + CommentListEnvelope: + type: object + required: [data, meta] + properties: + data: + type: array + items: + $ref: "#/components/schemas/Comment" + meta: + type: object + properties: + page: + type: integer + per_page: + type: integer + total: + type: integer + SettingsEnvelope: + type: object + required: [data] + properties: + data: + type: object + additionalProperties: + type: string + description: Setting key to stored string value. Never `smtp.pass` or `telemetry.instance_id`. responses: Error: description: Structured error diff --git a/web/includes/Rest/CommentsService.php b/web/includes/Rest/CommentsService.php new file mode 100644 index 000000000..f2ed129c0 --- /dev/null +++ b/web/includes/Rest/CommentsService.php @@ -0,0 +1,255 @@ + 'ban', + 'C' => 'comm', + 'S' => 'submission', + 'P' => 'protest', + ]; + + /** + * @param array $query + * @return array{data: list>, meta: array{page: int, per_page: int, total: int}} + */ + public function listForParent(int $parentId, string $ctype, array $query): array + { + $this->assertParent($parentId, $ctype); + [$page, $perPage, $offset] = $this->page($query); + + if (!$this->commentsVisible()) { + return [ + 'data' => [], + 'meta' => ['page' => $page, 'per_page' => $perPage, 'total' => 0], + ]; + } + + $pdo = $this->db(); + $pdo->query( + 'SELECT COUNT(*) AS c FROM `:prefix_comments` WHERE type = :type AND bid = :bid' + ); + $pdo->bind(':type', $ctype); + $pdo->bind(':bid', $parentId); + $countRow = $pdo->single(); + $total = is_array($countRow) ? (int) ($countRow['c'] ?? 0) : 0; + + $pdo->query( + 'SELECT C.cid, C.bid, C.type, C.aid, C.commenttxt, C.added, C.editaid, C.edittime,' + . ' (SELECT user FROM `:prefix_admins` WHERE aid = C.aid) AS author' + . ' FROM `:prefix_comments` AS C' + . ' WHERE C.type = :type AND C.bid = :bid' + . ' ORDER BY C.added ASC, C.cid ASC LIMIT :lim OFFSET :off' + ); + $pdo->bind(':type', $ctype); + $pdo->bind(':bid', $parentId); + $pdo->bind(':lim', $perPage); + $pdo->bind(':off', $offset); + $rows = $pdo->resultset(); + + $data = []; + foreach ($rows as $row) { + $data[] = $this->toResource($row); + } + + return [ + 'data' => $data, + 'meta' => ['page' => $page, 'per_page' => $perPage, 'total' => $total], + ]; + } + + /** + * @param array $body + * @return array + */ + public function create(int $parentId, string $ctype, array $body): array + { + $this->assertParent($parentId, $ctype); + $text = $this->bodyText($body); + Api::invoke('bans.add_comment', [ + 'bid' => $parentId, + 'ctype' => $ctype, + 'ctext' => $text, + 'page' => -1, + ]); + $id = (int) $this->db()->lastInsertId(); + if ($id <= 0) { + $id = $this->latestCid($parentId, $ctype); + } + if ($id <= 0) { + throw new ApiError('server_error', 'Comment was not created.', null, 500); + } + return $this->get($id); + } + + /** + * @param array $body + * @return array + */ + public function update(int $cid, array $body): array + { + $row = $this->row($cid); + $text = $this->bodyText($body); + Api::invoke('bans.edit_comment', [ + 'cid' => $cid, + 'ctype' => (string) $row['type'], + 'ctext' => $text, + 'page' => -1, + ]); + return $this->get($cid); + } + + /** + * @return array + */ + public function delete(int $cid): array + { + $row = $this->row($cid); + Api::invoke('bans.remove_comment', [ + 'cid' => $cid, + 'ctype' => (string) $row['type'], + 'page' => -1, + ]); + return ['id' => $cid]; + } + + /** + * @return array + */ + public function get(int $cid): array + { + return $this->toResource($this->row($cid)); + } + + /** + * @return array + */ + private function row(int $cid): array + { + $pdo = $this->db(); + $pdo->query( + 'SELECT C.cid, C.bid, C.type, C.aid, C.commenttxt, C.added, C.editaid, C.edittime,' + . ' (SELECT user FROM `:prefix_admins` WHERE aid = C.aid) AS author' + . ' FROM `:prefix_comments` AS C WHERE C.cid = :cid' + ); + $pdo->bind(':cid', $cid); + $row = $pdo->single(); + if (!is_array($row)) { + throw new ApiError('not_found', 'Comment not found.', null, 404); + } + return $row; + } + + private function assertParent(int $parentId, string $ctype): void + { + $table = match ($ctype) { + 'B' => '`:prefix_bans`', + 'C' => '`:prefix_comms`', + default => throw new ApiError('bad_type', 'Bad comment type.', null, 400), + }; + $pdo = $this->db(); + $pdo->query("SELECT bid FROM {$table} WHERE bid = :id"); + $pdo->bind(':id', $parentId); + $row = $pdo->single(); + if (!is_array($row)) { + $label = $ctype === 'C' ? 'Comm' : 'Ban'; + throw new ApiError('not_found', $label . ' not found.', null, 404); + } + } + + /** + * @param array $body + */ + private function bodyText(array $body): string + { + $text = trim((string) ($body['body'] ?? $body['ctext'] ?? '')); + if ($text === '') { + throw new ApiError('validation', 'body is required.', 'body', 400); + } + return $text; + } + + /** + * @param array $row + * @return array + */ + private function toResource(array $row): array + { + $hideAdmin = PublicVisibility::hideAdminName(); + $letter = (string) ($row['type'] ?? ''); + $author = $row['author'] ?? null; + $editaid = $row['editaid'] ?? null; + $edittime = $row['edittime'] ?? null; + + return [ + 'id' => (int) $row['cid'], + 'parent_id' => (int) $row['bid'], + 'type' => self::TYPE_LABEL[$letter] ?? $letter, + 'body' => (string) ($row['commenttxt'] ?? ''), + 'created' => (int) ($row['added'] ?? 0), + 'author' => $hideAdmin || $author === null ? null : (string) $author, + 'author_aid' => $hideAdmin ? null : (int) $row['aid'], + 'edited_at' => $edittime !== null ? (int) $edittime : null, + 'editor_aid' => $hideAdmin || $editaid === null ? null : (int) $editaid, + ]; + } + + private function commentsVisible(): bool + { + return Config::getBool('config.enablepubliccomments') || PublicVisibility::isAdmin(); + } + + private function latestCid(int $parentId, string $ctype): int + { + $pdo = $this->db(); + $pdo->query( + 'SELECT cid FROM `:prefix_comments` WHERE type = :type AND bid = :bid' + . ' ORDER BY cid DESC LIMIT 1' + ); + $pdo->bind(':type', $ctype); + $pdo->bind(':bid', $parentId); + $row = $pdo->single(); + return is_array($row) ? (int) ($row['cid'] ?? 0) : 0; + } + + /** + * @param array $query + * @return array{0: int, 1: int, 2: int} + */ + private function page(array $query): array + { + $page = max(1, (int) ($query['page'] ?? 1)); + $perPage = (int) ($query['per_page'] ?? 30); + if ($perPage < 1) { + $perPage = 30; + } + $perPage = min(100, $perPage); + return [$page, $perPage, ($page - 1) * $perPage]; + } + + private function db(): Database + { + $pdo = $GLOBALS['PDO'] ?? null; + if ($pdo instanceof Database) { + return $pdo; + } + return new Database(DB_HOST, (int) DB_PORT, DB_NAME, DB_USER, DB_PASS, DB_PREFIX, DB_CHARSET); + } +} diff --git a/web/includes/Rest/Envelope.php b/web/includes/Rest/Envelope.php index c1c2f627e..b990c60b6 100644 --- a/web/includes/Rest/Envelope.php +++ b/web/includes/Rest/Envelope.php @@ -86,7 +86,9 @@ private static function statusForCode(string $code): int 'duplicate', 'mod_exists', 'conflict' => 409, - 'delete_failed' => 500, + 'delete_failed', + 'archive_failed', + 'restore_failed' => 500, default => 400, }; } diff --git a/web/includes/Rest/ProtestsService.php b/web/includes/Rest/ProtestsService.php new file mode 100644 index 000000000..baa78f48e --- /dev/null +++ b/web/includes/Rest/ProtestsService.php @@ -0,0 +1,136 @@ + $query + * @return array{data: list>, meta: array{page: int, per_page: int, total: int}} + */ + public function list(array $query): array + { + [$page, $perPage, $offset] = $this->page($query); + $archived = $this->wantsArchived($query); + $archivSql = $archived ? 'archiv <> 0' : 'archiv = 0'; + + $pdo = $this->db(); + $countRow = $pdo->query("SELECT COUNT(*) AS c FROM `:prefix_protests` WHERE {$archivSql}")->single(); + $total = is_array($countRow) ? (int) ($countRow['c'] ?? 0) : 0; + + $pdo->query( + 'SELECT pid, bid, datesubmitted, reason, email, archiv, archivedby, pip' + . " FROM `:prefix_protests` WHERE {$archivSql}" + . ' ORDER BY pid DESC LIMIT :lim OFFSET :off' + ); + $pdo->bind(':lim', $perPage); + $pdo->bind(':off', $offset); + $rows = $pdo->resultset(); + + $data = []; + foreach ($rows as $row) { + $data[] = $this->toResource($row); + } + + return [ + 'data' => $data, + 'meta' => ['page' => $page, 'per_page' => $perPage, 'total' => $total], + ]; + } + + /** + * @return array + */ + public function get(int $pid): array + { + $pdo = $this->db(); + $pdo->query( + 'SELECT pid, bid, datesubmitted, reason, email, archiv, archivedby, pip' + . ' FROM `:prefix_protests` WHERE pid = :pid' + ); + $pdo->bind(':pid', $pid); + $row = $pdo->single(); + if (!is_array($row)) { + throw new ApiError('not_found', 'Protest not found.', null, 404); + } + return $this->toResource($row); + } + + /** + * @return array + */ + public function delete(int $pid): array + { + $this->get($pid); + Api::invoke('protests.remove', ['pid' => $pid, 'archiv' => '0']); + return ['id' => $pid]; + } + + /** + * @param array $row + * @return array + */ + private function toResource(array $row): array + { + $archivedBy = $row['archivedby'] ?? null; + return [ + 'id' => (int) $row['pid'], + 'ban_id' => (int) $row['bid'], + 'submitted' => (int) ($row['datesubmitted'] ?? 0), + 'reason' => (string) ($row['reason'] ?? ''), + 'email' => (string) ($row['email'] ?? ''), + 'ip' => (string) ($row['pip'] ?? ''), + 'archived' => (int) ($row['archiv'] ?? 0) !== 0, + 'archived_by' => $archivedBy !== null && (int) $archivedBy > 0 ? (int) $archivedBy : null, + ]; + } + + /** + * @param array $query + */ + private function wantsArchived(array $query): bool + { + if (!array_key_exists('archived', $query)) { + return false; + } + $v = $query['archived']; + return $v === true || $v === 1 || $v === '1' || $v === 'true'; + } + + /** + * @param array $query + * @return array{0: int, 1: int, 2: int} + */ + private function page(array $query): array + { + $page = max(1, (int) ($query['page'] ?? 1)); + $perPage = (int) ($query['per_page'] ?? 30); + if ($perPage < 1) { + $perPage = 30; + } + $perPage = min(100, $perPage); + return [$page, $perPage, ($page - 1) * $perPage]; + } + + private function db(): Database + { + $pdo = $GLOBALS['PDO'] ?? null; + if ($pdo instanceof Database) { + return $pdo; + } + return new Database(DB_HOST, (int) DB_PORT, DB_NAME, DB_USER, DB_PASS, DB_PREFIX, DB_CHARSET); + } +} diff --git a/web/includes/Rest/PublicVisibility.php b/web/includes/Rest/PublicVisibility.php index c01f25998..99b26cf5a 100644 --- a/web/includes/Rest/PublicVisibility.php +++ b/web/includes/Rest/PublicVisibility.php @@ -27,7 +27,7 @@ public static function hideAdminName(): bool return Config::getBool('banlist.hideadminname') && !self::isAdmin(); } - private static function isAdmin(): bool + public static function isAdmin(): bool { $userbank = $GLOBALS['userbank'] ?? null; return $userbank instanceof UserManager && $userbank->is_admin(); diff --git a/web/includes/Rest/Routes.php b/web/includes/Rest/Routes.php index c641fbb20..fc44d489f 100644 --- a/web/includes/Rest/Routes.php +++ b/web/includes/Rest/Routes.php @@ -38,6 +38,9 @@ public static function all(): array $readMods = ADMIN_OWNER | ADMIN_LIST_MODS | ADMIN_ADD_MODS | ADMIN_EDIT_MODS; $addMod = ADMIN_OWNER | ADMIN_ADD_MODS; $deleteMod = ADMIN_OWNER | ADMIN_DELETE_MODS; + $protests = ADMIN_OWNER | ADMIN_BAN_PROTESTS; + $submissions = ADMIN_OWNER | ADMIN_BAN_SUBMISSIONS; + $settings = ADMIN_OWNER | ADMIN_WEB_SETTINGS; return [ [ @@ -145,6 +148,20 @@ public static function all(): array 'perm' => $unban, 'handler' => self::bansUnban(...), ], + [ + 'method' => 'GET', + 'path' => '/bans/{bid}/comments', + 'auth' => false, + 'perm' => 0, + 'handler' => self::bansCommentsList(...), + ], + [ + 'method' => 'POST', + 'path' => '/bans/{bid}/comments', + 'auth' => true, + 'perm' => $anyAdmin, + 'handler' => self::bansCommentsCreate(...), + ], [ 'method' => 'GET', 'path' => '/comms', @@ -180,6 +197,20 @@ public static function all(): array 'perm' => $deleteBan, 'handler' => self::commsDelete(...), ], + [ + 'method' => 'GET', + 'path' => '/comms/{cid}/comments', + 'auth' => false, + 'perm' => 0, + 'handler' => self::commsCommentsList(...), + ], + [ + 'method' => 'POST', + 'path' => '/comms/{cid}/comments', + 'auth' => true, + 'perm' => $anyAdmin, + 'handler' => self::commsCommentsCreate(...), + ], [ 'method' => 'GET', 'path' => '/servers', @@ -271,6 +302,76 @@ public static function all(): array 'perm' => $deleteMod, 'handler' => self::modsDelete(...), ], + [ + 'method' => 'GET', + 'path' => '/protests', + 'auth' => true, + 'perm' => $protests, + 'handler' => self::protestsList(...), + ], + [ + 'method' => 'GET', + 'path' => '/protests/{pid}', + 'auth' => true, + 'perm' => $protests, + 'handler' => self::protestsGet(...), + ], + [ + 'method' => 'DELETE', + 'path' => '/protests/{pid}', + 'auth' => true, + 'perm' => $protests, + 'handler' => self::protestsDelete(...), + ], + [ + 'method' => 'GET', + 'path' => '/submissions', + 'auth' => true, + 'perm' => $submissions, + 'handler' => self::submissionsList(...), + ], + [ + 'method' => 'GET', + 'path' => '/submissions/{sid}', + 'auth' => true, + 'perm' => $submissions, + 'handler' => self::submissionsGet(...), + ], + [ + 'method' => 'DELETE', + 'path' => '/submissions/{sid}', + 'auth' => true, + 'perm' => $submissions, + 'handler' => self::submissionsDelete(...), + ], + [ + 'method' => 'PATCH', + 'path' => '/comments/{cid}', + 'auth' => true, + 'perm' => $anyAdmin, + 'handler' => self::commentsPatch(...), + ], + [ + 'method' => 'DELETE', + 'path' => '/comments/{cid}', + 'auth' => true, + 'perm' => ADMIN_OWNER, + 'handler' => self::commentsDelete(...), + ], + [ + 'method' => 'GET', + 'path' => '/settings', + 'auth' => true, + 'perm' => $settings, + 'handler' => self::settingsGet(...), + ], + [ + 'method' => 'PATCH', + 'path' => '/settings', + 'auth' => true, + 'perm' => $settings, + 'handler' => self::settingsPatch(...), + ], ]; } @@ -655,6 +756,166 @@ private static function modsDelete(array $params, array $body, array $query): Re return Envelope::ok((new ModsService())->delete($mid, $ureason)); } + /** + * @param array $params + * @param array $body + * @param array $query + */ + private static function bansCommentsList(array $params, array $body, array $query): Response + { + $result = (new CommentsService())->listForParent( + self::positiveId($params['bid'] ?? '', 'bid'), + 'B', + $query, + ); + return Envelope::ok($result['data'], $result['meta']); + } + + /** + * @param array $params + * @param array $body + * @param array $query + */ + private static function bansCommentsCreate(array $params, array $body, array $query): Response + { + return Envelope::ok( + (new CommentsService())->create(self::positiveId($params['bid'] ?? '', 'bid'), 'B', $body), + [], + 201, + ); + } + + /** + * @param array $params + * @param array $body + * @param array $query + */ + private static function commsCommentsList(array $params, array $body, array $query): Response + { + $result = (new CommentsService())->listForParent( + self::positiveId($params['cid'] ?? '', 'cid'), + 'C', + $query, + ); + return Envelope::ok($result['data'], $result['meta']); + } + + /** + * @param array $params + * @param array $body + * @param array $query + */ + private static function commsCommentsCreate(array $params, array $body, array $query): Response + { + return Envelope::ok( + (new CommentsService())->create(self::positiveId($params['cid'] ?? '', 'cid'), 'C', $body), + [], + 201, + ); + } + + /** + * @param array $params + * @param array $body + * @param array $query + */ + private static function commentsPatch(array $params, array $body, array $query): Response + { + return Envelope::ok((new CommentsService())->update(self::positiveId($params['cid'] ?? '', 'cid'), $body)); + } + + /** + * @param array $params + * @param array $body + * @param array $query + */ + private static function commentsDelete(array $params, array $body, array $query): Response + { + return Envelope::ok((new CommentsService())->delete(self::positiveId($params['cid'] ?? '', 'cid'))); + } + + /** + * @param array $params + * @param array $body + * @param array $query + */ + private static function protestsList(array $params, array $body, array $query): Response + { + $result = (new ProtestsService())->list($query); + return Envelope::ok($result['data'], $result['meta']); + } + + /** + * @param array $params + * @param array $body + * @param array $query + */ + private static function protestsGet(array $params, array $body, array $query): Response + { + return Envelope::ok((new ProtestsService())->get(self::positiveId($params['pid'] ?? '', 'pid'))); + } + + /** + * @param array $params + * @param array $body + * @param array $query + */ + private static function protestsDelete(array $params, array $body, array $query): Response + { + return Envelope::ok((new ProtestsService())->delete(self::positiveId($params['pid'] ?? '', 'pid'))); + } + + /** + * @param array $params + * @param array $body + * @param array $query + */ + private static function submissionsList(array $params, array $body, array $query): Response + { + $result = (new SubmissionsService())->list($query); + return Envelope::ok($result['data'], $result['meta']); + } + + /** + * @param array $params + * @param array $body + * @param array $query + */ + private static function submissionsGet(array $params, array $body, array $query): Response + { + return Envelope::ok((new SubmissionsService())->get(self::positiveId($params['sid'] ?? '', 'sid'))); + } + + /** + * @param array $params + * @param array $body + * @param array $query + */ + private static function submissionsDelete(array $params, array $body, array $query): Response + { + return Envelope::ok((new SubmissionsService())->delete(self::positiveId($params['sid'] ?? '', 'sid'))); + } + + /** + * @param array $params + * @param array $body + * @param array $query + */ + private static function settingsGet(array $params, array $body, array $query): Response + { + return Envelope::ok((new SettingsService())->get()); + } + + /** + * @param array $params + * @param array $body + * @param array $query + */ + private static function settingsPatch(array $params, array $body, array $query): Response + { + return Envelope::ok((new SettingsService())->patch($body)); + } + private static function positiveId(string $raw, string $field): int { if (preg_match('/^[1-9][0-9]*$/D', $raw) !== 1) { diff --git a/web/includes/Rest/SettingsService.php b/web/includes/Rest/SettingsService.php new file mode 100644 index 000000000..2dbbc423d --- /dev/null +++ b/web/includes/Rest/SettingsService.php @@ -0,0 +1,126 @@ + + */ + public function get(): array + { + return $this->allVisible(); + } + + /** + * @param array $body + * @return array + */ + public function patch(array $body): array + { + if ($body === []) { + throw new ApiError('validation', 'Send at least one setting key.', null, 400); + } + + $pdo = $this->db(); + $changed = []; + foreach ($body as $key => $value) { + if (!is_string($key) || $key === '') { + throw new ApiError('validation', 'Setting keys must be strings.', null, 400); + } + if (in_array($key, EntityExporter::FORBIDDEN_SETTING_KEYS, true)) { + throw new ApiError('validation', 'That setting cannot be read or written.', $key, 400); + } + $pdo->query('SELECT setting FROM `:prefix_settings` WHERE setting = :setting'); + $pdo->bind(':setting', $key); + $row = $pdo->single(); + if (!is_array($row)) { + throw new ApiError('validation', 'Unknown setting.', $key, 400); + } + $stored = $this->stringify($value, $key); + $pdo->query('UPDATE `:prefix_settings` SET `value` = :value WHERE `setting` = :setting'); + $pdo->bind(':value', $stored); + $pdo->bind(':setting', $key); + $pdo->execute(); + $changed[] = $key; + } + + Config::init($pdo); + Log::add( + LogType::Message, + 'Settings Updated', + 'REST updated: ' . implode(', ', $changed), + ); + + return $this->allVisible(); + } + + /** + * @return array + */ + private function allVisible(): array + { + $forbidden = EntityExporter::FORBIDDEN_SETTING_KEYS; + $placeholders = implode(',', array_fill(0, count($forbidden), '?')); + $pdo = $this->db(); + $pdo->query( + "SELECT `setting`, `value` FROM `:prefix_settings`" + . " WHERE `setting` NOT IN ({$placeholders}) ORDER BY `setting`" + ); + $i = 1; + foreach ($forbidden as $key) { + $pdo->bind($i++, $key); + } + $out = []; + foreach ($pdo->resultset() as $row) { + $name = (string) ($row['setting'] ?? ''); + if ($name === '') { + continue; + } + $out[$name] = (string) ($row['value'] ?? ''); + } + return $out; + } + + private function stringify(mixed $value, string $key): string + { + if (is_bool($value)) { + return $value ? '1' : '0'; + } + if (is_int($value) || is_float($value)) { + return (string) $value; + } + if (is_string($value)) { + return $value; + } + if ($value === null) { + return ''; + } + throw new ApiError('validation', 'Value must be a string, number, or boolean.', $key, 400); + } + + private function db(): Database + { + $pdo = $GLOBALS['PDO'] ?? null; + if ($pdo instanceof Database) { + return $pdo; + } + return new Database(DB_HOST, (int) DB_PORT, DB_NAME, DB_USER, DB_PASS, DB_PREFIX, DB_CHARSET); + } +} diff --git a/web/includes/Rest/SubmissionsService.php b/web/includes/Rest/SubmissionsService.php new file mode 100644 index 000000000..e0daa0603 --- /dev/null +++ b/web/includes/Rest/SubmissionsService.php @@ -0,0 +1,161 @@ + $query + * @return array{data: list>, meta: array{page: int, per_page: int, total: int}} + */ + public function list(array $query): array + { + [$page, $perPage, $offset] = $this->page($query); + $archived = $this->wantsArchived($query); + $archivSql = $archived ? 'archiv <> 0' : 'archiv = 0'; + + $pdo = $this->db(); + $countRow = $pdo->query("SELECT COUNT(*) AS c FROM `:prefix_submissions` WHERE {$archivSql}")->single(); + $total = is_array($countRow) ? (int) ($countRow['c'] ?? 0) : 0; + + $pdo->query( + 'SELECT subid, submitted, ModID, SteamId, name, email, reason, ip, subname, sip,' + . ' archiv, archivedby, server' + . " FROM `:prefix_submissions` WHERE {$archivSql}" + . ' ORDER BY subid DESC LIMIT :lim OFFSET :off' + ); + $pdo->bind(':lim', $perPage); + $pdo->bind(':off', $offset); + $rows = $pdo->resultset(); + + $data = []; + foreach ($rows as $row) { + $data[] = $this->toResource($row); + } + + return [ + 'data' => $data, + 'meta' => ['page' => $page, 'per_page' => $perPage, 'total' => $total], + ]; + } + + /** + * @return array + */ + public function get(int $sid): array + { + $pdo = $this->db(); + $pdo->query( + 'SELECT subid, submitted, ModID, SteamId, name, email, reason, ip, subname, sip,' + . ' archiv, archivedby, server' + . ' FROM `:prefix_submissions` WHERE subid = :sid' + ); + $pdo->bind(':sid', $sid); + $row = $pdo->single(); + if (!is_array($row)) { + throw new ApiError('not_found', 'Submission not found.', null, 404); + } + return $this->toResource($row); + } + + /** + * @return array + */ + public function delete(int $sid): array + { + $this->get($sid); + Api::invoke('submissions.remove', ['sid' => $sid, 'archiv' => '0']); + return ['id' => $sid]; + } + + /** + * @param array $row + * @return array + */ + private function toResource(array $row): array + { + $rawSteam = trim((string) ($row['SteamId'] ?? '')); + $steam2 = null; + $steam64 = null; + if ($rawSteam !== '' && SteamID::isValidID($rawSteam)) { + $steam2 = SteamID::toSteam2($rawSteam); + $converted = SteamID::toSteam64($steam2); + if ($converted !== false && $converted !== null && $converted !== '') { + $steam64 = (string) $converted; + } + } + + $archivedBy = $row['archivedby'] ?? null; + $serverId = (int) ($row['server'] ?? 0); + $modId = (int) ($row['ModID'] ?? 0); + $subname = trim((string) ($row['subname'] ?? '')); + $sip = trim((string) ($row['sip'] ?? '')); + + return [ + 'id' => (int) $row['subid'], + 'steam' => $steam2 ?? ($rawSteam !== '' ? $rawSteam : null), + 'steam64' => $steam64, + 'player_name' => (string) ($row['name'] ?? ''), + 'email' => (string) ($row['email'] ?? ''), + 'reason' => (string) ($row['reason'] ?? ''), + 'ip' => (string) ($row['ip'] ?? ''), + 'submitter_name' => $subname !== '' ? $subname : null, + 'submitter_ip' => $sip !== '' ? $sip : null, + 'server_id' => $serverId > 0 ? $serverId : null, + 'mod_id' => $modId > 0 ? $modId : null, + 'submitted' => (int) ($row['submitted'] ?? 0), + 'archived' => (int) ($row['archiv'] ?? 0) !== 0, + 'archived_by' => $archivedBy !== null && (int) $archivedBy > 0 ? (int) $archivedBy : null, + ]; + } + + /** + * @param array $query + */ + private function wantsArchived(array $query): bool + { + if (!array_key_exists('archived', $query)) { + return false; + } + $v = $query['archived']; + return $v === true || $v === 1 || $v === '1' || $v === 'true'; + } + + /** + * @param array $query + * @return array{0: int, 1: int, 2: int} + */ + private function page(array $query): array + { + $page = max(1, (int) ($query['page'] ?? 1)); + $perPage = (int) ($query['per_page'] ?? 30); + if ($perPage < 1) { + $perPage = 30; + } + $perPage = min(100, $perPage); + return [$page, $perPage, ($page - 1) * $perPage]; + } + + private function db(): Database + { + $pdo = $GLOBALS['PDO'] ?? null; + if ($pdo instanceof Database) { + return $pdo; + } + return new Database(DB_HOST, (int) DB_PORT, DB_NAME, DB_USER, DB_PASS, DB_PREFIX, DB_CHARSET); + } +} diff --git a/web/tests/api/RestCommentsTest.php b/web/tests/api/RestCommentsTest.php new file mode 100644 index 000000000..f61d5e90c --- /dev/null +++ b/web/tests/api/RestCommentsTest.php @@ -0,0 +1,155 @@ +seedBan('STEAM_0:1:9501'); + $token = $this->mintToken(); + + $created = $this->rest('POST', '/bans/' . $bid . '/comments', [ + 'body' => 'rest comment', + ], $token); + $this->assertSame(201, $created->status, json_encode($created->payload)); + $comment = $created->payload['data']; + $this->assertSame('rest comment', $comment['body']); + $this->assertSame('ban', $comment['type']); + $this->assertSame($bid, $comment['parent_id']); + $this->assertSame('admin', $comment['author']); + $this->assertSame(Fixture::adminAid(), $comment['author_aid']); + + $list = $this->rest('GET', '/bans/' . $bid . '/comments', token: $token); + $this->assertSame(200, $list->status); + $this->assertSame(1, $list->payload['meta']['total']); + $this->assertSame($comment['id'], $list->payload['data'][0]['id']); + + $anon = $this->rest('GET', '/bans/' . $bid . '/comments'); + $this->assertSame(200, $anon->status); + $this->assertSame(0, $anon->payload['meta']['total']); + + $patched = $this->rest('PATCH', '/comments/' . $comment['id'], [ + 'body' => 'edited comment', + ], $token); + $this->assertSame(200, $patched->status, json_encode($patched->payload)); + $this->assertSame('edited comment', $patched->payload['data']['body']); + $this->assertNotNull($patched->payload['data']['edited_at']); + + $deleted = $this->rest('DELETE', '/comments/' . $comment['id'], [], $token); + $this->assertSame(200, $deleted->status, json_encode($deleted->payload)); + $gone = $this->rest('GET', '/bans/' . $bid . '/comments', token: $token); + $this->assertSame(0, $gone->payload['meta']['total']); + } + + public function testAnonymousSeesCommentsWhenPublicEnabled(): void + { + $bid = $this->seedBan('STEAM_0:1:9502'); + $token = $this->mintToken(); + $created = $this->rest('POST', '/bans/' . $bid . '/comments', [ + 'body' => 'public comment', + ], $token); + $this->assertSame(201, $created->status); + + $pdo = Fixture::rawPdo(); + $pdo->prepare(sprintf( + 'REPLACE INTO `%s_settings` (`value`, `setting`) VALUES ("1", "config.enablepubliccomments")', + DB_PREFIX + ))->execute(); + \Config::init($GLOBALS['PDO']); + + $anon = $this->rest('GET', '/bans/' . $bid . '/comments'); + $this->assertSame(200, $anon->status, json_encode($anon->payload)); + $this->assertSame(1, $anon->payload['meta']['total']); + $this->assertSame('public comment', $anon->payload['data'][0]['body']); + $this->assertNull($anon->payload['data'][0]['author']); + $this->assertNull($anon->payload['data'][0]['author_aid']); + } + + public function testCreateOnComm(): void + { + $cid = $this->seedComm('STEAM_0:1:9503'); + $token = $this->mintToken(); + $created = $this->rest('POST', '/comms/' . $cid . '/comments', [ + 'body' => 'comm comment', + ], $token); + $this->assertSame(201, $created->status, json_encode($created->payload)); + $this->assertSame('comm', $created->payload['data']['type']); + $this->assertSame($cid, $created->payload['data']['parent_id']); + } + + public function testEmptyBodyIs400(): void + { + $bid = $this->seedBan('STEAM_0:1:9504'); + $token = $this->mintToken(); + $response = $this->rest('POST', '/bans/' . $bid . '/comments', [ + 'body' => ' ', + ], $token); + $this->assertRestError($response, 400, 'validation'); + $this->assertSame('body', $response->payload['error']['field'] ?? null); + } + + public function testMissingParentIs404(): void + { + $token = $this->mintToken(); + $response = $this->rest('POST', '/bans/999999/comments', [ + 'body' => 'x', + ], $token); + $this->assertRestError($response, 404, 'not_found'); + } + + public function testDeleteRequiresOwner(): void + { + $bid = $this->seedBan('STEAM_0:1:9505'); + $ownerToken = $this->mintToken(); + $created = $this->rest('POST', '/bans/' . $bid . '/comments', [ + 'body' => 'owner comment', + ], $ownerToken); + $this->assertSame(201, $created->status); + $id = $created->payload['data']['id']; + + $pdo = Fixture::rawPdo(); + $hash = password_hash('other', PASSWORD_BCRYPT); + $pdo->prepare(sprintf( + 'INSERT INTO `%s_admins` (user, authid, password, gid, email, extraflags, immunity, enabled) + VALUES (?, ?, ?, -1, ?, ?, 0, 1)', + DB_PREFIX + ))->execute(['commenter', 'STEAM_0:0:9505', $hash, 'commenter@example.test', ADMIN_ADD_BAN]); + $aid = (int) $pdo->lastInsertId(); + $otherToken = $this->mintToken($aid); + + $denied = $this->rest('DELETE', '/comments/' . $id, [], $otherToken); + $this->assertRestError($denied, 403, 'forbidden'); + } + + public function testMissingCommentIs404(): void + { + $token = $this->mintToken(); + $response = $this->rest('PATCH', '/comments/999999', ['body' => 'x'], $token); + $this->assertRestError($response, 404, 'not_found'); + } + + private function seedBan(string $steam): int + { + $pdo = Fixture::rawPdo(); + $pdo->prepare(sprintf( + 'INSERT INTO `%s_bans` (created, type, ip, authid, name, ends, length, reason, aid, adminIp, admin_name) + VALUES (UNIX_TIMESTAMP(), 0, ?, ?, ?, UNIX_TIMESTAMP(), 0, ?, ?, "127.0.0.1", ?)', + DB_PREFIX + ))->execute(['1.1.1.1', $steam, 'Cheater', 'test', Fixture::adminAid(), 'admin']); + return (int) $pdo->lastInsertId(); + } + + private function seedComm(string $steam): int + { + $pdo = Fixture::rawPdo(); + $pdo->prepare(sprintf( + 'INSERT INTO `%s_comms` (created, type, authid, name, ends, length, reason, aid, adminIp, admin_name) + VALUES (UNIX_TIMESTAMP(), ?, ?, ?, UNIX_TIMESTAMP(), 0, ?, ?, "127.0.0.1", ?)', + DB_PREFIX + ))->execute([1, $steam, 'Player', 'test', Fixture::adminAid(), 'admin']); + return (int) $pdo->lastInsertId(); + } +} diff --git a/web/tests/api/RestPermissionMatrixTest.php b/web/tests/api/RestPermissionMatrixTest.php index fad2e8a21..e02be1eba 100644 --- a/web/tests/api/RestPermissionMatrixTest.php +++ b/web/tests/api/RestPermissionMatrixTest.php @@ -30,6 +30,9 @@ public static function expectedRoutes(): array $readMods = ADMIN_OWNER | ADMIN_LIST_MODS | ADMIN_ADD_MODS | ADMIN_EDIT_MODS; $addMod = ADMIN_OWNER | ADMIN_ADD_MODS; $deleteMod = ADMIN_OWNER | ADMIN_DELETE_MODS; + $protests = ADMIN_OWNER | ADMIN_BAN_PROTESTS; + $submissions = ADMIN_OWNER | ADMIN_BAN_SUBMISSIONS; + $settings = ADMIN_OWNER | ADMIN_WEB_SETTINGS; return [ ['method' => 'GET', 'path' => '/openapi.yaml', 'auth' => false, 'perm' => 0], @@ -47,11 +50,15 @@ public static function expectedRoutes(): array ['method' => 'POST', 'path' => '/bans', 'auth' => true, 'perm' => $addBan], ['method' => 'GET', 'path' => '/bans/{bid}', 'auth' => false, 'perm' => 0], ['method' => 'POST', 'path' => '/bans/{bid}/unban', 'auth' => true, 'perm' => $unban], + ['method' => 'GET', 'path' => '/bans/{bid}/comments', 'auth' => false, 'perm' => 0], + ['method' => 'POST', 'path' => '/bans/{bid}/comments', 'auth' => true, 'perm' => $anyAdmin], ['method' => 'GET', 'path' => '/comms', 'auth' => false, 'perm' => 0], ['method' => 'POST', 'path' => '/comms', 'auth' => true, 'perm' => $addBan], ['method' => 'GET', 'path' => '/comms/{cid}', 'auth' => false, 'perm' => 0], ['method' => 'POST', 'path' => '/comms/{cid}/unblock', 'auth' => true, 'perm' => $unban], ['method' => 'DELETE', 'path' => '/comms/{cid}', 'auth' => true, 'perm' => $deleteBan], + ['method' => 'GET', 'path' => '/comms/{cid}/comments', 'auth' => false, 'perm' => 0], + ['method' => 'POST', 'path' => '/comms/{cid}/comments', 'auth' => true, 'perm' => $anyAdmin], ['method' => 'GET', 'path' => '/servers', 'auth' => false, 'perm' => 0], ['method' => 'POST', 'path' => '/servers', 'auth' => true, 'perm' => $addServer], ['method' => 'GET', 'path' => '/servers/{sid}', 'auth' => false, 'perm' => 0], @@ -65,6 +72,16 @@ public static function expectedRoutes(): array ['method' => 'POST', 'path' => '/mods', 'auth' => true, 'perm' => $addMod], ['method' => 'GET', 'path' => '/mods/{mid}', 'auth' => true, 'perm' => $readMods], ['method' => 'DELETE', 'path' => '/mods/{mid}', 'auth' => true, 'perm' => $deleteMod], + ['method' => 'GET', 'path' => '/protests', 'auth' => true, 'perm' => $protests], + ['method' => 'GET', 'path' => '/protests/{pid}', 'auth' => true, 'perm' => $protests], + ['method' => 'DELETE', 'path' => '/protests/{pid}', 'auth' => true, 'perm' => $protests], + ['method' => 'GET', 'path' => '/submissions', 'auth' => true, 'perm' => $submissions], + ['method' => 'GET', 'path' => '/submissions/{sid}', 'auth' => true, 'perm' => $submissions], + ['method' => 'DELETE', 'path' => '/submissions/{sid}', 'auth' => true, 'perm' => $submissions], + ['method' => 'PATCH', 'path' => '/comments/{cid}', 'auth' => true, 'perm' => $anyAdmin], + ['method' => 'DELETE', 'path' => '/comments/{cid}', 'auth' => true, 'perm' => ADMIN_OWNER], + ['method' => 'GET', 'path' => '/settings', 'auth' => true, 'perm' => $settings], + ['method' => 'PATCH', 'path' => '/settings', 'auth' => true, 'perm' => $settings], ]; } diff --git a/web/tests/api/RestProtestsTest.php b/web/tests/api/RestProtestsTest.php new file mode 100644 index 000000000..d6fdb1e76 --- /dev/null +++ b/web/tests/api/RestProtestsTest.php @@ -0,0 +1,71 @@ +rest('GET', '/protests'); + $this->assertRestError($response, 401, 'unauthorized'); + } + + public function testCookieJwtDoesNotList(): void + { + $this->loginAsAdmin(); + $response = $this->rest('GET', '/protests'); + $this->assertRestError($response, 401, 'unauthorized'); + } + + public function testListGetDelete(): void + { + $current = $this->seedProtest('0'); + $archived = $this->seedProtest('1'); + $token = $this->mintToken(); + + $list = $this->rest('GET', '/protests', token: $token); + $this->assertSame(200, $list->status, json_encode($list->payload)); + $ids = array_column($list->payload['data'], 'id'); + $this->assertContains($current, $ids); + $this->assertNotContains($archived, $ids); + + $archiveList = $this->rest('GET', '/protests', token: $token, query: ['archived' => 'true']); + $this->assertSame(200, $archiveList->status); + $archiveIds = array_column($archiveList->payload['data'], 'id'); + $this->assertContains($archived, $archiveIds); + $this->assertNotContains($current, $archiveIds); + + $got = $this->rest('GET', '/protests/' . $current, token: $token); + $this->assertSame(200, $got->status); + $this->assertSame($current, $got->payload['data']['id']); + $this->assertSame('wrong ban', $got->payload['data']['reason']); + $this->assertFalse($got->payload['data']['archived']); + $this->assertSame('127.0.0.1', $got->payload['data']['ip']); + + $deleted = $this->rest('DELETE', '/protests/' . $current, [], $token); + $this->assertSame(200, $deleted->status, json_encode($deleted->payload)); + $missing = $this->rest('GET', '/protests/' . $current, token: $token); + $this->assertRestError($missing, 404, 'not_found'); + } + + public function testMissingIs404(): void + { + $token = $this->mintToken(); + $response = $this->rest('GET', '/protests/999999', token: $token); + $this->assertRestError($response, 404, 'not_found'); + } + + private function seedProtest(string $archiv = '0'): int + { + $pdo = Fixture::rawPdo(); + $pdo->prepare(sprintf( + 'INSERT INTO `%s_protests` + (`bid`, `email`, `reason`, `archiv`, `datesubmitted`, `pip`) + VALUES (0, ?, ?, ?, ?, "127.0.0.1")', + DB_PREFIX + ))->execute(['protest@example.test', 'wrong ban', $archiv, time()]); + return (int) $pdo->lastInsertId(); + } +} diff --git a/web/tests/api/RestSettingsTest.php b/web/tests/api/RestSettingsTest.php new file mode 100644 index 000000000..684d9e7fa --- /dev/null +++ b/web/tests/api/RestSettingsTest.php @@ -0,0 +1,100 @@ +rest('GET', '/settings'); + $this->assertRestError($response, 401, 'unauthorized'); + } + + public function testCookieJwtDoesNotGet(): void + { + $this->loginAsAdmin(); + $response = $this->rest('GET', '/settings'); + $this->assertRestError($response, 401, 'unauthorized'); + } + + public function testGetOmitsForbiddenKeys(): void + { + $token = $this->mintToken(); + $response = $this->rest('GET', '/settings', token: $token); + $this->assertSame(200, $response->status, json_encode($response->payload)); + $data = $response->payload['data']; + $this->assertIsArray($data); + $this->assertArrayHasKey('template.title', $data); + $this->assertArrayNotHasKey('smtp.pass', $data); + $this->assertArrayNotHasKey('telemetry.instance_id', $data); + foreach (EntityExporter::FORBIDDEN_SETTING_KEYS as $key) { + $this->assertArrayNotHasKey($key, $data); + } + } + + public function testPatchRoundTripAndRestore(): void + { + $token = $this->mintToken(); + $before = $this->rest('GET', '/settings', token: $token); + $original = (string) $before->payload['data']['template.title']; + + $patched = $this->rest('PATCH', '/settings', [ + 'template.title' => 'REST Title', + ], $token); + $this->assertSame(200, $patched->status, json_encode($patched->payload)); + $this->assertSame('REST Title', $patched->payload['data']['template.title']); + $this->assertArrayNotHasKey('smtp.pass', $patched->payload['data']); + + $restored = $this->rest('PATCH', '/settings', [ + 'template.title' => $original, + ], $token); + $this->assertSame(200, $restored->status); + $this->assertSame($original, $restored->payload['data']['template.title']); + } + + public function testPatchForbiddenKeyIs400(): void + { + $token = $this->mintToken(); + $response = $this->rest('PATCH', '/settings', [ + 'smtp.pass' => 'secret', + ], $token); + $this->assertRestError($response, 400, 'validation'); + $this->assertSame('smtp.pass', $response->payload['error']['field'] ?? null); + } + + public function testPatchUnknownKeyIs400(): void + { + $token = $this->mintToken(); + $response = $this->rest('PATCH', '/settings', [ + 'not.a.real.setting' => 'x', + ], $token); + $this->assertRestError($response, 400, 'validation'); + $this->assertSame('not.a.real.setting', $response->payload['error']['field'] ?? null); + } + + public function testPatchEmptyBodyIs400(): void + { + $token = $this->mintToken(); + $response = $this->rest('PATCH', '/settings', [], $token); + $this->assertRestError($response, 400, 'validation'); + } + + public function testPatchAcceptsBoolean(): void + { + $token = $this->mintToken(); + $before = $this->rest('GET', '/settings', token: $token); + $original = (string) $before->payload['data']['config.enablecomms']; + + $patched = $this->rest('PATCH', '/settings', [ + 'config.enablecomms' => false, + ], $token); + $this->assertSame(200, $patched->status, json_encode($patched->payload)); + $this->assertSame('0', $patched->payload['data']['config.enablecomms']); + + $this->rest('PATCH', '/settings', [ + 'config.enablecomms' => $original, + ], $token); + } +} diff --git a/web/tests/api/RestSubmissionsTest.php b/web/tests/api/RestSubmissionsTest.php new file mode 100644 index 000000000..8972a9b11 --- /dev/null +++ b/web/tests/api/RestSubmissionsTest.php @@ -0,0 +1,60 @@ +rest('GET', '/submissions'); + $this->assertRestError($response, 401, 'unauthorized'); + } + + public function testListGetDelete(): void + { + $current = $this->seedSubmission('RestPlayer', 'STEAM_0:1:9401', '0'); + $archived = $this->seedSubmission('Archived', 'STEAM_0:1:9402', '1'); + $token = $this->mintToken(); + + $list = $this->rest('GET', '/submissions', token: $token); + $this->assertSame(200, $list->status, json_encode($list->payload)); + $ids = array_column($list->payload['data'], 'id'); + $this->assertContains($current, $ids); + $this->assertNotContains($archived, $ids); + + $got = $this->rest('GET', '/submissions/' . $current, token: $token); + $this->assertSame(200, $got->status); + $data = $got->payload['data']; + $this->assertSame($current, $data['id']); + $this->assertSame('STEAM_0:1:9401', $data['steam']); + $this->assertIsString($data['steam64']); + $this->assertSame('RestPlayer', $data['player_name']); + $this->assertFalse($data['archived']); + + $deleted = $this->rest('DELETE', '/submissions/' . $current, [], $token); + $this->assertSame(200, $deleted->status, json_encode($deleted->payload)); + $missing = $this->rest('GET', '/submissions/' . $current, token: $token); + $this->assertRestError($missing, 404, 'not_found'); + } + + public function testMissingIs404(): void + { + $token = $this->mintToken(); + $response = $this->rest('GET', '/submissions/999999', token: $token); + $this->assertRestError($response, 404, 'not_found'); + } + + private function seedSubmission(string $name, string $steamId, string $archiv): int + { + $pdo = Fixture::rawPdo(); + $pdo->prepare(sprintf( + 'INSERT INTO `%s_submissions` + (`name`, `SteamId`, `email`, `reason`, `archiv`, `submitted`, `ModID`, `ip`, `server`) + VALUES (?, ?, ?, ?, ?, ?, 0, "127.0.0.1", 0)', + DB_PREFIX + ))->execute([$name, $steamId, $name . '@example.test', 'cheating', $archiv, time()]); + return (int) $pdo->lastInsertId(); + } +} diff --git a/web/tests/e2e/specs/flows/rest-api.spec.ts b/web/tests/e2e/specs/flows/rest-api.spec.ts index 6c3df5b2b..1d10b8005 100644 --- a/web/tests/e2e/specs/flows/rest-api.spec.ts +++ b/web/tests/e2e/specs/flows/rest-api.spec.ts @@ -95,6 +95,18 @@ test.describe('REST API v1', () => { const anonBody = await anon.json(); expect(anonBody.data.admin_name).toBeNull(); + const comment = await request.post(`/api/v1.php/bans/${bid}/comments`, { + headers: { + Authorization: `Bearer ${secret}`, + 'Content-Type': 'application/json', + }, + data: { body: 'e2e rest comment' }, + }); + expect(comment.status(), await comment.text()).toBe(201); + const commentBody = await comment.json(); + expect(commentBody.data.body).toBe('e2e rest comment'); + expect(commentBody.data.type).toBe('ban'); + const unban = await request.post(`/api/v1.php/bans/${bid}/unban`, { headers: { Authorization: `Bearer ${secret}`, From af42af94e310d151df0714c6cbf79a16436ea878 Mon Sep 17 00:00:00 2001 From: Maximiliano Jabase Date: Mon, 31 Aug 2026 19:48:53 -0300 Subject: [PATCH 05/27] harden REST v1 after the security audit --- AGENTS.md | 13 ++- ARCHITECTURE.md | 27 +++--- .../src/content/docs/configuring/rest-api.mdx | 18 ++-- web/api/handlers/bans.php | 2 + web/api/openapi-v1.yaml | 16 +++- web/api/v1.php | 4 + web/includes/Rest/AdminsService.php | 9 ++ web/includes/Rest/CommentsService.php | 4 +- web/includes/Rest/CommsService.php | 10 ++ web/includes/Rest/FrontController.php | 7 ++ web/includes/Rest/Rehasher.php | 16 +++- web/includes/Rest/ServersService.php | 15 ++- web/includes/Security/CSRF.php | 3 + web/tests/api/RestAdminsTest.php | 93 +++++++++++++++++++ web/tests/api/RestAuthTest.php | 49 ++++++++++ web/tests/api/RestCommentsTest.php | 35 +++++++ web/tests/api/RestCommsTest.php | 31 +++++++ web/tests/api/RestServersTest.php | 41 ++++++++ web/tests/api/RestSessionTest.php | 35 +++++++ .../bans/add_comment_success.json | 3 +- 20 files changed, 398 insertions(+), 33 deletions(-) create mode 100644 web/tests/api/RestSessionTest.php diff --git a/AGENTS.md b/AGENTS.md index 5dbaded35..de4b6e670 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -982,10 +982,15 @@ fallback). This is a **separate product** from `POST /api.php`. - 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`. GET - `/servers` is public with a trimmed A2S `query` and **never** returns - `rcon`. A well-formed PAT that fails to resolve is 401. Cookie JWT - never authenticates REST (would leak IPs on public GET). + 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. 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 seconds. Optional `kick: true` on POST `/bans` fans RCON (`meta.kick`). Unban/unblock require non-empty `ureason`. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 940679711..787473220 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -316,11 +316,12 @@ GET /api/v1/… -> │ api/v1.php │ -> │ FrontController │ -> │ lists: Rest queries ``` -1. `api/v1.php` registers a JSON exception handler, includes `init.php` - (no CSRF), and calls `FrontController::dispatch()`. +1. `api/v1.php` defines `SBPP_REST` before including `init.php` so + `CSRF::init()` does not start a PHP session, then calls + `FrontController::dispatch()`. 2. The controller **replaces** `$GLOBALS['userbank']` with the PAT - identity or an anonymous `UserManager(null)`. The panel cookie is - ignored. + identity or an anonymous `UserManager(null)`, then rebinds + `Log::init` to that userbank. The panel cookie is ignored. 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, @@ -337,16 +338,18 @@ Slice 0: `/me`, `/admins/{id}` (aid or Steam64), deactivate / reactivate, 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. Writes require a PAT. POST `/bans` `length` is minutes; -GET `length` is seconds. Optional `kick: true` fans RCON via +as the panel. Anonymous GET `/comms` is 404 when `config.enablecomms` is +off (a PAT still reads). Writes require a PAT. POST `/bans` `length` is +minutes; GET `length` is seconds. Optional `kick: true` fans RCON via `kickit.kick_player` and records `meta.kick`. -Slice 2: `/servers` (public GET with A2S `query`, never `rcon`; 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 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, DELETE hard-delete via `protests.remove` / `submissions.remove` with `archiv=0`), nested diff --git a/docs/src/content/docs/configuring/rest-api.mdx b/docs/src/content/docs/configuring/rest-api.mdx index 5756752f7..5f90138d9 100644 --- a/docs/src/content/docs/configuring/rest-api.mdx +++ b/docs/src/content/docs/configuring/rest-api.mdx @@ -135,11 +135,11 @@ A 429 includes `Retry-After`. | GET | `/bans`, `/bans/{bid}` | Public. Hide IP / admin name like the panel | | POST | `/bans` | `length` is minutes. Optional `kick: true` | | POST | `/bans/{bid}/unban` | Requires `ureason` | -| GET | `/comms`, `/comms/{cid}` | Public. Hide admin name like the panel | +| GET | `/comms`, `/comms/{cid}` | Public when Comm blocks are enabled. Hide admin name like the panel. Anonymous GET is 404 when `config.enablecomms` is off | | POST | `/comms` | `kind`: mute, gag, or silence | | POST | `/comms/{cid}/unblock` | Requires `ureason` | | DELETE | `/comms/{cid}` | Hard delete | -| GET | `/servers`, `/servers/{sid}` | Public. A2S in `query`. Never returns `rcon` | +| GET | `/servers`, `/servers/{sid}` | Public for enabled hosts. A2S in `query`. Never returns `rcon`. Anonymous GET omits `group_ids` and ignores `enabled=` | | POST | `/servers` | `ip` / `address`, `port`, `mod`. `enabled` defaults to true | | PATCH | `/servers/{sid}` | Merge. Omit `rcon` to keep the stored password | | DELETE | `/servers/{sid}` | Hard delete | @@ -165,11 +165,15 @@ A 429 includes `Retry-After`. GET `/bans`, `/comms`, and `/servers` work without a token. Ban and comm GET apply the same hide-* settings as the public lists (`banlist.hideplayerips`, `banlist.hideadminname`). Send a valid PAT to -see IPs and admin names. GET `/servers` never includes `rcon`, even with -a PAT. A well-formed token that is revoked, expired, or unknown is 401 -even on those GETs. GET comments on a ban or comm is also public (empty -when public comments are off). GET `/protests`, `/submissions`, and -`/settings` need a PAT. +see IPs and admin names. Anonymous GET `/comms` is 404 when Comm blocks +are off in Settings (`config.enablecomms`). A PAT still reads comms. +GET `/servers` never includes `rcon`, even with a PAT. Anonymous GET +`/servers` returns enabled hosts only (the same as `?p=servers`), omits +`group_ids`, and 404s a disabled `{sid}`. A PAT may pass `enabled=` and +sees `group_ids`. A well-formed token that is revoked, expired, or +unknown is 401 even on those GETs. GET comments on a ban or comm is also +public (empty when public comments are off). GET `/protests`, +`/submissions`, and `/settings` need a PAT. POST `/bans` `length` is minutes (0 = permanent), matching the panel form. GET responses use `length` in **seconds** (what is stored). Steam64 in JSON diff --git a/web/api/handlers/bans.php b/web/api/handlers/bans.php index 24a1b01b8..72d013e0b 100644 --- a/web/api/handlers/bans.php +++ b/web/api/handlers/bans.php @@ -317,6 +317,7 @@ function api_bans_add_comment(array $params): array $GLOBALS['PDO']->query( "INSERT INTO `:prefix_comments`(bid,type,aid,commenttxt,added) VALUES (?,?,?,?,UNIX_TIMESTAMP())" )->execute([$bid, $ctype, $userbank->GetAid(), $ctext]); + $cid = (int) $GLOBALS['PDO']->lastInsertId(); Log::add(LogType::Message, 'Comment Added', "$username added a comment for ban #$bid"); @@ -328,6 +329,7 @@ function api_bans_add_comment(array $params): array 'kind' => 'green', 'redir' => 'index.php' . $redir, ], + 'cid' => $cid, ]; } diff --git a/web/api/openapi-v1.yaml b/web/api/openapi-v1.yaml index 6e8ee2e70..d0b6c4c5c 100644 --- a/web/api/openapi-v1.yaml +++ b/web/api/openapi-v1.yaml @@ -429,8 +429,10 @@ paths: security: [] summary: List mute and gag rows description: > - Public. Same hide-admin-name rule as bans. `kind` on the resource - is `mute` (type 1) or `gag` (type 2). Silence is two rows. + Public when `config.enablecomms` is on. Same hide-admin-name + rule as bans. Anonymous GET is 404 when the setting is off. + A PAT still lists rows. `kind` on the resource is `mute` + (type 1) or `gag` (type 2). Silence is two rows. parameters: - $ref: "#/components/parameters/page" - $ref: "#/components/parameters/perPage" @@ -481,6 +483,9 @@ paths: tags: [comms] security: [] summary: Get one mute or gag row + description: > + Public when `config.enablecomms` is on. Anonymous GET is 404 + when the setting is off. A PAT still returns the row. responses: "200": description: Comm resource @@ -588,7 +593,8 @@ paths: summary: List game servers description: > Public. A2S summary is in `query` (null when the probe fails). - Never includes `rcon`. Filter with `enabled=true|false`. + Never includes `rcon`. Anonymous GET returns enabled hosts only + and ignores `enabled=`. A PAT may filter with `enabled=true|false`. parameters: - $ref: "#/components/parameters/page" - $ref: "#/components/parameters/perPage" @@ -636,6 +642,9 @@ paths: tags: [servers] security: [] summary: Get one game server + description: > + Public for enabled hosts. Anonymous GET of a disabled host is + 404. A PAT can still fetch a disabled host. responses: "200": description: Server resource @@ -1498,6 +1507,7 @@ components: type: array items: type: integer + description: Server group membership. Present for PAT callers. Omitted on anonymous GET. query: type: object nullable: true diff --git a/web/api/v1.php b/web/api/v1.php index 2da5958f1..b153fb263 100644 --- a/web/api/v1.php +++ b/web/api/v1.php @@ -38,6 +38,10 @@ echo json_encode(['error' => ['code' => 'fatal', 'message' => $msg]]); }); +if (!defined('SBPP_REST')) { + define('SBPP_REST', true); +} + include_once dirname(__DIR__) . '/init.php'; require_once INCLUDES_PATH . '/system-functions.php'; diff --git a/web/includes/Rest/AdminsService.php b/web/includes/Rest/AdminsService.php index 978f76ba5..814d82490 100644 --- a/web/includes/Rest/AdminsService.php +++ b/web/includes/Rest/AdminsService.php @@ -244,6 +244,15 @@ private function update(int $aid, array $body, bool $reactivate): array throw new ApiError('not_found', 'Admin not found.', null, 404); } + $isOwnerEditor = $userbank->HasAccess(WebPermission::Owner); + $isSelfEdit = $aid === $userbank->GetAid(); + $canEditTarget = $isOwnerEditor + || ($userbank->HasAccess(WebPermission::EditAdmins) + && (!$userbank->HasAccess(WebPermission::Owner, $aid) || $isSelfEdit)); + if (!$canEditTarget) { + throw new ApiError('forbidden', 'No access', null, 403); + } + $name = array_key_exists('name', $body) ? trim((string) $body['name']) : (string) $current['user']; if ($name === '') { throw new ApiError('validation', 'You must type a name for the admin.', 'name', 400); diff --git a/web/includes/Rest/CommentsService.php b/web/includes/Rest/CommentsService.php index f2ed129c0..d073180ad 100644 --- a/web/includes/Rest/CommentsService.php +++ b/web/includes/Rest/CommentsService.php @@ -83,13 +83,13 @@ public function create(int $parentId, string $ctype, array $body): array { $this->assertParent($parentId, $ctype); $text = $this->bodyText($body); - Api::invoke('bans.add_comment', [ + $out = Api::invoke('bans.add_comment', [ 'bid' => $parentId, 'ctype' => $ctype, 'ctext' => $text, 'page' => -1, ]); - $id = (int) $this->db()->lastInsertId(); + $id = (int) ($out['cid'] ?? 0); if ($id <= 0) { $id = $this->latestCid($parentId, $ctype); } diff --git a/web/includes/Rest/CommsService.php b/web/includes/Rest/CommsService.php index 5e1da0045..07a6dd9b0 100644 --- a/web/includes/Rest/CommsService.php +++ b/web/includes/Rest/CommsService.php @@ -11,6 +11,7 @@ use Sbpp\Api\Api; use Sbpp\Api\ApiError; use Sbpp\Auth\UserManager; +use Sbpp\Config; use Sbpp\Db\Database; use SteamID\SteamID; @@ -28,6 +29,7 @@ final class CommsService */ public function list(array $query): array { + $this->assertPublicFeature(); PruneComms(); $page = max(1, (int) ($query['page'] ?? 1)); $perPage = (int) ($query['per_page'] ?? 30); @@ -76,6 +78,7 @@ public function list(array $query): array */ public function get(int $cid): array { + $this->assertPublicFeature(); PruneComms(); if ($cid <= 0) { throw new ApiError('validation', 'Block id must be a positive integer.', 'cid', 400); @@ -354,6 +357,13 @@ private function rowsAfter(int $before, string $rawSteam): array return $out; } + private function assertPublicFeature(): void + { + if (!Config::getBool('config.enablecomms') && !PublicVisibility::isAdmin()) { + throw new ApiError('not_found', 'Not found.', null, 404); + } + } + private function db(): Database { $pdo = $GLOBALS['PDO'] ?? null; diff --git a/web/includes/Rest/FrontController.php b/web/includes/Rest/FrontController.php index b3369636f..986627194 100644 --- a/web/includes/Rest/FrontController.php +++ b/web/includes/Rest/FrontController.php @@ -10,6 +10,8 @@ use Sbpp\Api\Api; use Sbpp\Api\ApiError; use Sbpp\Auth\UserManager; +use Sbpp\Db\Database; +use Sbpp\Log; use Throwable; /** @@ -31,6 +33,11 @@ public static function dispatch(?string $rawBody = null): Response } $identity = PatAuthenticator::bindUserbank(); + $pdo = $GLOBALS['PDO'] ?? null; + $userbank = $GLOBALS['userbank'] ?? null; + if ($pdo instanceof Database && $userbank instanceof UserManager) { + Log::init($pdo, $userbank); + } $rlKey = $identity !== null ? 'tok:' . $identity['token_id'] : 'ip:' . (string) ($_SERVER['REMOTE_ADDR'] ?? 'unknown'); diff --git a/web/includes/Rest/Rehasher.php b/web/includes/Rest/Rehasher.php index 1100f513d..7981681cd 100644 --- a/web/includes/Rest/Rehasher.php +++ b/web/includes/Rest/Rehasher.php @@ -8,7 +8,9 @@ namespace Sbpp\Rest; use Sbpp\Api\Api; +use Sbpp\Auth\UserManager; use Sbpp\Config; +use WebPermission; /** * Fires `sm_rehash` on the given server ids when admin rehashing is enabled. @@ -22,7 +24,19 @@ final class Rehasher public static function run(array $sids): array { $sids = array_values(array_unique(array_map('intval', $sids))); - if ($sids === [] || !Config::getBool('config.enableadminrehashing')) { + $userbank = $GLOBALS['userbank'] ?? null; + $rehashMask = WebPermission::mask( + WebPermission::Owner, + WebPermission::EditAdmins, + WebPermission::EditGroups, + WebPermission::AddAdmins, + ); + if ( + $sids === [] + || !Config::getBool('config.enableadminrehashing') + || !$userbank instanceof UserManager + || !$userbank->HasAccess($rehashMask) + ) { return ['attempted' => false, 'sids' => $sids, 'results' => []]; } diff --git a/web/includes/Rest/ServersService.php b/web/includes/Rest/ServersService.php index ebb771af6..d657d981a 100644 --- a/web/includes/Rest/ServersService.php +++ b/web/includes/Rest/ServersService.php @@ -36,7 +36,10 @@ public function list(array $query): array $where = '1=1'; $binds = []; - if (array_key_exists('enabled', $query) && $query['enabled'] !== '' && $query['enabled'] !== null) { + $isAdmin = PublicVisibility::isAdmin(); + if (!$isAdmin) { + $where .= ' AND S.enabled = 1'; + } elseif (array_key_exists('enabled', $query) && $query['enabled'] !== '' && $query['enabled'] !== null) { $enabled = $query['enabled']; $flag = $enabled === true || $enabled === 'true' || $enabled === '1' || $enabled === 1; $where .= ' AND S.enabled = :enabled'; @@ -84,6 +87,9 @@ public function get(int $sid): array if ($row === null) { throw new ApiError('not_found', 'Server not found.', null, 404); } + if (!PublicVisibility::isAdmin() && (int) $row['enabled'] !== 1) { + throw new ApiError('not_found', 'Server not found.', null, 404); + } return $this->toResource($row); } @@ -230,7 +236,7 @@ private function toResource(array $row): array $sid = (int) $row['sid']; $ip = (string) $row['ip']; $port = (int) $row['port']; - return [ + $resource = [ 'id' => $sid, 'ip' => $ip, 'port' => $port, @@ -240,9 +246,12 @@ private function toResource(array $row): array 'name' => (string) ($row['mod_name'] ?? ''), 'folder' => (string) ($row['modfolder'] ?? ''), ], - 'group_ids' => $this->groupIds($sid), 'query' => $this->liveQuery($ip, $port), ]; + if (PublicVisibility::isAdmin()) { + $resource['group_ids'] = $this->groupIds($sid); + } + return $resource; } /** diff --git a/web/includes/Security/CSRF.php b/web/includes/Security/CSRF.php index 3bf51c2da..22a87ece0 100644 --- a/web/includes/Security/CSRF.php +++ b/web/includes/Security/CSRF.php @@ -18,6 +18,9 @@ final class CSRF */ public static function init(): void { + if (defined('SBPP_REST')) { + return; + } if (session_status() === PHP_SESSION_NONE) { session_start(); } diff --git a/web/tests/api/RestAdminsTest.php b/web/tests/api/RestAdminsTest.php index 81643c046..e83f04239 100644 --- a/web/tests/api/RestAdminsTest.php +++ b/web/tests/api/RestAdminsTest.php @@ -176,4 +176,97 @@ public function testAdminsListRequiresAuth(): void $response = $this->rest('GET', '/admins'); $this->assertRestError($response, 401, 'unauthorized'); } + + public function testDeleteAdminsOnlyPatDoesNotRehashAfterDelete(): void + { + $pdo = Fixture::rawPdo(); + $pdo->prepare(sprintf( + 'REPLACE INTO `%s_settings` (`value`, `setting`) VALUES ("1", "config.enableadminrehashing")', + DB_PREFIX + ))->execute(); + \Config::init($GLOBALS['PDO']); + + $pdo->prepare(sprintf( + 'INSERT INTO `%s_servers` (ip, port, rcon, modid, enabled) VALUES (?, ?, ?, 1, 1)', + DB_PREFIX + ))->execute(['203.0.113.80', 27015, '']); + $sid = (int) $pdo->lastInsertId(); + + $targetAid = $this->insertAdmin('rehash-target', 'STEAM_0:0:9701', ADMIN_ADD_BAN); + $pdo->prepare(sprintf( + 'INSERT INTO `%s_admins_servers_groups` (admin_id, group_id, srv_group_id, server_id) + VALUES (?, 0, -1, ?)', + DB_PREFIX + ))->execute([$targetAid, $sid]); + + $deleterAid = $this->insertAdmin('rehash-deleter', 'STEAM_0:0:9702', ADMIN_DELETE_ADMINS); + $token = $this->mintToken($deleterAid); + + $response = $this->rest('DELETE', '/admins/' . $targetAid, ['reason' => 'cleanup'], $token); + $this->assertSame(200, $response->status, json_encode($response->payload)); + $this->assertSame($targetAid, $response->payload['data']['id']); + $this->assertFalse($response->payload['meta']['rehash']['attempted']); + $this->assertContains($sid, $response->payload['meta']['rehash']['sids']); + + $gone = $pdo->prepare(sprintf('SELECT aid FROM `%s_admins` WHERE aid = ?', DB_PREFIX)); + $gone->execute([$targetAid]); + $this->assertFalse($gone->fetch()); + } + + public function testEditAdminsPatCannotPatchOwner(): void + { + $editorAid = $this->insertAdmin('owner-editor', 'STEAM_0:0:9703', ADMIN_EDIT_ADMINS); + $token = $this->mintToken($editorAid); + $ownerAid = Fixture::adminAid(); + + $name = $this->rest('PATCH', '/admins/' . $ownerAid, [ + 'name' => 'HackedOwner', + ], $token); + $this->assertRestError($name, 403, 'forbidden'); + + $steam = $this->rest('PATCH', '/admins/' . $ownerAid, [ + 'steam' => 'STEAM_0:0:99999', + ], $token); + $this->assertRestError($steam, 403, 'forbidden'); + + $servers = $this->rest('PATCH', '/admins/' . $ownerAid, [ + 'server_ids' => [1], + ], $token); + $this->assertRestError($servers, 403, 'forbidden'); + } + + public function testOwnerPatCanPatchOwner(): void + { + $token = $this->mintToken(); + $ownerAid = Fixture::adminAid(); + + $name = $this->rest('PATCH', '/admins/' . $ownerAid, [ + 'name' => 'OwnerRenamed', + ], $token); + $this->assertSame(200, $name->status, json_encode($name->payload)); + $this->assertSame('OwnerRenamed', $name->payload['data']['name']); + + $steam = $this->rest('PATCH', '/admins/' . $ownerAid, [ + 'steam' => 'STEAM_0:0:9704', + ], $token); + $this->assertSame(200, $steam->status, json_encode($steam->payload)); + $this->assertSame('STEAM_0:0:9704', $steam->payload['data']['steam']); + + $servers = $this->rest('PATCH', '/admins/' . $ownerAid, [ + 'server_ids' => [], + ], $token); + $this->assertSame(200, $servers->status, json_encode($servers->payload)); + } + + private function insertAdmin(string $user, string $steam, int $flags): int + { + $pdo = Fixture::rawPdo(); + $hash = password_hash('other', PASSWORD_BCRYPT); + $pdo->prepare(sprintf( + 'INSERT INTO `%s_admins` (user, authid, password, gid, email, extraflags, immunity, enabled) + VALUES (?, ?, ?, -1, ?, ?, 0, 1)', + DB_PREFIX + ))->execute([$user, $steam, $hash, $user . '@example.test', $flags]); + return (int) $pdo->lastInsertId(); + } } diff --git a/web/tests/api/RestAuthTest.php b/web/tests/api/RestAuthTest.php index a67bb794a..dfdc361eb 100644 --- a/web/tests/api/RestAuthTest.php +++ b/web/tests/api/RestAuthTest.php @@ -105,6 +105,55 @@ public function testUnknownWellFormedPatIs401OnPublicGet(): void $this->assertRestError($response, 401, 'unauthorized'); } + public function testPatBanWriteIsAttributedToPatAdminInAuditLog(): void + { + $token = $this->mintToken(); + $created = $this->rest('POST', '/bans', [ + 'steam' => 'STEAM_0:1:9601', + 'name' => 'LogPat', + 'reason' => 'cheat', + 'length' => 0, + ], $token); + $this->assertSame(201, $created->status, json_encode($created->payload)); + + $row = Fixture::rawPdo()->query(sprintf( + 'SELECT aid FROM `%s_log` ORDER BY lid DESC LIMIT 1', + DB_PREFIX + ))->fetch(\PDO::FETCH_ASSOC); + $this->assertIsArray($row); + $this->assertSame(Fixture::adminAid(), (int) $row['aid']); + } + + public function testPatBanWriteIsAttributedToPatAdminWhenCookieSessionIsADifferentAdmin(): void + { + $pdo = Fixture::rawPdo(); + $hash = password_hash('other', PASSWORD_BCRYPT); + $pdo->prepare(sprintf( + 'INSERT INTO `%s_admins` (user, authid, password, gid, email, extraflags, immunity, enabled) + VALUES (?, ?, ?, -1, ?, ?, 0, 1)', + DB_PREFIX + ))->execute(['logwriter', 'STEAM_0:0:9602', $hash, 'logwriter@example.test', ADMIN_ADD_BAN]); + $writerAid = (int) $pdo->lastInsertId(); + + $this->loginAsAdmin(); + $token = $this->mintToken($writerAid); + $created = $this->rest('POST', '/bans', [ + 'steam' => 'STEAM_0:1:9602', + 'name' => 'LogPatB', + 'reason' => 'cheat', + 'length' => 0, + ], $token); + $this->assertSame(201, $created->status, json_encode($created->payload)); + + $row = Fixture::rawPdo()->query(sprintf( + 'SELECT aid FROM `%s_log` ORDER BY lid DESC LIMIT 1', + DB_PREFIX + ))->fetch(\PDO::FETCH_ASSOC); + $this->assertIsArray($row); + $this->assertSame($writerAid, (int) $row['aid']); + $this->assertNotSame(Fixture::adminAid(), (int) $row['aid']); + } + public function testMalformedBearerStaysAnonymousOnPublicGet(): void { $response = $this->rest('GET', '/bans', token: 'not-a-pat'); diff --git a/web/tests/api/RestCommentsTest.php b/web/tests/api/RestCommentsTest.php index f61d5e90c..0d9541b70 100644 --- a/web/tests/api/RestCommentsTest.php +++ b/web/tests/api/RestCommentsTest.php @@ -6,6 +6,41 @@ final class RestCommentsTest extends RestTestCase { + public function testCreateReturnsCommentCidWhenLogAutoIncrementHasDiverged(): void + { + $bid = $this->seedBan('STEAM_0:1:9510'); + $pdo = Fixture::rawPdo(); + $insertLog = $pdo->prepare(sprintf( + 'INSERT INTO `%s_log` (`type`, `title`, `message`, `function`, `query`, `aid`, `host`, `created`) + VALUES ("m", "dummy", "dummy", "", "", -1, "", UNIX_TIMESTAMP())', + DB_PREFIX + )); + for ($i = 0; $i < 5; $i++) { + $insertLog->execute(); + } + + $token = $this->mintToken(); + $created = $this->rest('POST', '/bans/' . $bid . '/comments', [ + 'body' => 'cid contract', + ], $token); + $this->assertSame(201, $created->status, json_encode($created->payload)); + $id = (int) $created->payload['data']['id']; + + $commentCid = (int) $pdo->query(sprintf( + 'SELECT cid FROM `%s_comments` WHERE bid = %d AND type = "B" ORDER BY cid DESC LIMIT 1', + DB_PREFIX, + $bid + ))->fetchColumn(); + $maxLid = (int) $pdo->query(sprintf( + 'SELECT MAX(lid) FROM `%s_log`', + DB_PREFIX + ))->fetchColumn(); + + $this->assertSame($commentCid, $id); + $this->assertNotSame($maxLid, $id); + $this->assertSame('cid contract', $created->payload['data']['body']); + } + public function testCreateListPatchDeleteOnBan(): void { $bid = $this->seedBan('STEAM_0:1:9501'); diff --git a/web/tests/api/RestCommsTest.php b/web/tests/api/RestCommsTest.php index 0717e031a..80f7ada53 100644 --- a/web/tests/api/RestCommsTest.php +++ b/web/tests/api/RestCommsTest.php @@ -121,6 +121,37 @@ public function testInvalidKindIs400(): void $this->assertSame('kind', $response->payload['error']['field'] ?? null); } + public function testAnonymousGetIs404WhenCommsDisabled(): void + { + $cid = $this->seedComm('STEAM_0:1:9305', 1); + $pdo = Fixture::rawPdo(); + $pdo->prepare(sprintf( + 'REPLACE INTO `%s_settings` (`value`, `setting`) VALUES ("0", "config.enablecomms")', + DB_PREFIX + ))->execute(); + \Config::init($GLOBALS['PDO']); + + $list = $this->rest('GET', '/comms'); + $this->assertRestError($list, 404, 'not_found'); + + $get = $this->rest('GET', '/comms/' . $cid); + $this->assertRestError($get, 404, 'not_found'); + + $token = $this->mintToken(); + $pat = $this->rest('GET', '/comms/' . $cid, token: $token); + $this->assertSame(200, $pat->status, json_encode($pat->payload)); + $this->assertSame($cid, $pat->payload['data']['id']); + + $pdo->prepare(sprintf( + 'REPLACE INTO `%s_settings` (`value`, `setting`) VALUES ("1", "config.enablecomms")', + DB_PREFIX + ))->execute(); + \Config::init($GLOBALS['PDO']); + + $restored = $this->rest('GET', '/comms/' . $cid); + $this->assertSame(200, $restored->status, json_encode($restored->payload)); + } + private function seedComm(string $steam, int $type): int { $pdo = Fixture::rawPdo(); diff --git a/web/tests/api/RestServersTest.php b/web/tests/api/RestServersTest.php index 492737197..883c6b72c 100644 --- a/web/tests/api/RestServersTest.php +++ b/web/tests/api/RestServersTest.php @@ -39,6 +39,7 @@ public function testAnonymousGetOmitsRcon(): void $this->assertSame($sid, $data['id']); $this->assertSame('203.0.113.50', $data['ip']); $this->assertArrayNotHasKey('rcon', $data); + $this->assertArrayNotHasKey('group_ids', $data); $this->assertStringNotContainsString('secret-rcon-rest', json_encode($response->payload)); $this->assertSame('Rest Query Host', $data['query']['hostname']); $this->assertSame('de_dust2', $data['query']['map']); @@ -127,6 +128,46 @@ public function testNonNumericSidIs400(): void $this->assertRestError($response, 400, 'validation'); } + public function testAnonymousListHidesDisabledServers(): void + { + $enabledSid = $this->seedServer(); + $pdo = Fixture::rawPdo(); + $pdo->prepare(sprintf( + 'INSERT INTO `%s_servers` (ip, port, rcon, modid, enabled) VALUES (?, ?, ?, 1, 0)', + DB_PREFIX + ))->execute(['203.0.113.60', 27016, '']); + $disabledSid = (int) $pdo->lastInsertId(); + + $anonList = $this->rest('GET', '/servers'); + $this->assertSame(200, $anonList->status, json_encode($anonList->payload)); + $anonIds = array_column($anonList->payload['data'], 'id'); + $this->assertContains($enabledSid, $anonIds); + $this->assertNotContains($disabledSid, $anonIds); + foreach ($anonList->payload['data'] as $row) { + $this->assertArrayNotHasKey('group_ids', $row); + } + + $anonFiltered = $this->rest('GET', '/servers', query: ['enabled' => '0']); + $this->assertSame(200, $anonFiltered->status, json_encode($anonFiltered->payload)); + $this->assertNotContains($disabledSid, array_column($anonFiltered->payload['data'], 'id')); + + $anonGet = $this->rest('GET', '/servers/' . $disabledSid); + $this->assertRestError($anonGet, 404, 'not_found'); + + $token = $this->mintToken(); + $patList = $this->rest('GET', '/servers', token: $token, query: ['enabled' => '0']); + $this->assertSame(200, $patList->status, json_encode($patList->payload)); + $this->assertContains($disabledSid, array_column($patList->payload['data'], 'id')); + foreach ($patList->payload['data'] as $row) { + $this->assertArrayHasKey('group_ids', $row); + } + + $patGet = $this->rest('GET', '/servers/' . $disabledSid, token: $token); + $this->assertSame(200, $patGet->status, json_encode($patGet->payload)); + $this->assertFalse($patGet->payload['data']['enabled']); + $this->assertArrayHasKey('group_ids', $patGet->payload['data']); + } + public function testRconRequiresSmFlagAndServerMapping(): void { $sid = $this->seedServer(); diff --git a/web/tests/api/RestSessionTest.php b/web/tests/api/RestSessionTest.php new file mode 100644 index 000000000..20d8ed17c --- /dev/null +++ b/web/tests/api/RestSessionTest.php @@ -0,0 +1,35 @@ +assertNotFalse($definePos); + $this->assertNotFalse($includePos); + $this->assertLessThan($includePos, $definePos); + } + + /** + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + public function testCsrfInitDoesNotStartSessionWhenRestFlagIsSet(): void + { + if (!defined('SBPP_REST')) { + define('SBPP_REST', true); + } + if (session_status() === PHP_SESSION_ACTIVE) { + session_write_close(); + } + $this->assertNotSame(PHP_SESSION_ACTIVE, session_status()); + \CSRF::init(); + $this->assertNotSame(PHP_SESSION_ACTIVE, session_status()); + } +} diff --git a/web/tests/api/__snapshots__/bans/add_comment_success.json b/web/tests/api/__snapshots__/bans/add_comment_success.json index e67b1aeec..20b4bb34c 100644 --- a/web/tests/api/__snapshots__/bans/add_comment_success.json +++ b/web/tests/api/__snapshots__/bans/add_comment_success.json @@ -7,6 +7,7 @@ "body": "The comment has been successfully published", "kind": "green", "redir": "index.php?p=banlist" - } + }, + "cid": 1 } } From ea648fdb508bbe666221ba47d9b70152f075e3ad Mon Sep 17 00:00:00 2001 From: Maximiliano Jabase Date: Mon, 31 Aug 2026 21:11:40 -0300 Subject: [PATCH 06/27] isolate REST CSRF skip from the phpunit process --- web/includes/Rest/AdminsService.php | 2 +- web/includes/Rest/GroupsService.php | 2 +- web/includes/Rest/PatAuthenticator.php | 11 ++++------- web/tests/api/RestSessionTest.php | 8 ++++---- 4 files changed, 10 insertions(+), 13 deletions(-) diff --git a/web/includes/Rest/AdminsService.php b/web/includes/Rest/AdminsService.php index 814d82490..958d82eb8 100644 --- a/web/includes/Rest/AdminsService.php +++ b/web/includes/Rest/AdminsService.php @@ -626,6 +626,6 @@ private function db(): Database if ($pdo instanceof Database) { return $pdo; } - return new Database(DB_HOST, DB_PORT, DB_NAME, DB_USER, DB_PASS, DB_PREFIX, DB_CHARSET); + return new Database(DB_HOST, (int) DB_PORT, DB_NAME, DB_USER, DB_PASS, DB_PREFIX, DB_CHARSET); } } diff --git a/web/includes/Rest/GroupsService.php b/web/includes/Rest/GroupsService.php index 54de54517..28d3b40c1 100644 --- a/web/includes/Rest/GroupsService.php +++ b/web/includes/Rest/GroupsService.php @@ -55,6 +55,6 @@ private function db(): Database if ($pdo instanceof Database) { return $pdo; } - return new Database(DB_HOST, DB_PORT, DB_NAME, DB_USER, DB_PASS, DB_PREFIX, DB_CHARSET); + return new Database(DB_HOST, (int) DB_PORT, DB_NAME, DB_USER, DB_PASS, DB_PREFIX, DB_CHARSET); } } diff --git a/web/includes/Rest/PatAuthenticator.php b/web/includes/Rest/PatAuthenticator.php index 31fc325eb..6b6f14249 100644 --- a/web/includes/Rest/PatAuthenticator.php +++ b/web/includes/Rest/PatAuthenticator.php @@ -229,12 +229,9 @@ public static function authorizationHeader(): string return $direct; } if (function_exists('getallheaders')) { - $headers = getallheaders(); - if (is_array($headers)) { - foreach ($headers as $name => $value) { - if (strtolower((string) $name) === 'authorization') { - return (string) $value; - } + foreach (getallheaders() as $name => $value) { + if (strtolower((string) $name) === 'authorization') { + return (string) $value; } } } @@ -247,6 +244,6 @@ private static function db(): Database if ($pdo instanceof Database) { return $pdo; } - return new Database(DB_HOST, DB_PORT, DB_NAME, DB_USER, DB_PASS, DB_PREFIX, DB_CHARSET); + return new Database(DB_HOST, (int) DB_PORT, DB_NAME, DB_USER, DB_PASS, DB_PREFIX, DB_CHARSET); } } diff --git a/web/tests/api/RestSessionTest.php b/web/tests/api/RestSessionTest.php index 20d8ed17c..56ed6635f 100644 --- a/web/tests/api/RestSessionTest.php +++ b/web/tests/api/RestSessionTest.php @@ -2,6 +2,8 @@ namespace Sbpp\Tests\Api; +use PHPUnit\Framework\Attributes\PreserveGlobalState; +use PHPUnit\Framework\Attributes\RunInSeparateProcess; use PHPUnit\Framework\TestCase; final class RestSessionTest extends TestCase @@ -16,10 +18,8 @@ public function testV1EntryDefinesRestFlagBeforeInit(): void $this->assertLessThan($includePos, $definePos); } - /** - * @runInSeparateProcess - * @preserveGlobalState disabled - */ + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] public function testCsrfInitDoesNotStartSessionWhenRestFlagIsSet(): void { if (!defined('SBPP_REST')) { From 5757ec9edc8eef8e4257646cb9294f3c0d3ba232 Mon Sep 17 00:00:00 2001 From: Maximiliano Jabase Date: Thu, 3 Sep 2026 09:29:19 -0300 Subject: [PATCH 07/27] fix(rest): skip panel cookie JWT under SBPP_REST REST was parsing sbpp_auth and sliding login_tokens before PAT bind. --- AGENTS.md | 4 ++- ARCHITECTURE.md | 7 ++-- web/includes/Auth/Auth.php | 4 +++ web/tests/api/RestSessionTest.php | 60 +++++++++++++++++++++++++++++++ 4 files changed, 71 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index de4b6e670..c58f86155 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -989,7 +989,9 @@ fallback). This is a **separate product** from `POST /api.php`. 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. After PAT bind, + 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 seconds. Optional `kick: true` on POST `/bans` fans diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 787473220..485cfba21 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -317,11 +317,12 @@ GET /api/v1/… -> │ api/v1.php │ -> │ FrontController │ -> │ ``` 1. `api/v1.php` defines `SBPP_REST` before including `init.php` so - `CSRF::init()` does not start a PHP session, then calls - `FrontController::dispatch()`. + `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 ignored. + `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, diff --git a/web/includes/Auth/Auth.php b/web/includes/Auth/Auth.php index cd2fd5aff..0142f6152 100644 --- a/web/includes/Auth/Auth.php +++ b/web/includes/Auth/Auth.php @@ -56,6 +56,10 @@ public static function logout(): bool public static function verify(): ?Token { + if (defined('SBPP_REST')) { + return null; + } + $cookie = self::getJWTFromCookie(); if (empty($cookie) || preg_match('/.*\..*\..*\./', $cookie)) { return null; diff --git a/web/tests/api/RestSessionTest.php b/web/tests/api/RestSessionTest.php index 56ed6635f..5dbb322ff 100644 --- a/web/tests/api/RestSessionTest.php +++ b/web/tests/api/RestSessionTest.php @@ -5,6 +5,7 @@ use PHPUnit\Framework\Attributes\PreserveGlobalState; use PHPUnit\Framework\Attributes\RunInSeparateProcess; use PHPUnit\Framework\TestCase; +use Sbpp\Tests\Fixture; final class RestSessionTest extends TestCase { @@ -32,4 +33,63 @@ public function testCsrfInitDoesNotStartSessionWhenRestFlagIsSet(): void \CSRF::init(); $this->assertNotSame(PHP_SESSION_ACTIVE, session_status()); } + + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] + public function testAuthVerifyDoesNotReadCookieWhenRestFlagIsSet(): void + { + if (!defined('SBPP_REST')) { + define('SBPP_REST', true); + } + Fixture::reset(); + + $jti = \Sbpp\Security\Crypto::genJTI(); + $past = time() - 120; + $pdo = Fixture::rawPdo(); + $pdo->prepare(sprintf( + 'INSERT INTO `%s_login_tokens` (jti, secret, lastAccessed) VALUES (?, ?, ?)', + DB_PREFIX + ))->execute([$jti, 'test-secret', $past]); + + $token = \Sbpp\Auth\JWT::create($jti, 3600, Fixture::adminAid()); + $_COOKIE['sbpp_auth'] = $token->toString(); + + $this->assertNull(\Auth::verify()); + + $stmt = $pdo->prepare(sprintf( + 'SELECT lastAccessed FROM `%s_login_tokens` WHERE jti = ?', + DB_PREFIX + )); + $stmt->execute([$jti]); + $this->assertSame($past, (int) $stmt->fetchColumn()); + } + + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] + public function testAuthVerifySlidesSessionWhenRestFlagIsNotSet(): void + { + $this->assertFalse(defined('SBPP_REST')); + Fixture::reset(); + + $jti = \Sbpp\Security\Crypto::genJTI(); + $past = time() - 120; + $pdo = Fixture::rawPdo(); + $pdo->prepare(sprintf( + 'INSERT INTO `%s_login_tokens` (jti, secret, lastAccessed) VALUES (?, ?, ?)', + DB_PREFIX + ))->execute([$jti, 'test-secret', $past]); + + $token = \Sbpp\Auth\JWT::create($jti, 3600, Fixture::adminAid()); + $_COOKIE['sbpp_auth'] = $token->toString(); + + $verified = \Auth::verify(); + $this->assertNotNull($verified); + + $stmt = $pdo->prepare(sprintf( + 'SELECT lastAccessed FROM `%s_login_tokens` WHERE jti = ?', + DB_PREFIX + )); + $stmt->execute([$jti]); + $this->assertGreaterThan($past, (int) $stmt->fetchColumn()); + } } From e0f99e1f6e9c2663992bef96b95e56afc1fe415c Mon Sep 17 00:00:00 2001 From: Maximiliano Jabase Date: Thu, 3 Sep 2026 09:30:09 -0300 Subject: [PATCH 08/27] fix(rest): log REST admin create and update PUT /admins skipped the panel audit trail on the staff-hub writes. --- web/includes/Rest/AdminsService.php | 7 +++++++ web/tests/api/RestAdminsTest.php | 21 +++++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/web/includes/Rest/AdminsService.php b/web/includes/Rest/AdminsService.php index 958d82eb8..59520b667 100644 --- a/web/includes/Rest/AdminsService.php +++ b/web/includes/Rest/AdminsService.php @@ -11,6 +11,7 @@ use Sbpp\Api\ApiError; use Sbpp\Auth\UserManager; use Sbpp\Db\Database; +use Sbpp\Log; use Sbpp\Security\Crypto; use SteamID\SteamID; use WebPermission; @@ -223,6 +224,7 @@ private function create(string $steam64, array $body): array $sids = function_exists('_api_admins_rehash_sids') ? _api_admins_rehash_sids($aid) : []; $fresh = $this->requireRow(new AdminId($aid, null)); + Log::add(\LogType::Message, 'Admin added', "Admin ($name) has been added."); return [ 'admin' => $this->toResource($fresh, $this->serverIds($aid)), 'rehash' => Rehasher::run($sids), @@ -336,6 +338,11 @@ private function update(int $aid, array $body, bool $reactivate): array $sids = function_exists('_api_admins_rehash_sids') ? _api_admins_rehash_sids($aid) : []; $fresh = $this->requireRow(new AdminId($aid, null)); + Log::add( + \LogType::Message, + 'Admin Details Updated', + 'Admin (' . $name . ') details has been changed.', + ); return [ 'admin' => $this->toResource($fresh, $this->serverIds($aid)), 'rehash' => Rehasher::run($sids), diff --git a/web/tests/api/RestAdminsTest.php b/web/tests/api/RestAdminsTest.php index e83f04239..7056c2d74 100644 --- a/web/tests/api/RestAdminsTest.php +++ b/web/tests/api/RestAdminsTest.php @@ -21,6 +21,10 @@ public function testPutSteam64CreatesAdmin(): void $this->assertTrue($data['enabled']); $this->assertArrayHasKey('rehash', $response->payload['meta']); $this->assertArrayHasKey('attempted', $response->payload['meta']['rehash']); + $this->assertSame( + 'Admin (RestBot) has been added.', + $this->latestLogMessage('Admin added'), + ); } public function testPutSteam64UpdatesExisting(): void @@ -34,6 +38,10 @@ public function testPutSteam64UpdatesExisting(): void $this->assertSame(200, $response->status, json_encode($response->payload)); $this->assertSame('RestBotUpdated', $response->payload['data']['name']); $this->assertSame(12, $response->payload['data']['immunity']); + $this->assertSame( + 'Admin (RestBotUpdated) details has been changed.', + $this->latestLogMessage('Admin Details Updated'), + ); } public function testPutAidMissingIs404(): void @@ -269,4 +277,17 @@ private function insertAdmin(string $user, string $steam, int $flags): int ))->execute([$user, $steam, $hash, $user . '@example.test', $flags]); return (int) $pdo->lastInsertId(); } + + private function latestLogMessage(string $title): string + { + $pdo = Fixture::rawPdo(); + $stmt = $pdo->prepare(sprintf( + 'SELECT message FROM `%s_log` WHERE `title` = ? ORDER BY lid DESC LIMIT 1', + DB_PREFIX + )); + $stmt->execute([$title]); + $row = $stmt->fetch(\PDO::FETCH_ASSOC); + $this->assertIsArray($row, 'Expected an audit-log entry titled "' . $title . '"'); + return (string) $row['message']; + } } From a36172b7123ab28402b1bf2540c44b7422ace120 Mon Sep 17 00:00:00 2001 From: Maximiliano Jabase Date: Thu, 3 Sep 2026 09:31:45 -0300 Subject: [PATCH 09/27] fix(rest): 404 anonymous comm comments when comms are off GET /comms/{cid}/comments leaked parent existence on installs with config.enablecomms disabled. Match GET /comms: anonymous 404, PAT still reads. --- AGENTS.md | 4 +++- ARCHITECTURE.md | 4 +++- .../src/content/docs/configuring/rest-api.mdx | 6 +++-- web/includes/Rest/CommentsService.php | 3 +++ web/includes/Rest/CommsService.php | 5 +--- web/includes/Rest/PublicVisibility.php | 12 ++++++++++ web/tests/api/RestCommentsTest.php | 24 +++++++++++++++++++ 7 files changed, 50 insertions(+), 8 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c58f86155..6b15a2a21 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1002,7 +1002,9 @@ fallback). This is a **separate product** from `POST /api.php`. - GET `/protests` and `/submissions` require the matching queue flags. 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). DELETE `/comments/{id}` is Owner. GET/PATCH + still see them). Anonymous GET `/comms/{cid}/comments` is 404 when + `config.enablecomms` is off (a PAT still reads), matching GET + `/comms`. 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 diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 485cfba21..ef1c63bb0 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -355,7 +355,9 @@ Writes reuse `servers.add` / `servers.remove` / `servers.send_rcon`, Slice 3: `/protests` and `/submissions` (GET list/get, DELETE hard-delete via `protests.remove` / `submissions.remove` with `archiv=0`), nested comments on `/bans/{bid}/comments` and `/comms/{cid}/comments` (public GET -honours `config.enablepubliccomments` and `banlist.hideadminname`; POST / +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`; DELETE is Owner via `bans.remove_comment`), `/settings` GET+PATCH (dedicated; never `smtp.pass` or `telemetry.instance_id`). diff --git a/docs/src/content/docs/configuring/rest-api.mdx b/docs/src/content/docs/configuring/rest-api.mdx index 5f90138d9..a4b0fc7dc 100644 --- a/docs/src/content/docs/configuring/rest-api.mdx +++ b/docs/src/content/docs/configuring/rest-api.mdx @@ -154,7 +154,7 @@ A 429 includes `Retry-After`. | DELETE | `/protests/{pid}` | Hard delete | | GET | `/submissions`, `/submissions/{sid}` | Current queue. `archived=true` for the archive | | DELETE | `/submissions/{sid}` | Hard delete | -| GET | `/bans/{bid}/comments`, `/comms/{cid}/comments` | Public. Empty when public comments are off | +| GET | `/bans/{bid}/comments`, `/comms/{cid}/comments` | Public. Empty when public comments are off. Anonymous GET `/comms/{cid}/comments` is 404 when Comm blocks are off (`config.enablecomms`); a PAT still reads | | POST | `/bans/{bid}/comments`, `/comms/{cid}/comments` | `body`. Any web admin | | PATCH | `/comments/{cid}` | `body` | | DELETE | `/comments/{cid}` | Owner only | @@ -172,7 +172,9 @@ GET `/servers` never includes `rcon`, even with a PAT. Anonymous GET `group_ids`, and 404s a disabled `{sid}`. A PAT may pass `enabled=` and sees `group_ids`. A well-formed token that is revoked, expired, or unknown is 401 even on those GETs. GET comments on a ban or comm is also -public (empty when public comments are off). GET `/protests`, +public (empty when public comments are off). Anonymous GET +`/comms/{cid}/comments` is 404 when Comm blocks are off, matching GET +`/comms`. A PAT still reads. GET `/protests`, `/submissions`, and `/settings` need a PAT. POST `/bans` `length` is minutes (0 = permanent), matching the panel form. diff --git a/web/includes/Rest/CommentsService.php b/web/includes/Rest/CommentsService.php index d073180ad..fbd9bc795 100644 --- a/web/includes/Rest/CommentsService.php +++ b/web/includes/Rest/CommentsService.php @@ -164,6 +164,9 @@ private function assertParent(int $parentId, string $ctype): void 'C' => '`:prefix_comms`', default => throw new ApiError('bad_type', 'Bad comment type.', null, 400), }; + if ($ctype === 'C') { + PublicVisibility::assertCommsFeature(); + } $pdo = $this->db(); $pdo->query("SELECT bid FROM {$table} WHERE bid = :id"); $pdo->bind(':id', $parentId); diff --git a/web/includes/Rest/CommsService.php b/web/includes/Rest/CommsService.php index 07a6dd9b0..4d7b6336f 100644 --- a/web/includes/Rest/CommsService.php +++ b/web/includes/Rest/CommsService.php @@ -11,7 +11,6 @@ use Sbpp\Api\Api; use Sbpp\Api\ApiError; use Sbpp\Auth\UserManager; -use Sbpp\Config; use Sbpp\Db\Database; use SteamID\SteamID; @@ -359,9 +358,7 @@ private function rowsAfter(int $before, string $rawSteam): array private function assertPublicFeature(): void { - if (!Config::getBool('config.enablecomms') && !PublicVisibility::isAdmin()) { - throw new ApiError('not_found', 'Not found.', null, 404); - } + PublicVisibility::assertCommsFeature(); } private function db(): Database diff --git a/web/includes/Rest/PublicVisibility.php b/web/includes/Rest/PublicVisibility.php index 99b26cf5a..e9bd168f6 100644 --- a/web/includes/Rest/PublicVisibility.php +++ b/web/includes/Rest/PublicVisibility.php @@ -7,6 +7,7 @@ namespace Sbpp\Rest; +use Sbpp\Api\ApiError; use Sbpp\Auth\UserManager; use Sbpp\Config; @@ -32,4 +33,15 @@ public static function isAdmin(): bool $userbank = $GLOBALS['userbank'] ?? null; return $userbank instanceof UserManager && $userbank->is_admin(); } + + /** + * Anonymous GET `/comms` (and nested `/comms/{cid}/comments`) is 404 + * when Comm blocks are off. A PAT still reads. + */ + public static function assertCommsFeature(): void + { + if (!Config::getBool('config.enablecomms') && !self::isAdmin()) { + throw new ApiError('not_found', 'Not found.', null, 404); + } + } } diff --git a/web/tests/api/RestCommentsTest.php b/web/tests/api/RestCommentsTest.php index 0d9541b70..9ea01be81 100644 --- a/web/tests/api/RestCommentsTest.php +++ b/web/tests/api/RestCommentsTest.php @@ -103,6 +103,30 @@ public function testAnonymousSeesCommentsWhenPublicEnabled(): void $this->assertNull($anon->payload['data'][0]['author_aid']); } + public function testAnonymousGetCommCommentsIs404WhenCommsDisabled(): void + { + $cid = $this->seedComm('STEAM_0:1:9506'); + $pdo = Fixture::rawPdo(); + $pdo->prepare(sprintf( + 'REPLACE INTO `%s_settings` (`value`, `setting`) VALUES ("0", "config.enablecomms")', + DB_PREFIX + ))->execute(); + \Config::init($GLOBALS['PDO']); + + $anon = $this->rest('GET', '/comms/' . $cid . '/comments'); + $this->assertRestError($anon, 404, 'not_found'); + + $token = $this->mintToken(); + $pat = $this->rest('GET', '/comms/' . $cid . '/comments', token: $token); + $this->assertSame(200, $pat->status, json_encode($pat->payload)); + + $pdo->prepare(sprintf( + 'REPLACE INTO `%s_settings` (`value`, `setting`) VALUES ("1", "config.enablecomms")', + DB_PREFIX + ))->execute(); + \Config::init($GLOBALS['PDO']); + } + public function testCreateOnComm(): void { $cid = $this->seedComm('STEAM_0:1:9503'); From 790fefdc600d0500e3804de25b465bd33359236e Mon Sep 17 00:00:00 2001 From: Maximiliano Jabase Date: Thu, 3 Sep 2026 09:32:57 -0300 Subject: [PATCH 10/27] fix(rest): prune stale rate-limit files Public GET keyed by IP left one file per address forever. Sweep a few old windows per request, and document that anonymous keys are REMOTE_ADDR. --- .../src/content/docs/configuring/rest-api.mdx | 10 ++++-- web/includes/Rest/RateLimiter.php | 34 +++++++++++++++++++ web/tests/api/RestAuthTest.php | 22 ++++++++++++ 3 files changed, 64 insertions(+), 2 deletions(-) diff --git a/docs/src/content/docs/configuring/rest-api.mdx b/docs/src/content/docs/configuring/rest-api.mdx index a4b0fc7dc..fd8ecdfb4 100644 --- a/docs/src/content/docs/configuring/rest-api.mdx +++ b/docs/src/content/docs/configuring/rest-api.mdx @@ -115,8 +115,14 @@ define('SB_REST_CORS_ORIGINS', 'https://staff.example.com'); ## Rate limit Default 60 requests per minute. Anonymous callers (OpenAPI spec, public -ban/comms/servers GET) are keyed by IP. Token callers are keyed by token. -A 429 includes `Retry-After`. +ban/comms/servers GET) are keyed by `REMOTE_ADDR`. Token callers are +keyed by token. A 429 includes `Retry-After`. + +If you terminate TLS at a reverse proxy, the production Apache config +already sets `RemoteIPHeader X-Forwarded-For` +(`docker/apache/sbpp-prod.conf`). nginx needs `real_ip`. Without that, +every anonymous visitor shares one 60/min bucket. Do not parse +`X-Forwarded-For` in PHP. It is spoofable. ## Routes diff --git a/web/includes/Rest/RateLimiter.php b/web/includes/Rest/RateLimiter.php index a3e1a193f..b0f84980a 100644 --- a/web/includes/Rest/RateLimiter.php +++ b/web/includes/Rest/RateLimiter.php @@ -10,12 +10,18 @@ /** * Fixed-window file rate limiter under `SB_CACHE/rest-rl/`. * Anonymous callers are keyed by IP. Authenticated callers by token id. + * + * Read-modify-write is not atomic: concurrent requests under the same + * key can undercount. That is acceptable for a limiter. Do not "fix" + * it with flock or Redis in a drive-by. */ final class RateLimiter { public const DEFAULT_LIMIT = 60; public const WINDOW_SECONDS = 60; + private const GC_MAX_FILES = 32; + private static ?int $limitOverride = null; public static function setLimitForTests(?int $limit): void @@ -53,6 +59,8 @@ public static function consume(string $key): array return ['ok' => true, 'remaining' => $limit, 'retry_after' => $retryAfter, 'limit' => $limit]; } + self::gc($dir); + $path = $dir . '/' . hash('sha1', $key) . '.json'; $count = 0; $storedWindow = $window; @@ -103,6 +111,32 @@ private static function write(string $path, int $window, int $count): void } } + /** + * Unlink files whose mtime is older than two windows. Caps how many + * entries one request will look at so a large dir cannot stall. + */ + private static function gc(string $dir): void + { + $handle = @opendir($dir); + if ($handle === false) { + return; + } + $cutoff = time() - (2 * self::WINDOW_SECONDS); + $scanned = 0; + while ($scanned < self::GC_MAX_FILES && ($name = readdir($handle)) !== false) { + if (!str_ends_with($name, '.json')) { + continue; + } + $scanned++; + $path = $dir . '/' . $name; + $mtime = @filemtime($path); + if ($mtime !== false && $mtime < $cutoff) { + @unlink($path); + } + } + closedir($handle); + } + private static function dir(): string { $root = defined('SB_CACHE') ? SB_CACHE : (defined('ROOT') ? ROOT . 'cache/' : sys_get_temp_dir() . '/sbpp-rest-rl/'); diff --git a/web/tests/api/RestAuthTest.php b/web/tests/api/RestAuthTest.php index dfdc361eb..792e1b271 100644 --- a/web/tests/api/RestAuthTest.php +++ b/web/tests/api/RestAuthTest.php @@ -98,6 +98,28 @@ public function testRateLimitReturns429(): void $this->assertArrayHasKey('Retry-After', $second->headers); } + public function testRateLimiterGcUnlinksStaleFilesAndKeepsInWindowFiles(): void + { + RateLimiter::resetForTests(); + RateLimiter::consume('gc-keep'); + + $dir = rtrim(str_replace('\\', '/', SB_CACHE), '/') . '/rest-rl'; + $this->assertDirectoryExists($dir); + + $stale = $dir . '/stale-gc-test.json'; + $fresh = $dir . '/fresh-gc-test.json'; + file_put_contents($stale, '{"window":0,"count":1}'); + file_put_contents($fresh, '{"window":0,"count":1}'); + $this->assertTrue(touch($stale, time() - (2 * RateLimiter::WINDOW_SECONDS) - 10)); + $this->assertTrue(touch($fresh, time())); + + RateLimiter::consume('gc-keep'); + + $this->assertFileDoesNotExist($stale); + $this->assertFileExists($fresh); + $this->assertFileExists($dir . '/' . hash('sha1', 'gc-keep') . '.json'); + } + public function testUnknownWellFormedPatIs401OnPublicGet(): void { $secret = PatAuthenticator::SECRET_PREFIX . str_repeat('cd', 32); From d01e8e822cb11cc50fcdc80942b936f34db8540d Mon Sep 17 00:00:00 2001 From: Maximiliano Jabase Date: Thu, 3 Sep 2026 09:36:13 -0300 Subject: [PATCH 11/27] fix(account): require password to mint PAT, revoke on password change A stolen cookie could mint a never-expiring token that survived logout and password reset. Step-up with the current password, and kill every token when that password changes. --- AGENTS.md | 3 + ARCHITECTURE.md | 3 +- .../src/content/docs/configuring/rest-api.mdx | 7 +- web/api/handlers/account.php | 9 ++- web/includes/Rest/PatAuthenticator.php | 13 ++++ web/tests/api/AccountTest.php | 65 ++++++++++++++++++- web/tests/e2e/pages/admin/MyAccount.ts | 4 ++ web/tests/e2e/specs/flows/rest-api.spec.ts | 3 + web/themes/default/page_youraccount.tpl | 19 +++++- 9 files changed, 116 insertions(+), 10 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6b15a2a21..f75503635 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -967,6 +967,9 @@ fallback). This is a **separate product** from `POST /api.php`. `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, diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ef1c63bb0..c4205d5a2 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -335,7 +335,8 @@ GET /api/v1/… -> │ api/v1.php │ -> │ FrontController │ -> │ 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). +`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-* diff --git a/docs/src/content/docs/configuring/rest-api.mdx b/docs/src/content/docs/configuring/rest-api.mdx index fd8ecdfb4..a50e34a03 100644 --- a/docs/src/content/docs/configuring/rest-api.mdx +++ b/docs/src/content/docs/configuring/rest-api.mdx @@ -25,8 +25,8 @@ that admin. 1. Sign in to the panel. 2. Open **Your account**. -3. Under **API tokens**, give the token a name (for example `website-next`) - and an expiry (or Never). +3. Under **API tokens**, give the token a name (for example `website-next`), + enter your current password, and pick an expiry (or Never). 4. Copy the secret. It is shown once. The secret looks like `sbpp_pat_` plus 64 hex characters. The panel stores @@ -36,7 +36,8 @@ The token inherits that admin's web flags. There are no extra scopes. A read-only bot is an admin with list flags, not a trimmed token. Soft-retired admins (`enabled = 0`) cannot use a token. Password lockout -does not apply. Revoke is the kill switch. +does not apply. Changing your panel password revokes every token. Revoke +is otherwise the kill switch. ## Call the API diff --git a/web/api/handlers/account.php b/web/api/handlers/account.php index 2d9806ee0..80775fac5 100644 --- a/web/api/handlers/account.php +++ b/web/api/handlers/account.php @@ -70,6 +70,8 @@ function api_account_change_password(array $params): array $GLOBALS['PDO']->bind(':aid', $aid); $GLOBALS['PDO']->execute(); + \Sbpp\Rest\PatAuthenticator::revokeAllForAid($aid); + $GLOBALS['PDO']->query("SELECT user FROM `:prefix_admins` WHERE aid = :aid"); $GLOBALS['PDO']->bind(':aid', $aid); $admin = $GLOBALS['PDO']->single(); @@ -163,7 +165,7 @@ function api_account_tokens_list(array $params): array } /** - * @param array{name?: string, expires_days?: int|string|null} $params + * @param array{name?: string, expires_days?: int|string|null, password?: string} $params * @return array{id: int, name: string, token: string, token_prefix: string, created: int, expires_at: int|null} */ function api_account_tokens_create(array $params): array @@ -181,6 +183,11 @@ function api_account_tokens_create(array $params): array } $expiresAt = $days === 0 ? null : time() + ($days * 86400); + $password = (string) ($params['password'] ?? ''); + if (!$userbank->isCurrentPasswordValid($userbank->GetAid(), $password)) { + throw new ApiError('bad_password', 'Current password doesn\'t match.', 'current'); + } + $minted = \Sbpp\Rest\PatAuthenticator::mint($userbank->GetAid(), $name, $expiresAt); Log::add(LogType::Message, 'API token created', 'API token "' . $name . '" created.'); diff --git a/web/includes/Rest/PatAuthenticator.php b/web/includes/Rest/PatAuthenticator.php index 6b6f14249..fd3364608 100644 --- a/web/includes/Rest/PatAuthenticator.php +++ b/web/includes/Rest/PatAuthenticator.php @@ -136,6 +136,19 @@ public static function revoke(int $aid, int $id): bool return $pdo->rowCount() > 0; } + public static function revokeAllForAid(int $aid): int + { + $pdo = self::db(); + $pdo->query( + 'UPDATE `:prefix_api_tokens` SET revoked_at = :now' + . ' WHERE aid = :aid AND revoked_at IS NULL' + ); + $pdo->bind(':now', time()); + $pdo->bind(':aid', $aid); + $pdo->execute(); + return $pdo->rowCount(); + } + /** * @return Identity|null */ diff --git a/web/tests/api/AccountTest.php b/web/tests/api/AccountTest.php index d59b1cd5a..0d052d16d 100644 --- a/web/tests/api/AccountTest.php +++ b/web/tests/api/AccountTest.php @@ -261,6 +261,7 @@ public function testTokensCreateReturnsSecretOnce(): void $env = $this->api('account.tokens_create', [ 'name' => 'bot', 'expires_days' => 0, + 'password' => 'admin', ]); $this->assertTrue($env['ok'] ?? false, json_encode($env)); $this->assertMatchesRegularExpression('/^sbpp_pat_[0-9a-f]{64}$/', $env['data']['token']); @@ -271,10 +272,70 @@ public function testTokensCreateReturnsSecretOnce(): void ); } + public function testTokensCreateRejectsMissingPassword(): void + { + $this->loginAsAdmin(); + $env = $this->api('account.tokens_create', [ + 'name' => 'bot', + 'expires_days' => 0, + ]); + $this->assertEnvelopeError($env, 'bad_password'); + $this->assertSame('current', $env['error']['field'] ?? null); + } + + public function testTokensCreateRejectsWrongPassword(): void + { + $this->loginAsAdmin(); + $env = $this->api('account.tokens_create', [ + 'name' => 'bot', + 'expires_days' => 0, + 'password' => 'wrong', + ]); + $this->assertEnvelopeError($env, 'bad_password'); + $this->assertSame('current', $env['error']['field'] ?? null); + } + + public function testChangePasswordRevokesApiTokens(): void + { + $this->loginAsAdmin(); + $created = $this->api('account.tokens_create', [ + 'name' => 'bot', + 'expires_days' => 0, + 'password' => 'admin', + ]); + $this->assertTrue($created['ok'] ?? false, json_encode($created)); + $secret = (string) $created['data']['token']; + + $changed = $this->api('account.change_password', [ + 'aid' => Fixture::adminAid(), + 'old_password' => 'admin', + 'new_password' => 'a-much-better-password', + ]); + $this->assertFalse($changed['ok'] ?? true); + $this->assertSame('index.php?p=login', $changed['redirect'] ?? null); + $this->assertNull(\Sbpp\Rest\PatAuthenticator::resolve($secret)); + + \Sbpp\Rest\RateLimiter::setLimitForTests(100000); + $prevServer = $_SERVER; + $_SERVER['REQUEST_METHOD'] = 'GET'; + $_SERVER['PATH_INFO'] = '/me'; + $_SERVER['REQUEST_URI'] = '/api/v1.php/me'; + $_SERVER['REMOTE_ADDR'] = '127.0.0.1'; + $_SERVER['HTTP_AUTHORIZATION'] = 'Bearer ' . $secret; + try { + $response = \Sbpp\Rest\FrontController::dispatch(null); + } finally { + $_SERVER = $prevServer; + \Sbpp\Rest\RateLimiter::setLimitForTests(null); + } + $this->assertSame(401, $response->status, json_encode($response->payload)); + $this->assertSame('unauthorized', $response->payload['error']['code'] ?? null); + } + public function testTokensListOmitsSecret(): void { $this->loginAsAdmin(); - $this->api('account.tokens_create', ['name' => 'bot', 'expires_days' => 0]); + $this->api('account.tokens_create', ['name' => 'bot', 'expires_days' => 0, 'password' => 'admin']); $env = $this->api('account.tokens_list'); $this->assertTrue($env['ok'] ?? false, json_encode($env)); $this->assertCount(1, $env['data']['tokens']); @@ -286,7 +347,7 @@ public function testTokensListOmitsSecret(): void public function testTokensRevokeRemovesFromList(): void { $this->loginAsAdmin(); - $created = $this->api('account.tokens_create', ['name' => 'bot', 'expires_days' => 0]); + $created = $this->api('account.tokens_create', ['name' => 'bot', 'expires_days' => 0, 'password' => 'admin']); $id = (int) $created['data']['id']; $env = $this->api('account.tokens_revoke', ['id' => $id]); $this->assertTrue($env['ok'] ?? false, json_encode($env)); diff --git a/web/tests/e2e/pages/admin/MyAccount.ts b/web/tests/e2e/pages/admin/MyAccount.ts index e82f24d88..cbd0a4e8e 100644 --- a/web/tests/e2e/pages/admin/MyAccount.ts +++ b/web/tests/e2e/pages/admin/MyAccount.ts @@ -34,6 +34,10 @@ export class MyAccountPage extends BasePage { return this.page.locator('[data-testid="account-token-name"]'); } + get tokenPassword(): Locator { + return this.page.locator('[data-testid="account-token-password"]'); + } + get tokenCreate(): Locator { return this.page.locator('[data-testid="account-token-create"]'); } diff --git a/web/tests/e2e/specs/flows/rest-api.spec.ts b/web/tests/e2e/specs/flows/rest-api.spec.ts index 1d10b8005..9cfb45cea 100644 --- a/web/tests/e2e/specs/flows/rest-api.spec.ts +++ b/web/tests/e2e/specs/flows/rest-api.spec.ts @@ -21,6 +21,7 @@ test.describe('REST API v1', () => { const tokenName = `e2e-rest-${Date.now()}`; await account.tokenName.fill(tokenName); + await account.tokenPassword.fill('admin'); await account.tokenCreate.click(); await expect(account.tokenSecret).toHaveText(/^sbpp_pat_[0-9a-f]{64}$/); const secret = (await account.tokenSecret.textContent()) ?? ''; @@ -66,6 +67,7 @@ test.describe('REST API v1', () => { const tokenName = `e2e-rest-ban-${Date.now()}`; await account.tokenName.fill(tokenName); + await account.tokenPassword.fill('admin'); await account.tokenCreate.click(); await expect(account.tokenSecret).toHaveText(/^sbpp_pat_[0-9a-f]{64}$/); const secret = (await account.tokenSecret.textContent()) ?? ''; @@ -126,6 +128,7 @@ test.describe('REST API v1', () => { const tokenName = `e2e-rest-srv-${Date.now()}`; await account.tokenName.fill(tokenName); + await account.tokenPassword.fill('admin'); await account.tokenCreate.click(); await expect(account.tokenSecret).toHaveText(/^sbpp_pat_[0-9a-f]{64}$/); const secret = (await account.tokenSecret.textContent()) ?? ''; diff --git a/web/themes/default/page_youraccount.tpl b/web/themes/default/page_youraccount.tpl index 4450080ce..9a19ff5e0 100644 --- a/web/themes/default/page_youraccount.tpl +++ b/web/themes/default/page_youraccount.tpl @@ -308,7 +308,7 @@

API tokens

-

Personal access tokens for the REST API. The secret is shown once when you create it.

+

Personal access tokens for the REST API. Creating one requires your current password. The secret is shown once. Changing your panel password revokes every token.

@@ -335,6 +335,12 @@
+
+ + + +
@@ -669,22 +675,29 @@ tokenForm.addEventListener('submit', function (ev) { ev.preventDefault(); setMsg('account-token-name-msg', ''); + setMsg('account-token-password-msg', ''); var name = val('account-token-name'); + var password = val('account-token-password'); var expiryEl = document.getElementById('account-token-expiry'); var days = expiryEl && 'value' in expiryEl ? parseInt(String(expiryEl.value), 10) : 0; if (name.length === 0) { setMsg('account-token-name-msg', 'Give this token a name.'); return; } + if (password.length === 0) { + setMsg('account-token-password-msg', 'Enter your current password.'); + return; + } var createBtn = tokenForm.querySelector('[data-testid="account-token-create"]'); setBusy(createBtn, true); sb.api.call(Actions.AccountTokensCreate, { name: name, - expires_days: days + expires_days: days, + password: password }).then(function (env) { setBusy(createBtn, false); if (env && env.redirect) return; - if (showFieldError('account-token-', env && env.error, { name: 'name' })) return; + if (showFieldError('account-token-', env && env.error, { name: 'name', current: 'password' })) return; if (!env || !env.ok || !env.data) { flashFailure(env); return; From 7a9651e5e15c9ab70c32d0c624fcdf96a9ac446d Mon Sep 17 00:00:00 2001 From: Maximiliano Jabase Date: Thu, 3 Sep 2026 09:38:02 -0300 Subject: [PATCH 12/27] fix(rest): return insert ids from mods.add and comms.add REST create was guessing the new row with SELECT/MAX, which races under concurrent staff-hub writes. Handlers already know lastInsertId; surface it. --- web/api/handlers/comms.php | 4 ++ web/api/handlers/mods.php | 1 + web/includes/Rest/CommentsService.php | 16 ------- web/includes/Rest/CommsService.php | 48 +++---------------- web/includes/Rest/ModsService.php | 12 ++--- web/tests/api/CommsTest.php | 9 +++- web/tests/api/ModsTest.php | 5 +- web/tests/api/RestCommsTest.php | 1 + .../__snapshots__/comms/add_gag_success.json | 3 +- .../api/__snapshots__/mods/add_success.json | 1 + 10 files changed, 32 insertions(+), 68 deletions(-) diff --git a/web/api/handlers/comms.php b/web/api/handlers/comms.php index 548710ac0..9fb58406f 100644 --- a/web/api/handlers/comms.php +++ b/web/api/handlers/comms.php @@ -98,17 +98,20 @@ function api_comms_add(array $params): array } $adminName = (string) $userbank->GetProperty('user'); + $bids = []; if ($type === 1 || $type === 3) { $GLOBALS['PDO']->query( "INSERT INTO `:prefix_comms`(created,type,authid,name,ends,length,reason,aid,adminIp,admin_name) VALUES (UNIX_TIMESTAMP(),1,?,?,(UNIX_TIMESTAMP() + ?),?,?,?,?,?)" )->execute([$steam, $nickname, $length * 60, $len, $reason, $userbank->GetAid(), $_SERVER['REMOTE_ADDR'] ?? '', $adminName]); + $bids[] = (int) $GLOBALS['PDO']->lastInsertId(); } if ($type === 2 || $type === 3) { $GLOBALS['PDO']->query( "INSERT INTO `:prefix_comms`(created,type,authid,name,ends,length,reason,aid,adminIp,admin_name) VALUES (UNIX_TIMESTAMP(),2,?,?,(UNIX_TIMESTAMP() + ?),?,?,?,?,?)" )->execute([$steam, $nickname, $length * 60, $len, $reason, $userbank->GetAid(), $_SERVER['REMOTE_ADDR'] ?? '', $adminName]); + $bids[] = (int) $GLOBALS['PDO']->lastInsertId(); } Log::add(LogType::Message, 'Block Added', "Block against ($steam) has been added. Reason: $reason; Length: $length"); @@ -116,6 +119,7 @@ function api_comms_add(array $params): array return [ 'reload' => true, 'block' => ['steam' => $steam, 'type' => $type, 'length' => $len], + 'bids' => $bids, ]; } diff --git a/web/api/handlers/mods.php b/web/api/handlers/mods.php index 2c909b825..e135fba4f 100644 --- a/web/api/handlers/mods.php +++ b/web/api/handlers/mods.php @@ -25,6 +25,7 @@ function api_mods_add(array $params): array Log::add(LogType::Message, 'Mod Added', "Mod ($name) has been added."); return [ + 'mid' => (int) $GLOBALS['PDO']->lastInsertId(), 'reload' => true, 'message' => [ 'title' => 'Mod Added', diff --git a/web/includes/Rest/CommentsService.php b/web/includes/Rest/CommentsService.php index fbd9bc795..81e732314 100644 --- a/web/includes/Rest/CommentsService.php +++ b/web/includes/Rest/CommentsService.php @@ -90,9 +90,6 @@ public function create(int $parentId, string $ctype, array $body): array 'page' => -1, ]); $id = (int) ($out['cid'] ?? 0); - if ($id <= 0) { - $id = $this->latestCid($parentId, $ctype); - } if ($id <= 0) { throw new ApiError('server_error', 'Comment was not created.', null, 500); } @@ -219,19 +216,6 @@ private function commentsVisible(): bool return Config::getBool('config.enablepubliccomments') || PublicVisibility::isAdmin(); } - private function latestCid(int $parentId, string $ctype): int - { - $pdo = $this->db(); - $pdo->query( - 'SELECT cid FROM `:prefix_comments` WHERE type = :type AND bid = :bid' - . ' ORDER BY cid DESC LIMIT 1' - ); - $pdo->bind(':type', $ctype); - $pdo->bind(':bid', $parentId); - $row = $pdo->single(); - return is_array($row) ? (int) ($row['cid'] ?? 0) : 0; - } - /** * @param array $query * @return array{0: int, 1: int, 2: int} diff --git a/web/includes/Rest/CommsService.php b/web/includes/Rest/CommsService.php index 4d7b6336f..820dda3cd 100644 --- a/web/includes/Rest/CommsService.php +++ b/web/includes/Rest/CommsService.php @@ -10,7 +10,6 @@ use BanRemoval; use Sbpp\Api\Api; use Sbpp\Api\ApiError; -use Sbpp\Auth\UserManager; use Sbpp\Db\Database; use SteamID\SteamID; @@ -108,16 +107,15 @@ public function create(array $body): array 'reason' => (string) ($body['reason'] ?? ''), ]; - $pdo = $this->db(); - $beforeRow = $pdo->query('SELECT MAX(bid) AS m FROM `:prefix_comms`')->single(); - $before = is_array($beforeRow) ? (int) ($beforeRow['m'] ?? 0) : 0; - - Api::invoke('comms.add', $params); - - $created = $this->rowsAfter($before, $steam); - if ($created === []) { + $out = Api::invoke('comms.add', $params); + $bids = $out['bids'] ?? []; + if (!is_array($bids) || $bids === []) { throw new ApiError('server_error', 'Block was not created.', null, 500); } + $created = []; + foreach ($bids as $bid) { + $created[] = $this->get((int) $bid); + } if (count($created) === 1) { return $created[0]; } @@ -324,38 +322,6 @@ private function bodyType(array $body): int return $type; } - /** - * @return list> - */ - private function rowsAfter(int $before, string $rawSteam): array - { - $steam2 = $rawSteam; - if ($rawSteam !== '' && SteamID::isValidID($rawSteam)) { - $converted = SteamID::toSteam2($rawSteam); - if (is_string($converted) && $converted !== '') { - $steam2 = $converted; - } - } - /** @var UserManager $userbank */ - $userbank = $GLOBALS['userbank']; - $aid = $userbank->GetAid(); - $pdo = $this->db(); - $pdo->query( - $this->selectSql() - . ' WHERE C.bid > :before AND C.authid = :authid AND C.aid = :aid' - . ' ORDER BY C.bid ASC' - ); - $pdo->bind(':before', $before); - $pdo->bind(':authid', $steam2); - $pdo->bind(':aid', $aid); - $rows = $pdo->resultset(); - $out = []; - foreach ($rows as $row) { - $out[] = $this->toResource($row); - } - return $out; - } - private function assertPublicFeature(): void { PublicVisibility::assertCommsFeature(); diff --git a/web/includes/Rest/ModsService.php b/web/includes/Rest/ModsService.php index 1bcb31b6e..a0a97554d 100644 --- a/web/includes/Rest/ModsService.php +++ b/web/includes/Rest/ModsService.php @@ -79,22 +79,18 @@ public function create(array $body): array { $folder = (string) ($body['folder'] ?? $body['modfolder'] ?? ''); $name = (string) ($body['name'] ?? ''); - Api::invoke('mods.add', [ + $out = Api::invoke('mods.add', [ 'name' => $name, 'folder' => $folder, 'icon' => (string) ($body['icon'] ?? ''), 'steam_universe' => (int) ($body['steam_universe'] ?? 0), 'enabled' => $body['enabled'] ?? true, ]); - $pdo = $this->db(); - $pdo->query('SELECT mid FROM `:prefix_mods` WHERE modfolder = :folder OR name = :name ORDER BY mid DESC'); - $pdo->bind(':folder', $folder); - $pdo->bind(':name', $name); - $row = $pdo->single(); - if (!is_array($row)) { + $mid = (int) ($out['mid'] ?? 0); + if ($mid <= 0) { throw new ApiError('server_error', 'Mod was not created.', null, 500); } - return $this->get((int) $row['mid']); + return $this->get($mid); } /** diff --git a/web/tests/api/CommsTest.php b/web/tests/api/CommsTest.php index 3bec6fb75..e247fcea9 100644 --- a/web/tests/api/CommsTest.php +++ b/web/tests/api/CommsTest.php @@ -37,7 +37,8 @@ public function testAddCreatesGagRow(): void $this->assertSame(1, (int)$rows[0]['type'], 'gag is type 1'); $this->assertSame(30 * 60, (int)$rows[0]['length']); $this->assertSame(Fixture::adminAid(), (int)$rows[0]['aid']); - $this->assertSnapshot('comms/add_gag_success', $env); + $this->assertSame([(int) $rows[0]['bid']], $env['data']['bids']); + $this->assertSnapshot('comms/add_gag_success', $env, ['data.bids']); } public function testAddBothBlockTypeCreatesTwoRows(): void @@ -57,6 +58,12 @@ public function testAddBothBlockTypeCreatesTwoRows(): void $types = array_map(fn($r) => (int)$r['type'], $rows); sort($types); $this->assertSame([1, 2], $types, 'type=3 must insert both gag (1) and mute (2)'); + $this->assertCount(2, $env['data']['bids']); + $expectedBids = array_map(static fn(array $r): int => (int) $r['bid'], $rows); + sort($expectedBids); + $actualBids = $env['data']['bids']; + sort($actualBids); + $this->assertSame($expectedBids, $actualBids); } public function testAddRefusesDuplicateActiveBlock(): void diff --git a/web/tests/api/ModsTest.php b/web/tests/api/ModsTest.php index 161f5832e..60fc2ec5f 100644 --- a/web/tests/api/ModsTest.php +++ b/web/tests/api/ModsTest.php @@ -19,11 +19,14 @@ public function testAddCreatesRow(): void ]); $this->assertTrue($env['ok']); $this->assertSame('Mod Added', $env['data']['message']['title']); - $this->assertSnapshot('mods/add_success', $env); + $this->assertIsInt($env['data']['mid']); + $this->assertGreaterThan(0, $env['data']['mid']); + $this->assertSnapshot('mods/add_success', $env, ['data.mid']); $row = $this->row('mods', ['modfolder' => 'tmod']); $this->assertNotNull($row); $this->assertSame('Test Mod', $row['name']); + $this->assertSame((int) $row['mid'], $env['data']['mid']); } public function testAddRejectsAnonymous(): void diff --git a/web/tests/api/RestCommsTest.php b/web/tests/api/RestCommsTest.php index 80f7ada53..baa48e38e 100644 --- a/web/tests/api/RestCommsTest.php +++ b/web/tests/api/RestCommsTest.php @@ -92,6 +92,7 @@ public function testSilenceCreatesTwoRows(): void $this->assertNotNull($data['gag']); $this->assertSame('mute', $data['mute']['kind']); $this->assertSame('gag', $data['gag']['kind']); + $this->assertNotSame($data['mute']['id'], $data['gag']['id']); } public function testDuplicateCreateIs409(): void diff --git a/web/tests/api/__snapshots__/comms/add_gag_success.json b/web/tests/api/__snapshots__/comms/add_gag_success.json index 7706fdd65..16743ee59 100644 --- a/web/tests/api/__snapshots__/comms/add_gag_success.json +++ b/web/tests/api/__snapshots__/comms/add_gag_success.json @@ -6,6 +6,7 @@ "steam": "STEAM_0:0:42", "type": 1, "length": 1800 - } + }, + "bids": "<*>" } } diff --git a/web/tests/api/__snapshots__/mods/add_success.json b/web/tests/api/__snapshots__/mods/add_success.json index dee084f75..8bacd001f 100644 --- a/web/tests/api/__snapshots__/mods/add_success.json +++ b/web/tests/api/__snapshots__/mods/add_success.json @@ -1,6 +1,7 @@ { "ok": true, "data": { + "mid": "<*>", "reload": true, "message": { "title": "Mod Added", From 38ce1321708876ece2b6606c012a65d9a58d04cd Mon Sep 17 00:00:00 2001 From: Maximiliano Jabase Date: Thu, 3 Sep 2026 09:38:38 -0300 Subject: [PATCH 13/27] docs(rest-api): split PATH_INFO in the nginx snippet Pretty-URL rewrite lands on /api/v1.php/me. Stock PHP-FPM then 404s unless fastcgi_split_path_info is set. PATH_INFO is not always-on. --- .../src/content/docs/configuring/rest-api.mdx | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/docs/src/content/docs/configuring/rest-api.mdx b/docs/src/content/docs/configuring/rest-api.mdx index a50e34a03..ebf6f70c3 100644 --- a/docs/src/content/docs/configuring/rest-api.mdx +++ b/docs/src/content/docs/configuring/rest-api.mdx @@ -45,7 +45,10 @@ Pretty URLs (production Docker image, and the dev image after rebuild): -PATH_INFO fallback (always works, including tarball installs without rewrite): +PATH_INFO form (`/api/v1.php/me`). nginx + PHP-FPM must split PATH_INFO +(see the snippet below). If PATH_INFO is empty, the panel still routes +from `REQUEST_URI` when it contains `/api/v1` or `/api/v1.php` plus the +path. Pretty `/api/v1/…` still needs rewrite. @@ -204,12 +207,20 @@ location /api/v1 { } ``` -Pass `Authorization` through to PHP. Some Apache/PHP builds leave -`$_SERVER['HTTP_AUTHORIZATION']` empty. The panel also reads the header -via `getallheaders()`. For nginx + PHP-FPM: +That rewrite produces `/api/v1.php/me`. A stock +`location ~ \.php$` that sets `SCRIPT_FILENAME` from `$uri` will then +fail with "Primary script unknown". Split PATH_INFO in the PHP-FPM +location: ```nginx +fastcgi_split_path_info ^(.+\.php)(/.*)$; +fastcgi_param PATH_INFO $fastcgi_path_info; fastcgi_param HTTP_AUTHORIZATION $http_authorization; ``` -Tarball installs without rewrite should use `/api/v1.php/…`. +Pass `Authorization` through to PHP. Some Apache/PHP builds leave +`$_SERVER['HTTP_AUTHORIZATION']` empty. The panel also reads the header +via `getallheaders()`. + +Tarball installs without rewrite should call `/api/v1.php/…` and still +need PATH_INFO (or a `REQUEST_URI` that keeps `/api/v1.php/` in it). From af38b222f542918c16bdbb5805aaaf7ba6d95523 Mon Sep 17 00:00:00 2001 From: Maximiliano Jabase Date: Thu, 3 Sep 2026 09:44:37 -0300 Subject: [PATCH 14/27] fix(rest): capture mods.add insert id before the audit log Log::add inserts into sb_log on the same PDO, so lastInsertId after it is the log lid (often 1, the seeded HL2DM row) instead of the new mid. --- web/api/handlers/mods.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/web/api/handlers/mods.php b/web/api/handlers/mods.php index e135fba4f..767b73e8c 100644 --- a/web/api/handlers/mods.php +++ b/web/api/handlers/mods.php @@ -21,11 +21,14 @@ function api_mods_add(array $params): array $GLOBALS['PDO']->query( "INSERT INTO `:prefix_mods`(name,icon,modfolder,steam_universe,enabled) VALUES (?,?,?,?,?)" )->execute([$name, $icon, $folder, $steamUniverse, $enabled]); + // Capture before Log::add — that INSERT would otherwise steal lastInsertId + // (the log lid, often 1, which is the seeded Half-Life 2 Deathmatch row). + $mid = (int) $GLOBALS['PDO']->lastInsertId(); Log::add(LogType::Message, 'Mod Added', "Mod ($name) has been added."); return [ - 'mid' => (int) $GLOBALS['PDO']->lastInsertId(), + 'mid' => $mid, 'reload' => true, 'message' => [ 'title' => 'Mod Added', From f4e773aaf745db1a17cc19742cfd172771f8247c Mon Sep 17 00:00:00 2001 From: Maximiliano Jabase Date: Sat, 5 Sep 2026 14:25:16 -0400 Subject: [PATCH 15/27] fix(rest): add Coerce helper and cheap contract nits Share boolean/minutes coercion, quote router dots, always Vary Origin, map *_failed to 500, and stop guessing charset / steam64 false. --- web/includes/Rest/Coerce.php | 27 +++++++ web/includes/Rest/Envelope.php | 6 +- web/includes/Rest/FrontController.php | 34 ++++++--- web/includes/Rest/NotesService.php | 5 +- web/includes/Rest/Rehasher.php | 7 +- web/includes/Rest/Router.php | 11 ++- web/tests/unit/RestContractsTest.php | 101 ++++++++++++++++++++++++++ web/updater/data/812.php | 4 +- 8 files changed, 176 insertions(+), 19 deletions(-) create mode 100644 web/includes/Rest/Coerce.php create mode 100644 web/tests/unit/RestContractsTest.php diff --git a/web/includes/Rest/Coerce.php b/web/includes/Rest/Coerce.php new file mode 100644 index 000000000..5ca54dee9 --- /dev/null +++ b/web/includes/Rest/Coerce.php @@ -0,0 +1,27 @@ + 409, 'delete_failed', 'archive_failed', - 'restore_failed' => 500, - default => 400, + 'restore_failed', + 'create_failed', + 'server_error' => 500, + default => str_ends_with($code, '_failed') ? 500 : 400, }; } } diff --git a/web/includes/Rest/FrontController.php b/web/includes/Rest/FrontController.php index 986627194..ed5c53815 100644 --- a/web/includes/Rest/FrontController.php +++ b/web/includes/Rest/FrontController.php @@ -142,7 +142,7 @@ public static function requestPath(): string $uri = (string) (parse_url((string) ($_SERVER['REQUEST_URI'] ?? ''), PHP_URL_PATH) ?? ''); if (preg_match('#/api/v1(?:\.php)?(/.*)?$#', $uri, $m) === 1) { - return Router::normalize($m[1] ?? '/'); + return Router::normalize(rawurldecode($m[1] ?? '/')); } return '/'; } @@ -179,19 +179,31 @@ private static function corsHeaders(): array if (!defined('SB_REST_CORS_ORIGINS') || SB_REST_CORS_ORIGINS === '') { return []; } - $origin = (string) ($_SERVER['HTTP_ORIGIN'] ?? ''); - if ($origin === '') { + return self::corsHeadersFor( + (string) ($_SERVER['HTTP_ORIGIN'] ?? ''), + (string) SB_REST_CORS_ORIGINS, + ); + } + + /** + * @return array + */ + public static function corsHeadersFor(string $origin, string $allowedCsv): array + { + if ($allowedCsv === '') { return []; } - $allowed = array_map('trim', explode(',', (string) SB_REST_CORS_ORIGINS)); + $headers = ['Vary' => 'Origin']; + if ($origin === '') { + return $headers; + } + $allowed = array_map('trim', explode(',', $allowedCsv)); if (!in_array($origin, $allowed, true)) { - return []; + return $headers; } - return [ - 'Access-Control-Allow-Origin' => $origin, - 'Access-Control-Allow-Headers' => 'Authorization, Content-Type', - 'Access-Control-Allow-Methods' => 'GET, PUT, PATCH, POST, DELETE, OPTIONS', - 'Vary' => 'Origin', - ]; + $headers['Access-Control-Allow-Origin'] = $origin; + $headers['Access-Control-Allow-Headers'] = 'Authorization, Content-Type'; + $headers['Access-Control-Allow-Methods'] = 'GET, PUT, PATCH, POST, DELETE, OPTIONS'; + return $headers; } } diff --git a/web/includes/Rest/NotesService.php b/web/includes/Rest/NotesService.php index eddcbabff..ec24f5e21 100644 --- a/web/includes/Rest/NotesService.php +++ b/web/includes/Rest/NotesService.php @@ -97,7 +97,10 @@ private function toResource(array $row): array $steam2 = (string) $row['steam_id']; $steam64 = null; if ($steam2 !== '' && SteamID::isValidID($steam2)) { - $steam64 = SteamID::toSteam64($steam2); + $converted = SteamID::toSteam64($steam2); + if ($converted !== false && $converted !== null && $converted !== '') { + $steam64 = (string) $converted; + } } return [ 'id' => (int) $row['nid'], diff --git a/web/includes/Rest/Rehasher.php b/web/includes/Rest/Rehasher.php index 7981681cd..2770ec4bc 100644 --- a/web/includes/Rest/Rehasher.php +++ b/web/includes/Rest/Rehasher.php @@ -10,6 +10,7 @@ use Sbpp\Api\Api; use Sbpp\Auth\UserManager; use Sbpp\Config; +use Sbpp\Db\Database; use WebPermission; /** @@ -57,7 +58,11 @@ public static function run(array $sids): array */ public static function allEnabledSids(): array { - $rows = $GLOBALS['PDO']->query( + $pdo = $GLOBALS['PDO'] ?? null; + if (!$pdo instanceof Database) { + $pdo = new Database(DB_HOST, (int) DB_PORT, DB_NAME, DB_USER, DB_PASS, DB_PREFIX, DB_CHARSET); + } + $rows = $pdo->query( 'SELECT sid FROM `:prefix_servers` WHERE enabled = 1' )->resultset(); $sids = []; diff --git a/web/includes/Rest/Router.php b/web/includes/Rest/Router.php index 2b2e67024..5c7138f31 100644 --- a/web/includes/Rest/Router.php +++ b/web/includes/Rest/Router.php @@ -79,9 +79,14 @@ public static function normalize(string $path): string private static function matchPath(string $pattern, string $path): ?array { $pattern = self::normalize($pattern); - $regex = preg_replace_callback('/\{([a-zA-Z_][a-zA-Z0-9_]*)\}/', static function (array $m): string { - return '(?P<' . $m[1] . '>[^/]+)'; - }, $pattern); + $quoted = preg_quote($pattern, '#'); + $regex = preg_replace_callback( + '/\\\\\{([a-zA-Z_][a-zA-Z0-9_]*)\\\\\}/', + static function (array $m): string { + return '(?P<' . $m[1] . '>[^/]+)'; + }, + $quoted, + ); if ($regex === null) { return null; } diff --git a/web/tests/unit/RestContractsTest.php b/web/tests/unit/RestContractsTest.php new file mode 100644 index 000000000..9dc56c142 --- /dev/null +++ b/web/tests/unit/RestContractsTest.php @@ -0,0 +1,101 @@ + 'GET', + 'path' => '/openapi.yaml', + 'auth' => false, + 'perm' => 0, + 'handler' => static fn (): mixed => null, + ], + ]); + $hit = $router->match('GET', '/openapi.yaml'); + $this->assertArrayHasKey('route', $hit); + $miss = $router->match('GET', '/openapiXyaml'); + $this->assertSame(404, $miss['error'] ?? null); + } + + public function testCoerceBool(): void + { + $this->assertTrue(Coerce::bool(true)); + $this->assertTrue(Coerce::bool(1)); + $this->assertTrue(Coerce::bool('1')); + $this->assertTrue(Coerce::bool('true')); + $this->assertFalse(Coerce::bool(false)); + $this->assertFalse(Coerce::bool(0)); + $this->assertFalse(Coerce::bool('0')); + $this->assertFalse(Coerce::bool('false')); + $this->assertFalse(Coerce::bool('maybe')); + } + + public function testCoerceMinutesFromSeconds(): void + { + $this->assertSame(0, Coerce::minutesFromSeconds(0)); + $this->assertSame(0, Coerce::minutesFromSeconds(-1)); + $this->assertSame(1, Coerce::minutesFromSeconds(60)); + $this->assertSame(1, Coerce::minutesFromSeconds(119)); + $this->assertSame(60, Coerce::minutesFromSeconds(3600)); + } + + public function testCorsHeadersAlwaysVaryWhenAllowlistSet(): void + { + $allowed = FrontController::corsHeadersFor('https://staff.example.com', 'https://staff.example.com'); + $this->assertSame('Origin', $allowed['Vary'] ?? null); + $this->assertSame('https://staff.example.com', $allowed['Access-Control-Allow-Origin'] ?? null); + + $missing = FrontController::corsHeadersFor('', 'https://staff.example.com'); + $this->assertSame(['Vary' => 'Origin'], $missing); + + $rejected = FrontController::corsHeadersFor('https://evil.example', 'https://staff.example.com'); + $this->assertSame(['Vary' => 'Origin'], $rejected); + $this->assertArrayNotHasKey('Access-Control-Allow-Origin', $rejected); + + $this->assertSame([], FrontController::corsHeadersFor('https://staff.example.com', '')); + } + + public function testEnvelopeMapsFailedCodesTo500(): void + { + $create = Envelope::fromApiError(new ApiError('create_failed', 'nope')); + $this->assertSame(500, $create->status); + $rehash = Envelope::fromApiError(new ApiError('rehash_failed', 'nope')); + $this->assertSame(500, $rehash->status); + $archive = Envelope::fromApiError(new ApiError('archive_failed', 'nope')); + $this->assertSame(500, $archive->status); + $restore = Envelope::fromApiError(new ApiError('restore_failed', 'nope')); + $this->assertSame(500, $restore->status); + $unknownFailed = Envelope::fromApiError(new ApiError('kick_failed', 'nope')); + $this->assertSame(500, $unknownFailed->status); + $validation = Envelope::fromApiError(new ApiError('validation', 'nope')); + $this->assertSame(400, $validation->status); + } + + public function testRequestPathUrlDecodesPrettyUrls(): void + { + $prev = $_SERVER; + try { + unset($_SERVER['PATH_INFO']); + $_SERVER['REQUEST_URI'] = '/api/v1/admins/76561197960265728%2Fextra'; + $this->assertSame('/admins/76561197960265728/extra', FrontController::requestPath()); + + $_SERVER['PATH_INFO'] = '/me'; + $this->assertSame('/me', FrontController::requestPath()); + } finally { + $_SERVER = $prev; + } + } +} diff --git a/web/updater/data/812.php b/web/updater/data/812.php index 77483f55f..53dc4e6c1 100644 --- a/web/updater/data/812.php +++ b/web/updater/data/812.php @@ -22,9 +22,11 @@ . 'PRIMARY KEY (`id`),' . 'UNIQUE KEY `token_hash` (`token_hash`),' . 'KEY `aid` (`aid`)' - . ') ENGINE=InnoDB DEFAULT CHARSET=utf8mb4' + . ') ENGINE=InnoDB DEFAULT CHARSET=:charset' ); // @phpstan-ignore variable.undefined +$this->dbs->bind(':charset', DB_CHARSET); +// @phpstan-ignore variable.undefined $this->dbs->execute(); return true; From 2a650560b2300a8bf9b8bf1ba4e955387629b279 Mon Sep 17 00:00:00 2001 From: Maximiliano Jabase Date: Sat, 5 Sep 2026 14:25:26 -0400 Subject: [PATCH 16/27] perf(rest): batch admin server_ids and server group_ids List endpoints were one query per row. Load the mappings in one IN() so the list cost stays flat. --- web/includes/Rest/AdminsService.php | 44 +++++++++++++++++++---- web/includes/Rest/ServersService.php | 54 +++++++++++++++++++++------- web/tests/api/RestAdminsTest.php | 35 ++++++++++++++++++ web/tests/api/RestServersTest.php | 36 +++++++++++++++++++ 4 files changed, 150 insertions(+), 19 deletions(-) diff --git a/web/includes/Rest/AdminsService.php b/web/includes/Rest/AdminsService.php index 59520b667..808dfc03a 100644 --- a/web/includes/Rest/AdminsService.php +++ b/web/includes/Rest/AdminsService.php @@ -52,9 +52,16 @@ public function list(array $query): array $pdo->bind(':off', $offset); $rows = $pdo->resultset(); + $aids = []; + foreach ($rows as $row) { + $aids[] = (int) $row['aid']; + } + $serverMap = $this->serverIdsForAids($aids); + $data = []; foreach ($rows as $row) { - $data[] = $this->toResource($row, $this->serverIds((int) $row['aid'])); + $aid = (int) $row['aid']; + $data[] = $this->toResource($row, $serverMap[$aid] ?? []); } return [ @@ -455,17 +462,40 @@ private function toResource(array $row, array $serverIds): array */ private function serverIds(int $aid): array { + return $this->serverIdsForAids([$aid])[$aid] ?? []; + } + + /** + * @param list $aids + * @return array> + */ + private function serverIdsForAids(array $aids): array + { + $map = []; + foreach ($aids as $aid) { + $map[$aid] = []; + } + if ($aids === []) { + return $map; + } + $placeholders = implode(',', array_fill(0, count($aids), '?')); $pdo = $this->db(); $pdo->query( - 'SELECT server_id FROM `:prefix_admins_servers_groups`' - . ' WHERE admin_id = :aid AND server_id > 0' + 'SELECT admin_id, server_id FROM `:prefix_admins_servers_groups`' + . " WHERE admin_id IN ({$placeholders}) AND server_id > 0" ); - $pdo->bind(':aid', $aid); - $ids = []; + $i = 1; + foreach ($aids as $aid) { + $pdo->bind($i++, $aid); + } foreach ($pdo->resultset() as $row) { - $ids[] = (int) $row['server_id']; + $aid = (int) $row['admin_id']; + $sid = (int) $row['server_id']; + if ($sid > 0) { + $map[$aid][] = $sid; + } } - return $ids; + return $map; } /** diff --git a/web/includes/Rest/ServersService.php b/web/includes/Rest/ServersService.php index d657d981a..54b1c9a8e 100644 --- a/web/includes/Rest/ServersService.php +++ b/web/includes/Rest/ServersService.php @@ -40,8 +40,7 @@ public function list(array $query): array if (!$isAdmin) { $where .= ' AND S.enabled = 1'; } elseif (array_key_exists('enabled', $query) && $query['enabled'] !== '' && $query['enabled'] !== null) { - $enabled = $query['enabled']; - $flag = $enabled === true || $enabled === 'true' || $enabled === '1' || $enabled === 1; + $flag = Coerce::bool($query['enabled']); $where .= ' AND S.enabled = :enabled'; $binds[':enabled'] = $flag ? 1 : 0; } @@ -67,9 +66,16 @@ public function list(array $query): array $pdo->bind(':off', $offset); $rows = $pdo->resultset(); + $sids = []; + foreach ($rows as $row) { + $sids[] = (int) $row['sid']; + } + $groupMap = $isAdmin ? $this->groupIdsForSids($sids) : []; + $data = []; foreach ($rows as $row) { - $data[] = $this->toResource($row); + $sid = (int) $row['sid']; + $data[] = $this->toResource($row, $isAdmin ? ($groupMap[$sid] ?? []) : null); } return [ @@ -105,7 +111,7 @@ public function create(array $body): array $rcon2 = array_key_exists('rcon2', $body) ? (string) $body['rcon2'] : $rcon; $mod = (int) ($body['mod'] ?? $body['mod_id'] ?? -2); $enabled = array_key_exists('enabled', $body) - ? ($body['enabled'] === true || $body['enabled'] === 'true' || $body['enabled'] === 1 || $body['enabled'] === '1') + ? Coerce::bool($body['enabled']) : true; $groupIds = $this->intList($body['group_ids'] ?? null); $group = $groupIds === [] ? '0' : implode(',', $groupIds); @@ -147,7 +153,7 @@ public function update(int $sid, array $body): array ? (int) ($body['mod'] ?? $body['mod_id'] ?? 0) : (int) $row['modid']; $enabled = array_key_exists('enabled', $body) - ? ($body['enabled'] === true || $body['enabled'] === 'true' || $body['enabled'] === 1 || $body['enabled'] === '1') + ? Coerce::bool($body['enabled']) : ((int) $row['enabled'] === 1); if ($ip === '') { @@ -229,9 +235,10 @@ public function rcon(int $sid, string $command): array /** * @param array $row + * @param list|null $groupIds * @return array */ - private function toResource(array $row): array + private function toResource(array $row, ?array $groupIds = null): array { $sid = (int) $row['sid']; $ip = (string) $row['ip']; @@ -249,7 +256,7 @@ private function toResource(array $row): array 'query' => $this->liveQuery($ip, $port), ]; if (PublicVisibility::isAdmin()) { - $resource['group_ids'] = $this->groupIds($sid); + $resource['group_ids'] = $groupIds ?? $this->groupIds($sid); } return $resource; } @@ -279,17 +286,40 @@ private function liveQuery(string $ip, int $port): ?array */ private function groupIds(int $sid): array { + return $this->groupIdsForSids([$sid])[$sid] ?? []; + } + + /** + * @param list $sids + * @return array> + */ + private function groupIdsForSids(array $sids): array + { + $map = []; + foreach ($sids as $sid) { + $map[$sid] = []; + } + if ($sids === []) { + return $map; + } + $placeholders = implode(',', array_fill(0, count($sids), '?')); $pdo = $this->db(); - $pdo->query('SELECT group_id FROM `:prefix_servers_groups` WHERE server_id = :sid ORDER BY group_id ASC'); - $pdo->bind(':sid', $sid); - $ids = []; + $pdo->query( + 'SELECT server_id, group_id FROM `:prefix_servers_groups`' + . " WHERE server_id IN ({$placeholders}) ORDER BY group_id ASC" + ); + $i = 1; + foreach ($sids as $sid) { + $pdo->bind($i++, $sid); + } foreach ($pdo->resultset() as $row) { + $sid = (int) $row['server_id']; $gid = (int) ($row['group_id'] ?? 0); if ($gid > 0) { - $ids[] = $gid; + $map[$sid][] = $gid; } } - return $ids; + return $map; } /** diff --git a/web/tests/api/RestAdminsTest.php b/web/tests/api/RestAdminsTest.php index 7056c2d74..f86e321f8 100644 --- a/web/tests/api/RestAdminsTest.php +++ b/web/tests/api/RestAdminsTest.php @@ -3,9 +3,12 @@ namespace Sbpp\Tests\Api; use Sbpp\Tests\Fixture; +use Sbpp\Tests\QueryCountAssertions; final class RestAdminsTest extends RestTestCase { + use QueryCountAssertions; + private const NEW_STEAM64 = '76561198000000000'; public function testPutSteam64CreatesAdmin(): void @@ -266,6 +269,38 @@ public function testOwnerPatCanPatchOwner(): void $this->assertSame(200, $servers->status, json_encode($servers->payload)); } + public function testListQueryCountDoesNotGrowWithMappedAdmins(): void + { + $pdo = Fixture::rawPdo(); + $pdo->prepare(sprintf( + 'INSERT INTO `%s_servers` (ip, port, rcon, modid, enabled) VALUES (?, ?, ?, 1, 1)', + DB_PREFIX + ))->execute(['203.0.113.90', 27015, '']); + $sid = (int) $pdo->lastInsertId(); + + $token = $this->mintToken(); + $this->assertQueryCountDelta( + 0, + function () use ($token): void { + $list = $this->rest('GET', '/admins', token: $token, query: ['per_page' => 100]); + $this->assertSame(200, $list->status, json_encode($list->payload)); + }, + function () use ($token, $sid): void { + for ($i = 0; $i < 8; $i++) { + $aid = $this->insertAdmin('nplus-' . $i, 'STEAM_0:0:980' . $i, ADMIN_ADD_BAN); + Fixture::rawPdo()->prepare(sprintf( + 'INSERT INTO `%s_admins_servers_groups` (admin_id, group_id, srv_group_id, server_id) + VALUES (?, 0, -1, ?)', + DB_PREFIX + ))->execute([$aid, $sid]); + } + $list = $this->rest('GET', '/admins', token: $token, query: ['per_page' => 100]); + $this->assertSame(200, $list->status, json_encode($list->payload)); + }, + 'GET /admins must batch server_ids, not issue one query per admin', + ); + } + private function insertAdmin(string $user, string $steam, int $flags): int { $pdo = Fixture::rawPdo(); diff --git a/web/tests/api/RestServersTest.php b/web/tests/api/RestServersTest.php index 883c6b72c..fe8add422 100644 --- a/web/tests/api/RestServersTest.php +++ b/web/tests/api/RestServersTest.php @@ -4,9 +4,11 @@ use Sbpp\Servers\SourceQueryCache; use Sbpp\Tests\Fixture; +use Sbpp\Tests\QueryCountAssertions; final class RestServersTest extends RestTestCase { + use QueryCountAssertions; protected function setUp(): void { parent::setUp(); @@ -196,6 +198,40 @@ public function testRconRequiresSmFlagAndServerMapping(): void $this->assertStringContainsString("Don't try to cheat", $blocked->payload['data']['error']); } + public function testListQueryCountDoesNotGrowWithGroupedServers(): void + { + $token = $this->mintToken(); + $this->assertQueryCountDelta( + 0, + function () use ($token): void { + $list = $this->rest('GET', '/servers', token: $token, query: ['per_page' => 100]); + $this->assertSame(200, $list->status, json_encode($list->payload)); + }, + function () use ($token): void { + $pdo = Fixture::rawPdo(); + $pdo->prepare(sprintf( + 'INSERT INTO `%s_srvgroups` (flags, immunity, name, groups_immune) VALUES ("", 0, ?, "")', + DB_PREFIX + ))->execute(['RestSrvGroup']); + $gid = (int) $pdo->lastInsertId(); + for ($i = 0; $i < 8; $i++) { + $pdo->prepare(sprintf( + 'INSERT INTO `%s_servers` (ip, port, rcon, modid, enabled) VALUES (?, ?, ?, 1, 1)', + DB_PREFIX + ))->execute(['203.0.113.' . (70 + $i), 27015, '']); + $sid = (int) $pdo->lastInsertId(); + $pdo->prepare(sprintf( + 'INSERT INTO `%s_servers_groups` (server_id, group_id) VALUES (?, ?)', + DB_PREFIX + ))->execute([$sid, $gid]); + } + $list = $this->rest('GET', '/servers', token: $token, query: ['per_page' => 100]); + $this->assertSame(200, $list->status, json_encode($list->payload)); + }, + 'GET /servers must batch group_ids, not issue one query per server', + ); + } + private function seedServer(string $rcon = ''): int { $pdo = Fixture::rawPdo(); From c0e86c45f1f4f648653f21a0ed62d8b5ed773174 Mon Sep 17 00:00:00 2001 From: Maximiliano Jabase Date: Sat, 5 Sep 2026 14:25:52 -0400 Subject: [PATCH 17/27] fix(rest): return GET length in minutes POST already used minutes. Convert stored seconds on GET so clients do not need two unit systems. --- web/includes/Rest/BansService.php | 5 ++--- web/includes/Rest/CommsService.php | 2 +- web/tests/api/RestBansTest.php | 32 +++++++++++++++++++++++++++++- web/tests/api/RestCommsTest.php | 2 +- 4 files changed, 35 insertions(+), 6 deletions(-) diff --git a/web/includes/Rest/BansService.php b/web/includes/Rest/BansService.php index 00f8cfb6f..9cf30a69c 100644 --- a/web/includes/Rest/BansService.php +++ b/web/includes/Rest/BansService.php @@ -280,7 +280,7 @@ private function toResource(array $row): array 'reason' => (string) ($row['reason'] ?? ''), 'created' => $created, 'ends' => $ends, - 'length' => $length, + 'length' => Coerce::minutesFromSeconds($length), 'state' => $state, 'admin_name' => $hideAdmin ? null : (string) ($row['admin_name'] ?? ''), 'server_id' => $sid > 0 ? $sid : null, @@ -306,8 +306,7 @@ private function bodyBanType(array $body): BanType */ private function wantsKick(array $body): bool { - $kick = $body['kick'] ?? false; - return $kick === true || $kick === 1; + return Coerce::bool($body['kick'] ?? false); } private function db(): Database diff --git a/web/includes/Rest/CommsService.php b/web/includes/Rest/CommsService.php index 820dda3cd..088d69bf9 100644 --- a/web/includes/Rest/CommsService.php +++ b/web/includes/Rest/CommsService.php @@ -292,7 +292,7 @@ private function toResource(array $row): array 'reason' => (string) ($row['reason'] ?? ''), 'created' => $created, 'ends' => $ends, - 'length' => $length, + 'length' => Coerce::minutesFromSeconds($length), 'state' => $state, 'admin_name' => $hideAdmin ? null : (string) ($row['admin_name'] ?? ''), 'server_id' => $sid > 0 ? $sid : null, diff --git a/web/tests/api/RestBansTest.php b/web/tests/api/RestBansTest.php index adcdb5fa2..26edc354a 100644 --- a/web/tests/api/RestBansTest.php +++ b/web/tests/api/RestBansTest.php @@ -67,7 +67,7 @@ public function testCreateAndListAndUnban(): void $ban = $created->payload['data']; $this->assertSame('RestBan', $ban['player_name']); $this->assertSame('STEAM_0:1:9101', $ban['steam']); - $this->assertSame(3600, $ban['length']); + $this->assertSame(60, $ban['length']); $this->assertSame('active', $ban['state']); $this->assertArrayNotHasKey('kick', $created->payload['meta'] ?? []); @@ -91,6 +91,20 @@ public function testCreateAndListAndUnban(): void $this->assertSame('served time', $unban->payload['data']['unban_reason']); } + public function testCreateKickTrueStringPutsKickInMeta(): void + { + $token = $this->mintToken(); + $created = $this->rest('POST', '/bans', [ + 'steam' => 'STEAM_0:1:9103', + 'name' => 'KickTrue', + 'reason' => 'kick-true-string', + 'length' => 0, + 'kick' => 'true', + ], $token); + $this->assertSame(201, $created->status, json_encode($created->payload)); + $this->assertArrayHasKey('kick', $created->payload['meta'] ?? []); + } + public function testDuplicateCreateIs409(): void { $token = $this->mintToken(); @@ -130,6 +144,22 @@ public function testNonNumericBidIs400(): void $this->assertRestError($response, 400, 'validation'); } + public function testGetLengthIsMinutesFromStoredSeconds(): void + { + $pdo = Fixture::rawPdo(); + $pdo->prepare(sprintf( + 'INSERT INTO `%s_bans` (created, type, ip, authid, name, ends, length, reason, aid, adminIp, admin_name) + VALUES (UNIX_TIMESTAMP(), 0, ?, ?, ?, UNIX_TIMESTAMP() + 3600, 3600, ?, ?, "127.0.0.1", ?)', + DB_PREFIX + ))->execute(['1.1.1.1', 'STEAM_0:1:9010', 'HourBan', 'test', Fixture::adminAid(), 'admin']); + $bid = (int) $pdo->lastInsertId(); + + $response = $this->rest('GET', '/bans/' . $bid); + $this->assertSame(200, $response->status, json_encode($response->payload)); + $this->assertSame(60, $response->payload['data']['length']); + $this->assertGreaterThan(time(), $response->payload['data']['ends']); + } + private function seedBan(string $steam, string $ip): int { $pdo = Fixture::rawPdo(); diff --git a/web/tests/api/RestCommsTest.php b/web/tests/api/RestCommsTest.php index baa48e38e..95a2c724e 100644 --- a/web/tests/api/RestCommsTest.php +++ b/web/tests/api/RestCommsTest.php @@ -54,7 +54,7 @@ public function testCreateMuteUnblockAndDelete(): void $this->assertSame(201, $created->status, json_encode($created->payload)); $block = $created->payload['data']; $this->assertSame('mute', $block['kind']); - $this->assertSame(1800, $block['length']); + $this->assertSame(30, $block['length']); $this->assertSame('active', $block['state']); $empty = $this->rest('POST', '/comms/' . $block['id'] . '/unblock', [], $token); From fc7fc169cf58ee92d0e4dc1b29859a041bd7b83f Mon Sep 17 00:00:00 2001 From: Maximiliano Jabase Date: Sat, 5 Sep 2026 14:26:01 -0400 Subject: [PATCH 18/27] fix(rest): let only author or Owner edit comments PATCH reused bans.edit_comment. The RPC gate now matches the panel so any admin cannot rewrite someone else's comment. --- web/api/handlers/bans.php | 13 +++++++++ web/tests/api/BansTest.php | 24 ++++++++++++++++ web/tests/api/RestCommentsTest.php | 45 ++++++++++++++++++++++++++++++ 3 files changed, 82 insertions(+) diff --git a/web/api/handlers/bans.php b/web/api/handlers/bans.php index 72d013e0b..eb0137819 100644 --- a/web/api/handlers/bans.php +++ b/web/api/handlers/bans.php @@ -355,6 +355,19 @@ function api_bans_edit_comment(array $params): array throw new ApiError('bad_type', 'Bad comment type.'); } + $row = $GLOBALS['PDO']->query( + "SELECT cid, aid FROM `:prefix_comments` WHERE cid = ?" + )->single([$cid]); + if (!$row) { + throw new ApiError('not_found', 'Comment not found.', null, 404); + } + + $authorAid = (int) $row['aid']; + $canEdit = $authorAid === $userbank->GetAid() || $userbank->HasAccess(WebPermission::Owner); + if (!$canEdit) { + throw new ApiError('forbidden', 'You can only edit your own comments.', null, 403); + } + $GLOBALS['PDO']->query( "UPDATE `:prefix_comments` SET commenttxt = ?, editaid = ?, edittime = UNIX_TIMESTAMP() WHERE cid = ?" )->execute([$ctext, $userbank->GetAid(), $cid]); diff --git a/web/tests/api/BansTest.php b/web/tests/api/BansTest.php index f8714e7be..252ab1938 100644 --- a/web/tests/api/BansTest.php +++ b/web/tests/api/BansTest.php @@ -687,6 +687,30 @@ public function testEditCommentRejectsBadType(): void $this->assertEnvelopeError($env, 'bad_type'); } + public function testEditCommentRejectsNonAuthor(): void + { + $authorAid = $this->createAdminWithFlags(ADMIN_ADD_BAN); + $editorAid = $this->createAdminWithFlags(ADMIN_UNBAN); + $this->loginAs($authorAid); + $bid = $this->seedBan(); + $this->api('bans.add_comment', ['bid' => $bid, 'ctype' => 'B', 'ctext' => 'authors', 'page' => -1]); + $cid = (int) $this->row('comments', ['bid' => $bid])['cid']; + + $this->loginAs($editorAid); + $denied = $this->api('bans.edit_comment', [ + 'cid' => $cid, 'ctype' => 'B', 'ctext' => 'hijack', 'page' => -1, + ]); + $this->assertEnvelopeError($denied, 'forbidden'); + $this->assertSame('authors', $this->row('comments', ['cid' => $cid])['commenttxt']); + + $this->loginAsAdmin(); + $owned = $this->api('bans.edit_comment', [ + 'cid' => $cid, 'ctype' => 'B', 'ctext' => 'owner-edit', 'page' => -1, + ]); + $this->assertTrue($owned['ok']); + $this->assertSame('owner-edit', $this->row('comments', ['cid' => $cid])['commenttxt']); + } + public function testRemoveCommentDeletesRow(): void { $this->loginAsAdmin(); diff --git a/web/tests/api/RestCommentsTest.php b/web/tests/api/RestCommentsTest.php index 9ea01be81..8c6ec33f2 100644 --- a/web/tests/api/RestCommentsTest.php +++ b/web/tests/api/RestCommentsTest.php @@ -183,6 +183,39 @@ public function testDeleteRequiresOwner(): void $this->assertRestError($denied, 403, 'forbidden'); } + public function testPatchIsAuthorOrOwner(): void + { + $bid = $this->seedBan('STEAM_0:1:9507'); + $authorAid = $this->insertAdmin('comment-author', 'STEAM_0:0:9507', ADMIN_ADD_BAN); + $otherAid = $this->insertAdmin('comment-other', 'STEAM_0:0:9508', ADMIN_ADD_BAN); + $authorToken = $this->mintToken($authorAid); + $otherToken = $this->mintToken($otherAid); + $ownerToken = $this->mintToken(); + + $mine = $this->rest('POST', '/bans/' . $bid . '/comments', [ + 'body' => 'authors comment', + ], $authorToken); + $this->assertSame(201, $mine->status, json_encode($mine->payload)); + $id = $mine->payload['data']['id']; + + $denied = $this->rest('PATCH', '/comments/' . $id, [ + 'body' => 'hijack', + ], $otherToken); + $this->assertRestError($denied, 403, 'forbidden'); + + $own = $this->rest('PATCH', '/comments/' . $id, [ + 'body' => 'author edit', + ], $authorToken); + $this->assertSame(200, $own->status, json_encode($own->payload)); + $this->assertSame('author edit', $own->payload['data']['body']); + + $asOwner = $this->rest('PATCH', '/comments/' . $id, [ + 'body' => 'owner edit', + ], $ownerToken); + $this->assertSame(200, $asOwner->status, json_encode($asOwner->payload)); + $this->assertSame('owner edit', $asOwner->payload['data']['body']); + } + public function testMissingCommentIs404(): void { $token = $this->mintToken(); @@ -201,6 +234,18 @@ private function seedBan(string $steam): int return (int) $pdo->lastInsertId(); } + private function insertAdmin(string $user, string $steam, int $flags): int + { + $pdo = Fixture::rawPdo(); + $hash = password_hash('other', PASSWORD_BCRYPT); + $pdo->prepare(sprintf( + 'INSERT INTO `%s_admins` (user, authid, password, gid, email, extraflags, immunity, enabled) + VALUES (?, ?, ?, -1, ?, ?, 0, 1)', + DB_PREFIX + ))->execute([$user, $steam, $hash, $user . '@example.test', $flags]); + return (int) $pdo->lastInsertId(); + } + private function seedComm(string $steam): int { $pdo = Fixture::rawPdo(); From 7280a4b07037aa182dfa2b14a6e607aee46ae3e3 Mon Sep 17 00:00:00 2001 From: Maximiliano Jabase Date: Sat, 5 Sep 2026 14:26:21 -0400 Subject: [PATCH 19/27] feat(rest): archive and restore protests and submissions DELETE stays a hard delete. Separate POST routes reuse the panel archive/restore RPC so queues can move without wiping rows. --- web/includes/Rest/ProtestsService.php | 23 +++++++- web/includes/Rest/Routes.php | 68 ++++++++++++++++++++++ web/includes/Rest/SubmissionsService.php | 25 +++++++- web/tests/api/RestPermissionMatrixTest.php | 4 ++ web/tests/api/RestProtestsTest.php | 26 +++++++++ web/tests/api/RestSubmissionsTest.php | 34 +++++++++++ 6 files changed, 175 insertions(+), 5 deletions(-) diff --git a/web/includes/Rest/ProtestsService.php b/web/includes/Rest/ProtestsService.php index baa78f48e..0d43ac8fc 100644 --- a/web/includes/Rest/ProtestsService.php +++ b/web/includes/Rest/ProtestsService.php @@ -79,6 +79,26 @@ public function delete(int $pid): array return ['id' => $pid]; } + /** + * @return array + */ + public function archive(int $pid): array + { + $this->get($pid); + Api::invoke('protests.remove', ['pid' => $pid, 'archiv' => '1']); + return $this->get($pid); + } + + /** + * @return array + */ + public function restore(int $pid): array + { + $this->get($pid); + Api::invoke('protests.remove', ['pid' => $pid, 'archiv' => '2']); + return $this->get($pid); + } + /** * @param array $row * @return array @@ -106,8 +126,7 @@ private function wantsArchived(array $query): bool if (!array_key_exists('archived', $query)) { return false; } - $v = $query['archived']; - return $v === true || $v === 1 || $v === '1' || $v === 'true'; + return Coerce::bool($query['archived']); } /** diff --git a/web/includes/Rest/Routes.php b/web/includes/Rest/Routes.php index fc44d489f..f58c3f61f 100644 --- a/web/includes/Rest/Routes.php +++ b/web/includes/Rest/Routes.php @@ -323,6 +323,20 @@ public static function all(): array 'perm' => $protests, 'handler' => self::protestsDelete(...), ], + [ + 'method' => 'POST', + 'path' => '/protests/{pid}/archive', + 'auth' => true, + 'perm' => $protests, + 'handler' => self::protestsArchive(...), + ], + [ + 'method' => 'POST', + 'path' => '/protests/{pid}/restore', + 'auth' => true, + 'perm' => $protests, + 'handler' => self::protestsRestore(...), + ], [ 'method' => 'GET', 'path' => '/submissions', @@ -344,6 +358,20 @@ public static function all(): array 'perm' => $submissions, 'handler' => self::submissionsDelete(...), ], + [ + 'method' => 'POST', + 'path' => '/submissions/{sid}/archive', + 'auth' => true, + 'perm' => $submissions, + 'handler' => self::submissionsArchive(...), + ], + [ + 'method' => 'POST', + 'path' => '/submissions/{sid}/restore', + 'auth' => true, + 'perm' => $submissions, + 'handler' => self::submissionsRestore(...), + ], [ 'method' => 'PATCH', 'path' => '/comments/{cid}', @@ -865,6 +893,26 @@ private static function protestsDelete(array $params, array $body, array $query) return Envelope::ok((new ProtestsService())->delete(self::positiveId($params['pid'] ?? '', 'pid'))); } + /** + * @param array $params + * @param array $body + * @param array $query + */ + private static function protestsArchive(array $params, array $body, array $query): Response + { + return Envelope::ok((new ProtestsService())->archive(self::positiveId($params['pid'] ?? '', 'pid'))); + } + + /** + * @param array $params + * @param array $body + * @param array $query + */ + private static function protestsRestore(array $params, array $body, array $query): Response + { + return Envelope::ok((new ProtestsService())->restore(self::positiveId($params['pid'] ?? '', 'pid'))); + } + /** * @param array $params * @param array $body @@ -896,6 +944,26 @@ private static function submissionsDelete(array $params, array $body, array $que return Envelope::ok((new SubmissionsService())->delete(self::positiveId($params['sid'] ?? '', 'sid'))); } + /** + * @param array $params + * @param array $body + * @param array $query + */ + private static function submissionsArchive(array $params, array $body, array $query): Response + { + return Envelope::ok((new SubmissionsService())->archive(self::positiveId($params['sid'] ?? '', 'sid'))); + } + + /** + * @param array $params + * @param array $body + * @param array $query + */ + private static function submissionsRestore(array $params, array $body, array $query): Response + { + return Envelope::ok((new SubmissionsService())->restore(self::positiveId($params['sid'] ?? '', 'sid'))); + } + /** * @param array $params * @param array $body diff --git a/web/includes/Rest/SubmissionsService.php b/web/includes/Rest/SubmissionsService.php index e0daa0603..7de7c2367 100644 --- a/web/includes/Rest/SubmissionsService.php +++ b/web/includes/Rest/SubmissionsService.php @@ -82,6 +82,26 @@ public function delete(int $sid): array return ['id' => $sid]; } + /** + * @return array + */ + public function archive(int $sid): array + { + $this->get($sid); + Api::invoke('submissions.remove', ['sid' => $sid, 'archiv' => '1']); + return $this->get($sid); + } + + /** + * @return array + */ + public function restore(int $sid): array + { + $this->get($sid); + Api::invoke('submissions.remove', ['sid' => $sid, 'archiv' => '2']); + return $this->get($sid); + } + /** * @param array $row * @return array @@ -107,7 +127,7 @@ private function toResource(array $row): array return [ 'id' => (int) $row['subid'], - 'steam' => $steam2 ?? ($rawSteam !== '' ? $rawSteam : null), + 'steam' => $steam2, 'steam64' => $steam64, 'player_name' => (string) ($row['name'] ?? ''), 'email' => (string) ($row['email'] ?? ''), @@ -131,8 +151,7 @@ private function wantsArchived(array $query): bool if (!array_key_exists('archived', $query)) { return false; } - $v = $query['archived']; - return $v === true || $v === 1 || $v === '1' || $v === 'true'; + return Coerce::bool($query['archived']); } /** diff --git a/web/tests/api/RestPermissionMatrixTest.php b/web/tests/api/RestPermissionMatrixTest.php index e02be1eba..aba7c24d4 100644 --- a/web/tests/api/RestPermissionMatrixTest.php +++ b/web/tests/api/RestPermissionMatrixTest.php @@ -75,9 +75,13 @@ public static function expectedRoutes(): array ['method' => 'GET', 'path' => '/protests', 'auth' => true, 'perm' => $protests], ['method' => 'GET', 'path' => '/protests/{pid}', 'auth' => true, 'perm' => $protests], ['method' => 'DELETE', 'path' => '/protests/{pid}', 'auth' => true, 'perm' => $protests], + ['method' => 'POST', 'path' => '/protests/{pid}/archive', 'auth' => true, 'perm' => $protests], + ['method' => 'POST', 'path' => '/protests/{pid}/restore', 'auth' => true, 'perm' => $protests], ['method' => 'GET', 'path' => '/submissions', 'auth' => true, 'perm' => $submissions], ['method' => 'GET', 'path' => '/submissions/{sid}', 'auth' => true, 'perm' => $submissions], ['method' => 'DELETE', 'path' => '/submissions/{sid}', 'auth' => true, 'perm' => $submissions], + ['method' => 'POST', 'path' => '/submissions/{sid}/archive', 'auth' => true, 'perm' => $submissions], + ['method' => 'POST', 'path' => '/submissions/{sid}/restore', 'auth' => true, 'perm' => $submissions], ['method' => 'PATCH', 'path' => '/comments/{cid}', 'auth' => true, 'perm' => $anyAdmin], ['method' => 'DELETE', 'path' => '/comments/{cid}', 'auth' => true, 'perm' => ADMIN_OWNER], ['method' => 'GET', 'path' => '/settings', 'auth' => true, 'perm' => $settings], diff --git a/web/tests/api/RestProtestsTest.php b/web/tests/api/RestProtestsTest.php index d6fdb1e76..cfa77636b 100644 --- a/web/tests/api/RestProtestsTest.php +++ b/web/tests/api/RestProtestsTest.php @@ -43,6 +43,22 @@ public function testListGetDelete(): void $this->assertSame('wrong ban', $got->payload['data']['reason']); $this->assertFalse($got->payload['data']['archived']); $this->assertSame('127.0.0.1', $got->payload['data']['ip']); + $this->assertSame('protest@example.test', $got->payload['data']['email']); + + $archivedLive = $this->rest('POST', '/protests/' . $current . '/archive', [], $token); + $this->assertSame(200, $archivedLive->status, json_encode($archivedLive->payload)); + $this->assertTrue($archivedLive->payload['data']['archived']); + + $afterArchive = $this->rest('GET', '/protests', token: $token); + $this->assertNotContains($current, array_column($afterArchive->payload['data'], 'id')); + $archiveAfter = $this->rest('GET', '/protests', token: $token, query: ['archived' => 'true']); + $this->assertContains($current, array_column($archiveAfter->payload['data'], 'id')); + + $restored = $this->rest('POST', '/protests/' . $current . '/restore', [], $token); + $this->assertSame(200, $restored->status, json_encode($restored->payload)); + $this->assertFalse($restored->payload['data']['archived']); + $afterRestore = $this->rest('GET', '/protests', token: $token); + $this->assertContains($current, array_column($afterRestore->payload['data'], 'id')); $deleted = $this->rest('DELETE', '/protests/' . $current, [], $token); $this->assertSame(200, $deleted->status, json_encode($deleted->payload)); @@ -55,6 +71,16 @@ public function testMissingIs404(): void $token = $this->mintToken(); $response = $this->rest('GET', '/protests/999999', token: $token); $this->assertRestError($response, 404, 'not_found'); + $this->assertRestError( + $this->rest('POST', '/protests/999999/archive', [], $token), + 404, + 'not_found', + ); + $this->assertRestError( + $this->rest('POST', '/protests/999999/restore', [], $token), + 404, + 'not_found', + ); } private function seedProtest(string $archiv = '0'): int diff --git a/web/tests/api/RestSubmissionsTest.php b/web/tests/api/RestSubmissionsTest.php index 8972a9b11..e390e59a0 100644 --- a/web/tests/api/RestSubmissionsTest.php +++ b/web/tests/api/RestSubmissionsTest.php @@ -32,6 +32,19 @@ public function testListGetDelete(): void $this->assertIsString($data['steam64']); $this->assertSame('RestPlayer', $data['player_name']); $this->assertFalse($data['archived']); + $this->assertSame('RestPlayer@example.test', $data['email']); + + $archivedLive = $this->rest('POST', '/submissions/' . $current . '/archive', [], $token); + $this->assertSame(200, $archivedLive->status, json_encode($archivedLive->payload)); + $this->assertTrue($archivedLive->payload['data']['archived']); + $afterArchive = $this->rest('GET', '/submissions', token: $token); + $this->assertNotContains($current, array_column($afterArchive->payload['data'], 'id')); + $archiveAfter = $this->rest('GET', '/submissions', token: $token, query: ['archived' => 'true']); + $this->assertContains($current, array_column($archiveAfter->payload['data'], 'id')); + + $restored = $this->rest('POST', '/submissions/' . $current . '/restore', [], $token); + $this->assertSame(200, $restored->status, json_encode($restored->payload)); + $this->assertFalse($restored->payload['data']['archived']); $deleted = $this->rest('DELETE', '/submissions/' . $current, [], $token); $this->assertSame(200, $deleted->status, json_encode($deleted->payload)); @@ -39,11 +52,32 @@ public function testListGetDelete(): void $this->assertRestError($missing, 404, 'not_found'); } + public function testInvalidSteamIsNullOnGet(): void + { + $id = $this->seedSubmission('JunkSteam', 'not-a-steam-id', '0'); + $token = $this->mintToken(); + $got = $this->rest('GET', '/submissions/' . $id, token: $token); + $this->assertSame(200, $got->status, json_encode($got->payload)); + $this->assertNull($got->payload['data']['steam']); + $this->assertNull($got->payload['data']['steam64']); + $this->assertSame('JunkSteam', $got->payload['data']['player_name']); + } + public function testMissingIs404(): void { $token = $this->mintToken(); $response = $this->rest('GET', '/submissions/999999', token: $token); $this->assertRestError($response, 404, 'not_found'); + $this->assertRestError( + $this->rest('POST', '/submissions/999999/archive', [], $token), + 404, + 'not_found', + ); + $this->assertRestError( + $this->rest('POST', '/submissions/999999/restore', [], $token), + 404, + 'not_found', + ); } private function seedSubmission(string $name, string $steamId, string $archiv): int From 6527c40425c0b0bdc7144484ac90b3cd36b98ff3 Mon Sep 17 00:00:00 2001 From: Maximiliano Jabase Date: Sat, 5 Sep 2026 14:26:30 -0400 Subject: [PATCH 20/27] fix(rest): paginate GET /groups The other list endpoints already take page and per_page. Groups was dumping every web and server group in one payload. --- web/includes/Rest/GroupsService.php | 38 ++++++++++- web/includes/Rest/Routes.php | 3 +- web/tests/api/RestGroupsTest.php | 102 ++++++++++++++++++++++++++++ 3 files changed, 139 insertions(+), 4 deletions(-) create mode 100644 web/tests/api/RestGroupsTest.php diff --git a/web/includes/Rest/GroupsService.php b/web/includes/Rest/GroupsService.php index 28d3b40c1..1e47dc990 100644 --- a/web/includes/Rest/GroupsService.php +++ b/web/includes/Rest/GroupsService.php @@ -7,6 +7,7 @@ namespace Sbpp\Rest; +use Sbpp\Api\ApiError; use Sbpp\Db\Database; /** @@ -16,10 +17,27 @@ final class GroupsService { /** - * @return array{web: list, server: list} + * @param array $query + * @return array{ + * data: array{web: list, server: list}, + * meta: array{page: int, per_page: int, web_total: int, server_total: int} + * } */ - public function list(): array + public function list(array $query): array { + $kind = strtolower(trim((string) ($query['kind'] ?? ''))); + if ($kind !== '' && $kind !== 'web' && $kind !== 'server') { + throw new ApiError('validation', 'kind must be web or server.', 'kind', 400); + } + + $page = max(1, (int) ($query['page'] ?? 1)); + $perPage = (int) ($query['per_page'] ?? 100); + if ($perPage < 1) { + $perPage = 100; + } + $perPage = min(100, $perPage); + $offset = ($page - 1) * $perPage; + $pdo = $this->db(); $webRows = $pdo->query( 'SELECT gid, name, flags FROM `:prefix_groups` WHERE type = 1 ORDER BY name ASC' @@ -46,7 +64,21 @@ public function list(): array ]; } - return ['web' => $web, 'server' => $server]; + $webSlice = $kind === 'server' ? [] : array_slice($web, $offset, $perPage); + $serverSlice = $kind === 'web' ? [] : array_slice($server, $offset, $perPage); + + return [ + 'data' => [ + 'web' => $webSlice, + 'server' => $serverSlice, + ], + 'meta' => [ + 'page' => $page, + 'per_page' => $perPage, + 'web_total' => count($web), + 'server_total' => count($server), + ], + ]; } private function db(): Database diff --git a/web/includes/Rest/Routes.php b/web/includes/Rest/Routes.php index f58c3f61f..0675c4ef5 100644 --- a/web/includes/Rest/Routes.php +++ b/web/includes/Rest/Routes.php @@ -522,7 +522,8 @@ private static function adminsDelete(array $params, array $body, array $query): */ private static function groupsList(array $params, array $body, array $query): Response { - return Envelope::ok((new GroupsService())->list()); + $result = (new GroupsService())->list($query); + return Envelope::ok($result['data'], $result['meta']); } /** diff --git a/web/tests/api/RestGroupsTest.php b/web/tests/api/RestGroupsTest.php new file mode 100644 index 000000000..a5abebe9d --- /dev/null +++ b/web/tests/api/RestGroupsTest.php @@ -0,0 +1,102 @@ +rest('GET', '/groups'); + $this->assertRestError($response, 401, 'unauthorized'); + } + + public function testListReturnsWebAndServerWithTotals(): void + { + $this->seedWebGroup('Rest Web Alpha'); + $this->seedWebGroup('Rest Web Beta'); + $this->seedServerGroup('Rest Srv Alpha'); + $token = $this->mintToken(); + + $list = $this->rest('GET', '/groups', token: $token); + $this->assertSame(200, $list->status, json_encode($list->payload)); + $this->assertArrayHasKey('web', $list->payload['data']); + $this->assertArrayHasKey('server', $list->payload['data']); + $this->assertGreaterThanOrEqual(2, $list->payload['meta']['web_total']); + $this->assertGreaterThanOrEqual(1, $list->payload['meta']['server_total']); + $this->assertSame(1, $list->payload['meta']['page']); + $this->assertSame(100, $list->payload['meta']['per_page']); + $webNames = array_column($list->payload['data']['web'], 'name'); + $this->assertContains('Rest Web Alpha', $webNames); + $this->assertContains('Rest Web Beta', $webNames); + } + + public function testKindWebOmitsServerRows(): void + { + $this->seedWebGroup('Kind Web'); + $this->seedServerGroup('Kind Srv'); + $token = $this->mintToken(); + + $list = $this->rest('GET', '/groups', token: $token, query: ['kind' => 'web']); + $this->assertSame(200, $list->status, json_encode($list->payload)); + $this->assertNotSame([], $list->payload['data']['web']); + $this->assertSame([], $list->payload['data']['server']); + $this->assertGreaterThanOrEqual(1, $list->payload['meta']['server_total']); + } + + public function testPerPageSlicesEachCatalog(): void + { + $this->seedWebGroup('Page Web A'); + $this->seedWebGroup('Page Web B'); + $this->seedWebGroup('Page Web C'); + $token = $this->mintToken(); + + $list = $this->rest('GET', '/groups', token: $token, query: [ + 'kind' => 'web', + 'per_page' => 1, + 'page' => 1, + ]); + $this->assertSame(200, $list->status, json_encode($list->payload)); + $this->assertCount(1, $list->payload['data']['web']); + $this->assertSame([], $list->payload['data']['server']); + $this->assertSame(1, $list->payload['meta']['per_page']); + $this->assertGreaterThanOrEqual(3, $list->payload['meta']['web_total']); + } + + public function testInvalidKindIs400(): void + { + $token = $this->mintToken(); + $response = $this->rest('GET', '/groups', token: $token, query: ['kind' => 'discord']); + $this->assertRestError($response, 400, 'validation'); + $this->assertSame('kind', $response->payload['error']['field'] ?? null); + } + + public function testPerPageCapsAt100(): void + { + $token = $this->mintToken(); + $list = $this->rest('GET', '/groups', token: $token, query: ['per_page' => 500]); + $this->assertSame(200, $list->status, json_encode($list->payload)); + $this->assertSame(100, $list->payload['meta']['per_page']); + } + + private function seedWebGroup(string $name): int + { + $pdo = Fixture::rawPdo(); + $pdo->prepare(sprintf( + 'INSERT INTO `%s_groups` (`type`, `name`, `flags`) VALUES (1, ?, 0)', + DB_PREFIX + ))->execute([$name]); + return (int) $pdo->lastInsertId(); + } + + private function seedServerGroup(string $name): int + { + $pdo = Fixture::rawPdo(); + $pdo->prepare(sprintf( + 'INSERT INTO `%s_srvgroups` (`flags`, `immunity`, `name`, `groups_immune`) VALUES ("", 0, ?, "")', + DB_PREFIX + ))->execute([$name]); + return (int) $pdo->lastInsertId(); + } +} From 8ed25f23c9fcff4352e1ec07987c16a82a8511d7 Mon Sep 17 00:00:00 2001 From: Maximiliano Jabase Date: Sat, 5 Sep 2026 14:26:38 -0400 Subject: [PATCH 21/27] fix(rest): validate settings PATCH values Bool keys only accept 0/1/true/false. Int keys must be a non-negative integer, not a coerced string. --- web/includes/Rest/SettingsService.php | 87 +++++++++++++++++++++++++-- web/tests/api/RestSettingsTest.php | 20 ++++++ 2 files changed, 103 insertions(+), 4 deletions(-) diff --git a/web/includes/Rest/SettingsService.php b/web/includes/Rest/SettingsService.php index 2dbbc423d..00a677166 100644 --- a/web/includes/Rest/SettingsService.php +++ b/web/includes/Rest/SettingsService.php @@ -39,6 +39,14 @@ public function patch(array $body): array } $pdo = $this->db(); + $known = []; + foreach ($pdo->query('SELECT `setting` FROM `:prefix_settings`')->resultset() as $row) { + $name = (string) ($row['setting'] ?? ''); + if ($name !== '') { + $known[$name] = true; + } + } + $changed = []; foreach ($body as $key => $value) { if (!is_string($key) || $key === '') { @@ -47,10 +55,7 @@ public function patch(array $body): array if (in_array($key, EntityExporter::FORBIDDEN_SETTING_KEYS, true)) { throw new ApiError('validation', 'That setting cannot be read or written.', $key, 400); } - $pdo->query('SELECT setting FROM `:prefix_settings` WHERE setting = :setting'); - $pdo->bind(':setting', $key); - $row = $pdo->single(); - if (!is_array($row)) { + if (!isset($known[$key])) { throw new ApiError('validation', 'Unknown setting.', $key, 400); } $stored = $this->stringify($value, $key); @@ -100,6 +105,15 @@ private function allVisible(): array private function stringify(mixed $value, string $key): string { + if (is_array($value) || is_object($value)) { + throw new ApiError('validation', 'Value must be a string, number, or boolean.', $key, 400); + } + if ($this->isBoolKey($key)) { + return $this->stringifyBool($value, $key); + } + if ($this->isIntKey($key)) { + return $this->stringifyInt($value, $key); + } if (is_bool($value)) { return $value ? '1' : '0'; } @@ -107,6 +121,12 @@ private function stringify(mixed $value, string $key): string return (string) $value; } if (is_string($value)) { + if (strlen($value) > 65535) { + throw new ApiError('validation', 'Value is too long.', $key, 400); + } + if (str_contains($value, "\0")) { + throw new ApiError('validation', 'Value must not contain a null byte.', $key, 400); + } return $value; } if ($value === null) { @@ -115,6 +135,65 @@ private function stringify(mixed $value, string $key): string throw new ApiError('validation', 'Value must be a string, number, or boolean.', $key, 400); } + private function stringifyBool(mixed $value, string $key): string + { + if ($value === true || $value === 1 || $value === '1' || $value === 'true') { + return '1'; + } + if ($value === false || $value === 0 || $value === '0' || $value === 'false' || $value === '') { + return '0'; + } + throw new ApiError('validation', 'Must be 0 or 1.', $key, 400); + } + + private function stringifyInt(mixed $value, string $key): string + { + if (is_bool($value) || $value === null || $value === '') { + throw new ApiError('validation', 'Must be an integer.', $key, 400); + } + if (is_int($value)) { + $n = $value; + } elseif (is_float($value)) { + if ($value !== floor($value)) { + throw new ApiError('validation', 'Must be an integer.', $key, 400); + } + $n = (int) $value; + } elseif (is_string($value) && preg_match('/^-?\d+$/', $value) === 1) { + $n = (int) $value; + } else { + throw new ApiError('validation', 'Must be an integer.', $key, 400); + } + if ($n < 0) { + throw new ApiError('validation', 'Must be zero or greater.', $key, 400); + } + return (string) $n; + } + + private function isBoolKey(string $key): bool + { + if (str_starts_with($key, 'config.enable')) { + return true; + } + return in_array($key, [ + 'dash.lognopopup', + 'banlist.hideadminname', + 'banlist.nocountryfetch', + 'banlist.hideplayerips', + 'config.debug', + 'config.exportpublic', + 'protest.emailonlyinvolved', + 'telemetry.enabled', + ], true); + } + + private function isIntKey(string $key): bool + { + return str_starts_with($key, 'auth.maxlife') + || $key === 'banlist.bansperpage' + || $key === 'config.password.minlength' + || $key === 'config.defaultpage'; + } + private function db(): Database { $pdo = $GLOBALS['PDO'] ?? null; diff --git a/web/tests/api/RestSettingsTest.php b/web/tests/api/RestSettingsTest.php index 684d9e7fa..4b5c03f39 100644 --- a/web/tests/api/RestSettingsTest.php +++ b/web/tests/api/RestSettingsTest.php @@ -97,4 +97,24 @@ public function testPatchAcceptsBoolean(): void 'config.enablecomms' => $original, ], $token); } + + public function testPatchRejectsGarbageBoolean(): void + { + $token = $this->mintToken(); + $response = $this->rest('PATCH', '/settings', [ + 'config.enablecomms' => 'maybe', + ], $token); + $this->assertRestError($response, 400, 'validation'); + $this->assertSame('config.enablecomms', $response->payload['error']['field'] ?? null); + } + + public function testPatchRejectsNonInteger(): void + { + $token = $this->mintToken(); + $response = $this->rest('PATCH', '/settings', [ + 'auth.maxlife' => 'abc', + ], $token); + $this->assertRestError($response, 400, 'validation'); + $this->assertSame('auth.maxlife', $response->payload['error']['field'] ?? null); + } } From 3d3c80c39840172cde589850e19db5d9d839ef85 Mon Sep 17 00:00:00 2001 From: Maximiliano Jabase Date: Sat, 5 Sep 2026 14:26:47 -0400 Subject: [PATCH 22/27] fix(account): live-insert PAT rows on Your Account Minting a token left the table empty until reload. Insert the row and keep the empty-state copy in sync with revoke. --- web/tests/e2e/pages/admin/MyAccount.ts | 16 +++++ web/tests/e2e/specs/flows/rest-api.spec.ts | 37 ++++++++++ .../integration/AccountTokensCardTest.php | 26 +++++++ web/themes/default/page_youraccount.tpl | 67 +++++++++++++++++-- 4 files changed, 140 insertions(+), 6 deletions(-) create mode 100644 web/tests/integration/AccountTokensCardTest.php diff --git a/web/tests/e2e/pages/admin/MyAccount.ts b/web/tests/e2e/pages/admin/MyAccount.ts index cbd0a4e8e..f96f1f5f5 100644 --- a/web/tests/e2e/pages/admin/MyAccount.ts +++ b/web/tests/e2e/pages/admin/MyAccount.ts @@ -45,4 +45,20 @@ export class MyAccountPage extends BasePage { get tokenSecret(): Locator { return this.page.locator('[data-testid="account-token-secret"]'); } + + get tokensEmpty(): Locator { + return this.page.locator('[data-testid="account-tokens-empty"]'); + } + + get tokenRows(): Locator { + return this.page.locator('[data-testid^="account-token-row-"]'); + } + + tokenRowByName(name: string): Locator { + return this.page.locator('[data-testid^="account-token-row-"]', { hasText: name }); + } + + tokenRevokeByName(name: string): Locator { + return this.tokenRowByName(name).locator('[data-testid="account-token-revoke"]'); + } } diff --git a/web/tests/e2e/specs/flows/rest-api.spec.ts b/web/tests/e2e/specs/flows/rest-api.spec.ts index 9cfb45cea..40bdfa070 100644 --- a/web/tests/e2e/specs/flows/rest-api.spec.ts +++ b/web/tests/e2e/specs/flows/rest-api.spec.ts @@ -11,8 +11,43 @@ import { test, expect } from '../../fixtures/auth.ts'; import { MyAccountPage } from '../../pages/admin/MyAccount.ts'; test.describe('REST API v1', () => { + test.describe.configure({ mode: 'serial' }); test.skip(({ isMobile }) => isMobile, 'flow spec runs only on desktop chromium'); + test('live-inserts a token row and restores empty state on last revoke', async ({ page }) => { + const account = new MyAccountPage(page); + await account.goto(); + await expect(account.tokensCard).toBeVisible(); + + const before = await account.tokenRows.count(); + const tokenName = `e2e-token-card-${Date.now()}`; + await account.tokenName.fill(tokenName); + await account.tokenPassword.fill('admin'); + await account.tokenCreate.click(); + await expect(account.tokenRowByName(tokenName)).toBeVisible(); + await expect(account.tokensEmpty).toBeHidden(); + await expect(account.tokenRows).toHaveCount(before + 1); + + const revokeResponse = page.waitForResponse( + (response) => + response.url().includes('api.php') && + response.request().method() === 'POST', + ); + await account.tokenRevokeByName(tokenName).click(); + await page.locator('[data-testid="sbpp-confirm-dialog"]').waitFor({ state: 'visible' }); + await page.locator('[data-testid="sbpp-confirm-submit"]').click(); + const revokeEnvelope = await (await revokeResponse).json(); + expect(revokeEnvelope.ok, JSON.stringify(revokeEnvelope)).toBe(true); + + await expect(account.tokenRowByName(tokenName)).toHaveCount(0); + await expect(account.tokenRows).toHaveCount(before); + if (before === 0) { + await expect(account.tokensEmpty).toBeVisible(); + } else { + await expect(account.tokensEmpty).toBeHidden(); + } + }); + test('mints a token, GET /me, PUT admin by Steam64, deactivate', async ({ page, request }) => { const account = new MyAccountPage(page); await account.goto(); @@ -24,6 +59,8 @@ test.describe('REST API v1', () => { await account.tokenPassword.fill('admin'); await account.tokenCreate.click(); await expect(account.tokenSecret).toHaveText(/^sbpp_pat_[0-9a-f]{64}$/); + await expect(account.tokenRowByName(tokenName)).toBeVisible(); + await expect(account.tokensEmpty).toBeHidden(); const secret = (await account.tokenSecret.textContent()) ?? ''; const me = await request.get('/api/v1.php/me', { diff --git a/web/tests/integration/AccountTokensCardTest.php b/web/tests/integration/AccountTokensCardTest.php new file mode 100644 index 000000000..f560ef55a --- /dev/null +++ b/web/tests/integration/AccountTokensCardTest.php @@ -0,0 +1,26 @@ +assertStringContainsString('data-testid="account-tokens-table"', $src); + $this->assertStringContainsString('data-testid="account-tokens-body"', $src); + $this->assertStringContainsString('data-testid="account-tokens-empty"', $src); + $this->assertStringContainsString('insertTokenRow', $src); + $this->assertStringContainsString('syncTokenEmptyState', $src); + $this->assertStringContainsString('{if !$api_tokens} hidden{/if}', $src); + $this->assertStringContainsString('{if $api_tokens} hidden{/if}', $src); + } +} diff --git a/web/themes/default/page_youraccount.tpl b/web/themes/default/page_youraccount.tpl index 9a19ff5e0..0f214dc87 100644 --- a/web/themes/default/page_youraccount.tpl +++ b/web/themes/default/page_youraccount.tpl @@ -345,8 +345,7 @@ - {if $api_tokens} -
+
@@ -357,7 +356,7 @@ - + {foreach from=$api_tokens item=token} @@ -380,9 +379,7 @@
{$token.name}
- {else} -

No tokens yet.

- {/if} +

No tokens yet.

@@ -670,6 +667,60 @@ }); } + function escapeHtml(s) { + return String(s).replace(/[&<>"']/g, function (c) { + return ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]; + }); + } + + function pad2(n) { + return n < 10 ? '0' + n : String(n); + } + + function formatTokenDate(unix) { + if (!unix) return 'Never'; + var d = new Date(Number(unix) * 1000); + if (isNaN(d.getTime())) return 'Never'; + return d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate()); + } + + function syncTokenEmptyState() { + var table = document.querySelector('[data-testid="account-tokens-table"]'); + var empty = document.querySelector('[data-testid="account-tokens-empty"]'); + var body = document.querySelector('[data-testid="account-tokens-body"]'); + var hasRows = !!(body && body.querySelector('tr')); + if (table) table.hidden = !hasRows; + if (empty) empty.hidden = hasRows; + } + + function insertTokenRow(data) { + var body = document.querySelector('[data-testid="account-tokens-body"]'); + if (!body || !data || !data.id) return; + var id = String(data.id); + var name = String(data.name || ''); + var prefix = String(data.token_prefix || ''); + var tr = document.createElement('tr'); + tr.setAttribute('data-testid', 'account-token-row-' + id); + tr.innerHTML = + '' + escapeHtml(name) + '' + + '' + escapeHtml(prefix) + '' + + 'Never' + + '' + escapeHtml(formatTokenDate(data.expires_at)) + '' + + '' + + ''; + body.appendChild(tr); + syncTokenEmptyState(); + if (window.lucide && typeof window.lucide.createIcons === 'function') { + window.lucide.createIcons(); + } + } + var tokenForm = document.getElementById('account-token-create-form'); if (tokenForm) { tokenForm.addEventListener('submit', function (ev) { @@ -710,6 +761,9 @@ wrap.hidden = false; } if (copyBtn) copyBtn.setAttribute('data-copy', env.data.token || ''); + insertTokenRow(env.data); + var pwd = document.getElementById('account-token-password'); + if (pwd && 'value' in pwd) pwd.value = ''; if (window.SBPP && typeof window.SBPP.showToast === 'function') { window.SBPP.showToast({ kind: 'success', @@ -745,6 +799,7 @@ if (env && env.ok) { var row = btn.closest('tr'); if (row) row.remove(); + syncTokenEmptyState(); return; } setBusy(btn, false); From 4d084c3c215d545eed43ae966e6abd844162f87a Mon Sep 17 00:00:00 2001 From: Maximiliano Jabase Date: Sat, 5 Sep 2026 14:26:55 -0400 Subject: [PATCH 23/27] docs(rest-api): document review polish and pin OpenAPI parity Keep operator docs, architecture, and the OpenAPI spec in the same change as the routes they describe. The parity test fails if a route drifts. --- AGENTS.md | 14 +- ARCHITECTURE.md | 14 +- .../src/content/docs/configuring/rest-api.mdx | 31 +++-- web/api/openapi-v1.yaml | 131 +++++++++++++++++- web/tests/api/RestOpenApiParityTest.php | 61 ++++++++ 5 files changed, 224 insertions(+), 27 deletions(-) create mode 100644 web/tests/api/RestOpenApiParityTest.php diff --git a/AGENTS.md b/AGENTS.md index f75503635..d88659c5b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -997,17 +997,21 @@ fallback). This is a **separate product** from `POST /api.php`. `: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 seconds. Optional `kick: true` on POST `/bans` fans - RCON (`meta.kick`). Unban/unblock require non-empty `ureason`. + `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. - DELETE is hard-delete (`archiv=0`). GET comments on a ban or comm is +- 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`. DELETE `/comments/{id}` is Owner. GET/PATCH + `/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 diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index c4205d5a2..a7d6ffa15 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -341,9 +341,9 @@ 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` `length` is -minutes; GET `length` is seconds. Optional `kick: true` fans RCON via -`kickit.kick_player` and records `meta.kick`. +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 @@ -353,13 +353,15 @@ 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, DELETE hard-delete -via `protests.remove` / `submissions.remove` with `archiv=0`), nested +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`; DELETE is Owner via +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`). diff --git a/docs/src/content/docs/configuring/rest-api.mdx b/docs/src/content/docs/configuring/rest-api.mdx index ebf6f70c3..b257371af 100644 --- a/docs/src/content/docs/configuring/rest-api.mdx +++ b/docs/src/content/docs/configuring/rest-api.mdx @@ -140,13 +140,13 @@ every anonymous visitor shares one 60/min bucket. Do not parse | POST | `/admins/{id}/deactivate` | Soft retire | | POST | `/admins/{id}/reactivate` | Restore | | DELETE | `/admins/{id}` | Hard delete | -| GET | `/groups` | Web + SourceMod groups | +| GET | `/groups` | Web + SourceMod groups. `page`, `per_page` (cap 100), optional `kind=web` or `kind=server` | | POST | `/system/rehash` | Optional `{ "sids": [1,2] }` | -| GET | `/bans`, `/bans/{bid}` | Public. Hide IP / admin name like the panel | +| GET | `/bans`, `/bans/{bid}` | Public. Hide IP / admin name like the panel. `length` is minutes | | POST | `/bans` | `length` is minutes. Optional `kick: true` | | POST | `/bans/{bid}/unban` | Requires `ureason` | -| GET | `/comms`, `/comms/{cid}` | Public when Comm blocks are enabled. Hide admin name like the panel. Anonymous GET is 404 when `config.enablecomms` is off | -| POST | `/comms` | `kind`: mute, gag, or silence | +| GET | `/comms`, `/comms/{cid}` | Public when Comm blocks are enabled. Hide admin name like the panel. Anonymous GET is 404 when `config.enablecomms` is off. `length` is minutes | +| POST | `/comms` | `kind`: mute, gag, or silence. `length` is minutes | | POST | `/comms/{cid}/unblock` | Requires `ureason` | | DELETE | `/comms/{cid}` | Hard delete | | GET | `/servers`, `/servers/{sid}` | Public for enabled hosts. A2S in `query`. Never returns `rcon`. Anonymous GET omits `group_ids` and ignores `enabled=` | @@ -160,16 +160,20 @@ every anonymous visitor shares one 60/min bucket. Do not parse | GET | `/mods`, `/mods/{mid}` | List / get | | POST | `/mods` | `name` + `folder` | | DELETE | `/mods/{mid}` | Optional `ureason` | -| GET | `/protests`, `/protests/{pid}` | Current queue. `archived=true` for the archive | +| GET | `/protests`, `/protests/{pid}` | Current queue. `archived=true` for the archive. Includes reporter `email` and `ip` | +| POST | `/protests/{pid}/archive` | Move to the archive (`archiv=1`) | +| POST | `/protests/{pid}/restore` | Return to the current queue | | DELETE | `/protests/{pid}` | Hard delete | -| GET | `/submissions`, `/submissions/{sid}` | Current queue. `archived=true` for the archive | +| GET | `/submissions`, `/submissions/{sid}` | Current queue. `archived=true` for the archive. Includes reporter `email` and `ip` | +| POST | `/submissions/{sid}/archive` | Move to the archive (`archiv=1`) | +| POST | `/submissions/{sid}/restore` | Return to the current queue | | DELETE | `/submissions/{sid}` | Hard delete | | GET | `/bans/{bid}/comments`, `/comms/{cid}/comments` | Public. Empty when public comments are off. Anonymous GET `/comms/{cid}/comments` is 404 when Comm blocks are off (`config.enablecomms`); a PAT still reads | | POST | `/bans/{bid}/comments`, `/comms/{cid}/comments` | `body`. Any web admin | -| PATCH | `/comments/{cid}` | `body` | +| PATCH | `/comments/{cid}` | `body`. Author or Owner | | DELETE | `/comments/{cid}` | Owner only | | GET | `/settings` | Flat key/value map. Never `smtp.pass` or `telemetry.instance_id` | -| PATCH | `/settings` | Existing keys only. Same forbidden keys | +| PATCH | `/settings` | Existing keys only. Same forbidden keys. Enable flags are 0/1. Integer settings must be an integer | | GET | `/openapi.yaml` | This spec | GET `/bans`, `/comms`, and `/servers` work without a token. Ban and comm @@ -187,9 +191,14 @@ public (empty when public comments are off). Anonymous GET `/comms`. A PAT still reads. GET `/protests`, `/submissions`, and `/settings` need a PAT. -POST `/bans` `length` is minutes (0 = permanent), matching the panel form. -GET responses use `length` in **seconds** (what is stored). Steam64 in JSON -is always a string. +GET `/protests` and `/submissions` return reporter `email` and `ip` +(protest `pip`, submission `ip` / submitter `sip`). Same fields as the +panel queue pages. Those routes already require the matching queue flags. +`smtp.pass` and `telemetry.instance_id` stay hidden on `/settings`. + +POST `/bans` and `/comms` `length` is minutes (0 = permanent), matching +the panel form. GET `length` is also minutes. `ends` stays unix seconds. +Steam64 in JSON is always a string. POST `/bans` with `"kick": true` runs RCON kicks on enabled servers and puts a summary in `meta.kick`. Without it, SourceMod still blocks the diff --git a/web/api/openapi-v1.yaml b/web/api/openapi-v1.yaml index d0b6c4c5c..83566c5fd 100644 --- a/web/api/openapi-v1.yaml +++ b/web/api/openapi-v1.yaml @@ -238,6 +238,18 @@ paths: get: tags: [groups] summary: List web groups and SourceMod server groups + description: > + Returns `{web, server}` arrays. Default `per_page` is 100. + Pass `kind=web` or `kind=server` to page one catalog. + `meta.web_total` and `meta.server_total` are the unpaged counts. + parameters: + - $ref: "#/components/parameters/page" + - $ref: "#/components/parameters/perPage" + - name: kind + in: query + schema: + type: string + enum: [web, server] responses: "200": description: Group catalog @@ -282,7 +294,8 @@ paths: Public. Anonymous callers follow `banlist.hideplayerips` and `banlist.hideadminname`. A valid PAT of a web admin sees IPs and admin names. A well-formed but invalid PAT is 401. - `length` is seconds. Filter with `state`, `search`, `server`. + `length` is minutes (0 = permanent), same as POST. `ends` is unix seconds. + Filter with `state`, `search`, `server`. parameters: - $ref: "#/components/parameters/page" - $ref: "#/components/parameters/perPage" @@ -931,6 +944,52 @@ paths: $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" + /protests/{pid}/archive: + parameters: + - name: pid + in: path + required: true + schema: + type: integer + post: + tags: [protests] + summary: Archive a protest + responses: + "200": + description: Archived protest + content: + application/json: + schema: + $ref: "#/components/schemas/ProtestEnvelope" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + /protests/{pid}/restore: + parameters: + - name: pid + in: path + required: true + schema: + type: integer + post: + tags: [protests] + summary: Restore a protest from the archive + responses: + "200": + description: Restored protest + content: + application/json: + schema: + $ref: "#/components/schemas/ProtestEnvelope" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" /submissions: get: tags: [submissions] @@ -990,6 +1049,52 @@ paths: $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" + /submissions/{sid}/archive: + parameters: + - name: sid + in: path + required: true + schema: + type: integer + post: + tags: [submissions] + summary: Archive a submission + responses: + "200": + description: Archived submission + content: + application/json: + schema: + $ref: "#/components/schemas/SubmissionEnvelope" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + /submissions/{sid}/restore: + parameters: + - name: sid + in: path + required: true + schema: + type: integer + post: + tags: [submissions] + summary: Restore a submission from the archive + responses: + "200": + description: Restored submission + content: + application/json: + schema: + $ref: "#/components/schemas/SubmissionEnvelope" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" /comments/{cid}: parameters: - name: cid @@ -1001,6 +1106,7 @@ paths: patch: tags: [comments] summary: Edit a comment + description: Author or Owner. requestBody: required: true content: @@ -1018,6 +1124,8 @@ paths: $ref: "#/components/responses/Error" "401": $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" "404": $ref: "#/components/responses/NotFound" delete: @@ -1056,7 +1164,9 @@ paths: summary: Update existing settings description: > Only keys that already exist. Unknown or forbidden keys are 400. - Never writes `smtp.pass` or `telemetry.instance_id`. + Enable flags must be 0/1. Integer settings (auth lifetimes, page + size) must be a non-negative integer. Never writes `smtp.pass` + or `telemetry.instance_id`. requestBody: required: true content: @@ -1273,7 +1383,7 @@ components: type: integer GroupsEnvelope: type: object - required: [data] + required: [data, meta] properties: data: type: object @@ -1302,6 +1412,17 @@ components: type: string immunity: type: integer + meta: + type: object + properties: + page: + type: integer + per_page: + type: integer + web_total: + type: integer + server_total: + type: integer Ban: type: object properties: @@ -1330,7 +1451,7 @@ components: type: integer length: type: integer - description: Seconds stored on the row. + description: Minutes. 0 is permanent. state: type: string enum: [permanent, active, expired, unbanned] @@ -1423,7 +1544,7 @@ components: type: integer length: type: integer - description: Seconds stored on the row. + description: Minutes. 0 is permanent. state: type: string enum: [permanent, active, expired, unmuted] diff --git a/web/tests/api/RestOpenApiParityTest.php b/web/tests/api/RestOpenApiParityTest.php new file mode 100644 index 000000000..2ce20608f --- /dev/null +++ b/web/tests/api/RestOpenApiParityTest.php @@ -0,0 +1,61 @@ +assertNotSame('', $raw, 'openapi-v1.yaml must be readable'); + $yaml = str_replace("\r\n", "\n", $raw); + $fromSpec = $this->operationsFromOpenApi($yaml); + $fromCode = []; + foreach (Routes::all() as $route) { + $fromCode[] = $route['method'] . ' ' . $route['path']; + } + sort($fromSpec); + sort($fromCode); + $this->assertSame( + $fromCode, + $fromSpec, + "OpenAPI paths drifted from Routes::all()\n" + . 'only in spec: ' . implode(', ', array_diff($fromSpec, $fromCode)) . "\n" + . 'only in code: ' . implode(', ', array_diff($fromCode, $fromSpec)), + ); + } + + /** + * @return list + */ + private function operationsFromOpenApi(string $yaml): array + { + $pathsAt = strpos($yaml, "\npaths:"); + $componentsAt = strpos($yaml, "\ncomponents:"); + $this->assertNotFalse($pathsAt, 'openapi-v1.yaml must contain a paths: block'); + $this->assertNotFalse($componentsAt, 'openapi-v1.yaml must contain a components: block'); + $this->assertGreaterThan($pathsAt, $componentsAt, 'components: must follow paths:'); + + $block = substr($yaml, $pathsAt, $componentsAt - $pathsAt); + $path = null; + $ops = []; + foreach (explode("\n", $block) as $line) { + if (preg_match('#^ (/[^:]+):$#', $line, $m) === 1) { + $path = $m[1]; + continue; + } + if ($path !== null && preg_match('#^ (get|put|post|patch|delete):$#i', $line, $m) === 1) { + $ops[] = strtoupper($m[1]) . ' ' . $path; + } + } + return $ops; + } +} From c70be24e16f0f1d6aed6f56a2acd4c2cf839c5af Mon Sep 17 00:00:00 2001 From: Maximiliano Jabase Date: Sun, 6 Sep 2026 17:21:01 -0300 Subject: [PATCH 24/27] fix(rest): unblock PAT table create and settings PATCH Native prepares reject CHARSET=:charset so upgrades never got api_tokens. Validate the whole PATCH body first and write in a transaction so a bad key does not half-apply. --- web/includes/Rest/SettingsService.php | 25 +++++++++++++++++-------- web/tests/api/RestSettingsTest.php | 17 +++++++++++++++++ web/updater/data/812.php | 7 ++++--- 3 files changed, 38 insertions(+), 11 deletions(-) diff --git a/web/includes/Rest/SettingsService.php b/web/includes/Rest/SettingsService.php index 00a677166..b3af0f04e 100644 --- a/web/includes/Rest/SettingsService.php +++ b/web/includes/Rest/SettingsService.php @@ -47,7 +47,7 @@ public function patch(array $body): array } } - $changed = []; + $pending = []; foreach ($body as $key => $value) { if (!is_string($key) || $key === '') { throw new ApiError('validation', 'Setting keys must be strings.', null, 400); @@ -58,19 +58,28 @@ public function patch(array $body): array if (!isset($known[$key])) { throw new ApiError('validation', 'Unknown setting.', $key, 400); } - $stored = $this->stringify($value, $key); - $pdo->query('UPDATE `:prefix_settings` SET `value` = :value WHERE `setting` = :setting'); - $pdo->bind(':value', $stored); - $pdo->bind(':setting', $key); - $pdo->execute(); - $changed[] = $key; + $pending[$key] = $this->stringify($value, $key); + } + + $pdo->beginTransaction(); + try { + foreach ($pending as $key => $stored) { + $pdo->query('UPDATE `:prefix_settings` SET `value` = :value WHERE `setting` = :setting'); + $pdo->bind(':value', $stored); + $pdo->bind(':setting', $key); + $pdo->execute(); + } + $pdo->endTransaction(); + } catch (\Throwable $e) { + $pdo->cancelTransaction(); + throw $e; } Config::init($pdo); Log::add( LogType::Message, 'Settings Updated', - 'REST updated: ' . implode(', ', $changed), + 'REST updated: ' . implode(', ', array_keys($pending)), ); return $this->allVisible(); diff --git a/web/tests/api/RestSettingsTest.php b/web/tests/api/RestSettingsTest.php index 4b5c03f39..4defe14a3 100644 --- a/web/tests/api/RestSettingsTest.php +++ b/web/tests/api/RestSettingsTest.php @@ -74,6 +74,23 @@ public function testPatchUnknownKeyIs400(): void $this->assertSame('not.a.real.setting', $response->payload['error']['field'] ?? null); } + public function testPatchMixedValidAndUnknownKeyDoesNotWrite(): void + { + $token = $this->mintToken(); + $before = $this->rest('GET', '/settings', token: $token); + $original = (string) $before->payload['data']['banlist.bansperpage']; + + $response = $this->rest('PATCH', '/settings', [ + 'banlist.bansperpage' => 50, + 'typo.key' => 1, + ], $token); + $this->assertRestError($response, 400, 'validation'); + $this->assertSame('typo.key', $response->payload['error']['field'] ?? null); + + $after = $this->rest('GET', '/settings', token: $token); + $this->assertSame($original, (string) $after->payload['data']['banlist.bansperpage']); + } + public function testPatchEmptyBodyIs400(): void { $token = $this->mintToken(); diff --git a/web/updater/data/812.php b/web/updater/data/812.php index 53dc4e6c1..33003a70e 100644 --- a/web/updater/data/812.php +++ b/web/updater/data/812.php @@ -3,6 +3,9 @@ // Personal Access Tokens for the external REST API (`/api/v1`). // SHA-256 hashes only. The plaintext secret is shown once at create time. // +// CHARSET is hardcoded utf8mb4 (same as 805.php). Native prepares cannot +// bind a charset name; `700.php`'s `:charset` placeholder predates that switch. +// // `$this` is supplied by Updater::update(), which loads this file inside // the Updater instance scope; PHPStan can't see that, so `$this->dbs` // reads below are suppressed inline. @@ -22,11 +25,9 @@ . 'PRIMARY KEY (`id`),' . 'UNIQUE KEY `token_hash` (`token_hash`),' . 'KEY `aid` (`aid`)' - . ') ENGINE=InnoDB DEFAULT CHARSET=:charset' + . ') ENGINE=InnoDB DEFAULT CHARSET=utf8mb4' ); // @phpstan-ignore variable.undefined -$this->dbs->bind(':charset', DB_CHARSET); -// @phpstan-ignore variable.undefined $this->dbs->execute(); return true; From 3028963a679013f7ec95e0c230d56f81792ef0c7 Mon Sep 17 00:00:00 2001 From: Maximiliano Jabase Date: Sun, 6 Sep 2026 18:44:28 -0300 Subject: [PATCH 25/27] fix(rest): unify POST /comms and DELETE /comms shapes Always return {blocks: [...]} so mute, gag, and silence share one body. DELETE matches the other resources with {id} only. --- .../src/content/docs/configuring/rest-api.mdx | 2 +- web/api/openapi-v1.yaml | 22 +++++++++++++++++-- web/includes/Rest/CommsService.php | 20 ++++------------- web/tests/api/RestCommsTest.php | 21 +++++++++++------- 4 files changed, 38 insertions(+), 27 deletions(-) diff --git a/docs/src/content/docs/configuring/rest-api.mdx b/docs/src/content/docs/configuring/rest-api.mdx index b257371af..d176743a1 100644 --- a/docs/src/content/docs/configuring/rest-api.mdx +++ b/docs/src/content/docs/configuring/rest-api.mdx @@ -146,7 +146,7 @@ every anonymous visitor shares one 60/min bucket. Do not parse | POST | `/bans` | `length` is minutes. Optional `kick: true` | | POST | `/bans/{bid}/unban` | Requires `ureason` | | GET | `/comms`, `/comms/{cid}` | Public when Comm blocks are enabled. Hide admin name like the panel. Anonymous GET is 404 when `config.enablecomms` is off. `length` is minutes | -| POST | `/comms` | `kind`: mute, gag, or silence. `length` is minutes | +| POST | `/comms` | `kind`: mute, gag, or silence. `length` is minutes. Always `{blocks: [...]}` (one item, or two for silence) | | POST | `/comms/{cid}/unblock` | Requires `ureason` | | DELETE | `/comms/{cid}` | Hard delete | | GET | `/servers`, `/servers/{sid}` | Public for enabled hosts. A2S in `query`. Never returns `rcon`. Anonymous GET omits `group_ids` and ignores `enabled=` | diff --git a/web/api/openapi-v1.yaml b/web/api/openapi-v1.yaml index 83566c5fd..7a263a7df 100644 --- a/web/api/openapi-v1.yaml +++ b/web/api/openapi-v1.yaml @@ -471,7 +471,8 @@ paths: summary: Create a mute, gag, or silence description: > `length` is minutes. `kind` is `mute`, `gag`, or `silence` - (or `type` 1 / 2 / 3). Silence returns both rows. + (or `type` 1 / 2 / 3). The 201 body is always `{blocks: [...]}`. + Mute and gag return one item. Silence returns two (one mute, one gag). requestBody: required: true content: @@ -481,6 +482,10 @@ paths: responses: "201": description: Block created + content: + application/json: + schema: + $ref: "#/components/schemas/CommCreateEnvelope" "400": $ref: "#/components/responses/Error" "401": @@ -517,7 +522,7 @@ paths: summary: Hard-delete a mute or gag row responses: "200": - description: Deleted + description: Deleted id "401": $ref: "#/components/responses/Unauthorized" "403": @@ -1581,6 +1586,19 @@ components: type: integer minimum: 0 description: Minutes. 0 is permanent. + CommCreateEnvelope: + type: object + required: [data] + properties: + data: + type: object + required: [blocks] + properties: + blocks: + type: array + items: + $ref: "#/components/schemas/Comm" + description: One row for mute or gag. Two for silence. CommEnvelope: type: object required: [data] diff --git a/web/includes/Rest/CommsService.php b/web/includes/Rest/CommsService.php index 088d69bf9..e86e5881a 100644 --- a/web/includes/Rest/CommsService.php +++ b/web/includes/Rest/CommsService.php @@ -93,7 +93,7 @@ public function get(int $cid): array /** * @param array $body - * @return array + * @return array{blocks: list>} */ public function create(array $body): array { @@ -116,19 +116,7 @@ public function create(array $body): array foreach ($bids as $bid) { $created[] = $this->get((int) $bid); } - if (count($created) === 1) { - return $created[0]; - } - $byKind = []; - foreach ($created as $block) { - $byKind[(string) $block['kind']] = $block; - } - return [ - 'kind' => 'silence', - 'blocks' => $created, - 'mute' => $byKind['mute'] ?? null, - 'gag' => $byKind['gag'] ?? null, - ]; + return ['blocks' => $created]; } /** @@ -144,7 +132,7 @@ public function unblock(int $cid, string $ureason): array } /** - * @return array{id: int, deleted: true} + * @return array{id: int} */ public function delete(int $cid): array { @@ -152,7 +140,7 @@ public function delete(int $cid): array throw new ApiError('validation', 'Block id must be a positive integer.', 'cid', 400); } Api::invoke('comms.delete', ['bid' => $cid]); - return ['id' => $cid, 'deleted' => true]; + return ['id' => $cid]; } /** diff --git a/web/tests/api/RestCommsTest.php b/web/tests/api/RestCommsTest.php index 95a2c724e..2887c2b0e 100644 --- a/web/tests/api/RestCommsTest.php +++ b/web/tests/api/RestCommsTest.php @@ -52,7 +52,10 @@ public function testCreateMuteUnblockAndDelete(): void 'length' => 30, ], $token); $this->assertSame(201, $created->status, json_encode($created->payload)); - $block = $created->payload['data']; + $blocks = $created->payload['data']['blocks']; + $this->assertIsArray($blocks); + $this->assertCount(1, $blocks); + $block = $blocks[0]; $this->assertSame('mute', $block['kind']); $this->assertSame(30, $block['length']); $this->assertSame('active', $block['state']); @@ -69,7 +72,8 @@ public function testCreateMuteUnblockAndDelete(): void $delete = $this->rest('DELETE', '/comms/' . $block['id'], token: $token); $this->assertSame(200, $delete->status, json_encode($delete->payload)); - $this->assertTrue($delete->payload['data']['deleted']); + $this->assertSame($block['id'], $delete->payload['data']['id']); + $this->assertArrayNotHasKey('deleted', $delete->payload['data']); $gone = $this->rest('GET', '/comms/' . $block['id']); $this->assertRestError($gone, 404, 'not_found'); } @@ -86,13 +90,14 @@ public function testSilenceCreatesTwoRows(): void ], $token); $this->assertSame(201, $created->status, json_encode($created->payload)); $data = $created->payload['data']; - $this->assertSame('silence', $data['kind']); + $this->assertArrayNotHasKey('kind', $data); + $this->assertArrayNotHasKey('mute', $data); + $this->assertArrayNotHasKey('gag', $data); $this->assertCount(2, $data['blocks']); - $this->assertNotNull($data['mute']); - $this->assertNotNull($data['gag']); - $this->assertSame('mute', $data['mute']['kind']); - $this->assertSame('gag', $data['gag']['kind']); - $this->assertNotSame($data['mute']['id'], $data['gag']['id']); + $kinds = array_column($data['blocks'], 'kind'); + sort($kinds); + $this->assertSame(['gag', 'mute'], $kinds); + $this->assertNotSame($data['blocks'][0]['id'], $data['blocks'][1]['id']); } public function testDuplicateCreateIs409(): void From 324362c2b9e45568bfc4b31072a2a63c60af536b Mon Sep 17 00:00:00 2001 From: Maximiliano Jabase Date: Sun, 6 Sep 2026 18:44:43 -0300 Subject: [PATCH 26/27] docs(rest-api): omit password on PUT /admins for Steam-only login The generated hash is never returned. Steam login still works; password login needs the field or a panel reset. --- docs/src/content/docs/configuring/rest-api.mdx | 6 +++++- web/api/openapi-v1.yaml | 5 ++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/docs/src/content/docs/configuring/rest-api.mdx b/docs/src/content/docs/configuring/rest-api.mdx index d176743a1..fd5a7a532 100644 --- a/docs/src/content/docs/configuring/rest-api.mdx +++ b/docs/src/content/docs/configuring/rest-api.mdx @@ -99,7 +99,11 @@ roles stay in your bot. SourceBans does not know about Discord. 2. Mint a PAT on that account. 3. Store the PAT in the website-next **backend** environment. 4. `PUT /admins/{steam64}` to create or update. A missing row is created. - An inactive row is reactivated. + An inactive row is reactivated. Omit `password` to skip password + login. Steam login still works. Send `password` if they also need + the username/password form. The omitted-password hash is never + returned. To add password login later, set it from the panel (or + lost-password if they have email). 5. `POST /admins/{steam64}/deactivate` to demote. That is a soft retire, not a hard delete, so ban history still shows the name. 6. Create/update/deactivate already run `sm_rehash` when diff --git a/web/api/openapi-v1.yaml b/web/api/openapi-v1.yaml index 7a263a7df..33783273e 100644 --- a/web/api/openapi-v1.yaml +++ b/web/api/openapi-v1.yaml @@ -1357,7 +1357,10 @@ components: type: integer password: type: string - description: Optional. Generated when omitted on create. + description: > + Optional on create. Omitted stores a random hash that is never + returned. Steam login still works. Password login needs this + field or a panel reset. AdminEnvelope: type: object required: [data] From 6a8f2f55803fec1256b61d6e025ab122bb191bdb Mon Sep 17 00:00:00 2001 From: Maximiliano Jabase Date: Sun, 6 Sep 2026 18:44:43 -0300 Subject: [PATCH 27/27] fix(rest): fetch the new ban once on create get() runs PruneBans. Reuse the row for kick fallback and the response. --- web/includes/Rest/BansService.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/includes/Rest/BansService.php b/web/includes/Rest/BansService.php index 9cf30a69c..4767fe462 100644 --- a/web/includes/Rest/BansService.php +++ b/web/includes/Rest/BansService.php @@ -114,11 +114,11 @@ public function create(array $body): array } $kickMeta = null; + $row = $this->get($bid); if ($this->wantsKick($body)) { $kickit = is_array($out['kickit'] ?? null) ? $out['kickit'] : []; $check = (string) ($kickit['check'] ?? ''); if ($check === '') { - $row = $this->get($bid); $check = $banType === BanType::Steam ? (string) ($row['steam'] ?? '') : (string) ($row['ip'] ?? ''); @@ -132,7 +132,7 @@ public function create(array $body): array } } - return ['ban' => $this->get($bid), 'kick' => $kickMeta]; + return ['ban' => $row, 'kick' => $kickMeta]; } /**