Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -436,6 +436,7 @@ matching its directory. PSR-4 autoloads from `web/includes/` →
| `Sbpp\Auth\Handler\SteamAuthHandler` | Steam OpenID login handler |
| `Sbpp\Security\CSRF` | CSRF token helpers |
| `Sbpp\Security\Crypto` | password / token crypto |
| `Sbpp\Security\PasswordGenerator` | configurable random password generator (owner defaults in `config.password.generator.*`) |
| `Sbpp\Log` | audit log |
| `Sbpp\Config` | settings cache |
| `Sbpp\Api\Api` | JSON API dispatcher |
Expand Down Expand Up @@ -4994,6 +4995,7 @@ the spec, target a 1920px viewport, not 1440px.
| Sanitise a player display name received from an operator-controlled URL query parameter (the `?name=…` smart-default pre-fill arm on `?p=admin&c=bans&section=add-ban` + `?p=admin&c=comms`) | `Sbpp\Util\PlayerName::sanitisePrefill(string $raw): string` (`web/includes/Util/PlayerName.php`, #1440). Single source for the strip set + UTF-8 validation + codepoint cap; both page handlers (`web/pages/admin.bans.php` + `web/pages/admin.comms.php`) call it so the contract stays byte-identical across the two surfaces. Pipeline: `trim` → `preg_replace` against `PlayerName::SANITISE_STRIP_REGEX` (ASCII controls `\x00-\x1F` + `\x7F` + C1 controls `\x80-\x9F` + soft hyphen `U+00AD` + ZWSP `U+200B` + line/paragraph separators `U+2028`/`U+2029` + bidi format/override `U+202A-U+202E` + bidi isolate `U+2066-U+2069` + BOM `U+FEFF`) → `mb_check_encoding(..., 'UTF-8')` (drop entirely on malformed input) → `mb_substr(..., 0, PlayerName::MAX_CODEPOINTS=128, 'UTF-8')` to the `varchar(128)` schema width of `:prefix_bans.name` / `:prefix_comms.name`. The bidi-control strip is the load-bearing defence against right-to-left override (`U+202E`) name-spoofing attacks where a hostile in-game name visually renders as a different string in the form's `<input>` than what's actually stored. The codepoint-based truncation (NOT byte-based) handles 4-byte emoji without slicing mid-character. Use this helper for any future operator-controlled query-parameter that pre-fills a `varchar(128) player.name` form field; do not hand-roll a parallel strip regex (the pre-#1440 reviewer-feedback iteration was a duplicated inline `preg_replace` across both page handlers — centralisation is the contract). Regression guards: `web/tests/integration/AdminBansAddSmartDefaultTest.php` + `web/tests/integration/AdminCommsAddSmartDefaultTest.php` (`hostileNamePrefillProvider` covers every codepoint class in the strip regex + 4-byte emoji + invalid UTF-8 + 128-codepoint cap; `testNameWithoutSteamPrefillsNicknameOnly` + `testValidNameWithInvalidSteamPrefillsNicknameOnly` pin the `?name=` / `?steam=` orthogonality contract); `web/tests/e2e/specs/flows/server-player-context-menu.spec.ts` (`encodes special characters in the name parameter (#1440)` — end-to-end `encodeURIComponent` round-trip from the menu's `data-name` attribute through the form's rendered `value="…"`). |
| Cache an A2S `GetInfo + GetPlayers` round-trip / add another public server-query handler | `web/includes/Servers/SourceQueryCache.php` (`Sbpp\Servers\SourceQueryCache::fetch($ip, $port, $ttl=30)` — per-`(ip, port)` on-disk cache under `SB_CACHE/srvquery/`, atomic tempfile + `rename()` writes mirroring `system.check_version`'s release cache; both success and failure cache so an unreachable server costs ONE A2S probe per ~30s window). The sibling `Sbpp\Servers\RconStatusCache` (`SB_CACHE/srvstatus/`) follows the same shape for RCON `status` round-trips — used by `api_servers_host_players` to surface per-player SteamIDs to admins (see the context-menu row above). Every public handler under `web/api/handlers/servers.php` (`api_servers_host_players` / `host_property` / `host_players_list` / `players`) goes through this — never call `new SourceQuery()` directly from a handler. The cache stamps user-agnostic data only; the handler stamps per-caller fields (`is_owner`, `can_ban`, the per-call `trunchostname`) on top. Per-tile JS debounce on the public servers page lives in `web/themes/default/page_servers.tpl` (`loadTile()` flips `tile.__sbppLoading` + the Re-query button's `disabled` attr while a probe is in flight, releases both in the success / error tails). The matching JS gate on the toggle button has been the precedent since v2.0.0; #1311 brought the refresh button onto the same shape. Tests: `web/tests/integration/SourceQueryCacheTest.php` (cache shape + coalescing + TTL + invalidation, drives `setProbeOverrideForTesting()` so the assertion is deterministic without UDP) + `testHostPlayersCoalescesRapidRepeatCallsViaCache` / `testHostPlayersNegativeCachesUnreachableServers` in `web/tests/api/ServersTest.php` (handler-shape coverage). E2E: `web/tests/e2e/specs/flows/server-refresh-debounce.spec.ts`. |
| Render admin-authored Markdown to safe HTML | `web/includes/Markup/IntroRenderer.php` (`Sbpp\Markup`) |
| Add a "Generate password" button to a password field (or change how passwords are generated) | Put `data-password-generator` + `data-password-targets="<id>[,<confirm-id>]"` on a `<button type="button" class="btn btn--ghost btn--icon">`; the shared dialog in `web/scripts/password-generator.js` (loaded from `core/footer.tpl`) does the rest: calls `Actions.AdminsGeneratePassword`, lets the admin tweak length + character sets, fills every non-disabled target on "Use password" and fires bubbling `input` / `change` events. Never write a page-local generator handler. Generation is server-side in `Sbpp\Security\PasswordGenerator` (`web/includes/Security/PasswordGenerator.php`, `random_int` + one-char-per-enabled-set guarantee + Fisher-Yates shuffle); the JSON action merges per-request options over the owner defaults (`config.password.generator.{length,lowercase,uppercase,digits,symbols,exclude_ambiguous}`, edited in Settings > Main, seeded by `data.sql` + `web/updater/data/813.php`) and clamps length to `[max(8, config.password.minlength), 128]`. The REST `POST /admins` fallback password uses the same class. `SYMBOLS` deliberately excludes quotes / backslash / `;` / space / backtick because values land in `server.cfg` and SourceMod configs. `admins.generate_password` stays `requireAdmin`, so surfaces reachable by non-web-admins gate the button (Your account uses `YourAccountView::$can_generate_password` = `is_admin()`). Regression guards: `web/tests/unit/PasswordGeneratorTest.php` (contract + data.sql / 813.php key and default parity), `AdminsTest::testGeneratePassword*`, `web/tests/e2e/specs/flows/password-generator.spec.ts`. |
| Write or edit a user-facing string (panel UI text, toast body, docs page) | See "User-facing text style (panel UI + docs)" under Conventions. Three rules: no emdash (`—`), terse, don't over-explain. Applies to `web/themes/default/**/*.tpl`, `\Sbpp\View\Toast::emit` titles + bodies, `echo` output from page handlers, and every `docs/src/content/docs/**/*.{md,mdx}` page. Does NOT apply to `AGENTS.md` / `ARCHITECTURE.md` / contributor docs / code comments / audit-log entries / test fixtures. Anti-pattern entries paired under "Anti-patterns". |
| Build / extend the anonymous opt-out daily telemetry payload (#1126) | `web/includes/Telemetry/Telemetry.php` (`Sbpp\Telemetry\Telemetry` — `tickIfDue`, `collect`, `send`) + `web/includes/Telemetry/Schema1.php` (`Sbpp\Telemetry\Schema1::payloadFieldNames()`, drives the extractor parity test) + `web/includes/Telemetry/schema-1.lock.json` (vendored from [sbpp/cf-analytics](https://github.com/sbpp/cf-analytics) — manual sync via `make sync-telemetry-schema`). Tick is registered at the tail of `init.php` via `register_shutdown_function`; on FPM, `fastcgi_finish_request()` flushes the response BEFORE the cURL POST so telemetry never delays a panel page. Slot reservation is atomic (`UPDATE :prefix_settings WHERE CAST(value AS UNSIGNED) <= :threshold`) at the START of the attempt, so a flapping endpoint costs one ping/day, not one ping/request. Audit-log only enable/disable transitions, never individual pings. The in-panel disclosure surface is the help-icon copy in `page_admin_settings_features.tpl`; the upgrade-time disclosure lives in `docs/src/content/docs/updating/1-8-to-2-0.mdx` (no first-login modal). |
| Add or edit a project announcement (the admin-only banner on the home dashboard) | Edit `docs/public/announcements.json` (the source of truth — Astro publishes it as a static asset at `https://sbpp.github.io/announcements.json`). Each entry: `id` (≤64 chars, **required**), `title` (**required**), `body_md` (CommonMark, optional — rendered through `Sbpp\Markup\IntroRenderer` so raw HTML is escaped + `javascript:` / `data:` URLs are stripped), `url` (optional `http(s)://` only — non-http schemes rejected at the parser), `published_at` (optional ISO-8601 or unix int — drives the sort order; entries without it sort below dated entries), `expires_at` (optional — the parser drops entries past this timestamp). Sorted newest-first by the panel; convention is to also place the newest entry at the top of the array so reviewers see the most relevant change first. The deploy chain is automatic: a push to `main` that touches `docs/` fires `.github/workflows/docs-deploy-trigger.yml` which lands the file at `https://sbpp.github.io/announcements.json` within minutes. The starter file ships as `[]` (empty array). NEVER write to the cache file (`SB_CACHE/announcements.json`) directly — the panel's shutdown hook owns that path. |
Expand Down
1 change: 1 addition & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ web/
│ ├── Auth/openid.php LightOpenID — third-party, intentionally global ns
│ ├── Security/CSRF.php Sbpp\Security\CSRF — token helpers
│ ├── Security/Crypto.php Sbpp\Security\Crypto — password / token crypto
│ ├── Security/PasswordGenerator.php Sbpp\Security\PasswordGenerator — configurable random passwords
│ ├── View/AdminNavCatalog.php Sbpp\View\AdminNavCatalog — Pattern A section catalogs for the main-sidebar accordion (#1490)
│ ├── View/AdminTabs.php Sbpp\View\AdminTabs — back-link chrome for edit-* admin pages (non-empty tabs are a no-op post-#1490)
│ ├── View/ Sbpp\View\* — typed Smarty view-model DTOs
Expand Down
22 changes: 22 additions & 0 deletions docs/src/content/docs/setup/admins-and-groups.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,28 @@ password login site-wide under **Admin Panel → Settings → Features
later you'll need a database query to get back in (covered in the
[FAQ](/faq/#i-locked-myself-out-by-enabling-steam-only-login)).

## Generating passwords

Password fields on **Add admin**, **Edit admin**, **Your account**,
and the server form (RCON) have a generate button. It opens a dialog
with a random password. Adjust the length and character sets there,
copy the value if you need it elsewhere, then click **Use password**
to fill the field and its confirmation.

Set the starting options under **Admin Panel → Settings → Main →
Password generator defaults**:

- **Length**: 20 by default. Kept between the panel's minimum
password length (never below 8) and 128.
- **Character sets**: lowercase, uppercase, digits, symbols. At least
one must stay on.
- **Skip look-alikes**: leaves out `0 O 1 l I`. On by default, which
helps when typing a server password into the game console.

Symbols never include quotes, backslash, semicolon, space, or
backtick, so generated values are safe in `server.cfg` and SourceMod
config files.

## What admins see

Each admin only sees the parts of the panel their permissions allow.
Expand Down
23 changes: 22 additions & 1 deletion web/api/handlers/admins.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
// Licensed under the Elastic License 2.0.
// See LICENSE.txt for the full license text and THIRD-PARTY-NOTICES.txt for attributions.

use Sbpp\Security\PasswordGenerator;
use SteamID\SteamID;

/**
Expand Down Expand Up @@ -778,7 +779,27 @@ function api_admins_edit_perms(array $params): array
];
}

/**
* Generate a random password. Any option left out falls back to the
* owner-configured defaults (`config.password.generator.*`); `length`
* is clamped to `[min_length, max_length]`. The response echoes the
* effective options so the generator dialog can paint them.
*
* @param array{length?: int|string, lowercase?: bool|int|string, uppercase?: bool|int|string, digits?: bool|int|string, symbols?: bool|int|string, exclude_ambiguous?: bool|int|string} $params
* @return array{password: string, options: array{length: int, lowercase: bool, uppercase: bool, digits: bool, symbols: bool, exclude_ambiguous: bool}, min_length: int, max_length: int}
*/
function api_admins_generate_password(array $params): array
{
return ['password' => Crypto::genPassword()];
try {
$options = PasswordGenerator::resolve($params);
} catch (\InvalidArgumentException $e) {
throw new ApiError('validation', $e->getMessage(), 'charset');
}

return [
'password' => PasswordGenerator::generate($options),
'options' => $options,
'min_length' => PasswordGenerator::minLength(),
'max_length' => PasswordGenerator::MAX_LENGTH,
];
}
4 changes: 2 additions & 2 deletions web/includes/Rest/AdminsService.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
use Sbpp\Auth\UserManager;
use Sbpp\Db\Database;
use Sbpp\Log;
use Sbpp\Security\Crypto;
use Sbpp\Security\PasswordGenerator;
use SteamID\SteamID;
use WebPermission;

Expand Down Expand Up @@ -199,7 +199,7 @@ private function create(string $steam64, array $body): array
$immunity = max(0, (int) ($body['immunity'] ?? 0));
$password = (string) ($body['password'] ?? '');
if ($password === '') {
$password = Crypto::genPassword();
$password = PasswordGenerator::generate();
}
if (strlen($password) < MIN_PASS_LENGTH) {
throw new ApiError(
Expand Down
7 changes: 7 additions & 0 deletions web/includes/Rest/SettingsService.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
use Sbpp\Db\Database;
use Sbpp\Export\EntityExporter;
use Sbpp\Log;
use Sbpp\Security\PasswordGenerator;
use LogType;

/**
Expand Down Expand Up @@ -192,6 +193,11 @@ private function isBoolKey(string $key): bool
'config.exportpublic',
'protest.emailonlyinvolved',
'telemetry.enabled',
PasswordGenerator::SETTING_LOWERCASE,
PasswordGenerator::SETTING_UPPERCASE,
PasswordGenerator::SETTING_DIGITS,
PasswordGenerator::SETTING_SYMBOLS,
PasswordGenerator::SETTING_EXCLUDE_AMBIGUOUS,
], true);
}

Expand All @@ -200,6 +206,7 @@ private function isIntKey(string $key): bool
return str_starts_with($key, 'auth.maxlife')
|| $key === 'banlist.bansperpage'
|| $key === 'config.password.minlength'
|| $key === PasswordGenerator::SETTING_LENGTH
|| $key === 'config.defaultpage';
}

Expand Down
5 changes: 0 additions & 5 deletions web/includes/Security/Crypto.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,6 @@ public static function genSecret(int $length = 47): string
return self::base64RandomBytes($length);
}

public static function genPassword(int $length = 23): string
{
return self::base64RandomBytes($length);
}

public static function recoveryHash(): string
{
return hash('sha256', self::base64RandomBytes(12));
Expand Down
Loading
Loading