diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..14ddfa32b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,59 @@ +# Build context for docker/Dockerfile.prod (repo root). Keeps the +# production image reproducible from environment variables instead of +# accidentally baking in whatever a contributor's local dev stack wrote +# to disk. +# +# web/config.php is the load-bearing exclusion here: docker/php/dev-* +# generates it for the local `./sbpp.sh up` stack (DB_HOST=db, dev +# creds) and it is gitignored but NOT excluded from the docker build +# context by default. Without this file, `docker build -f +# docker/Dockerfile.prod .` copies that dev config.php straight into +# the image (Dockerfile.prod's builder stage does `COPY web/ +# /build/web/` with no filtering), so every container started from the +# image already has a "config.php present" sentinel pointing at the +# dev stack's internal hostname 'db' -- the prod entrypoint's +# render_config step sees the file already exists and never writes the +# real DATABASE_URL / DB_* values, regardless of what the operator +# configures. Symptom: SQLSTATE[HY000] [2002] getaddrinfo for db +# failed, reproducible on a freshly created service with correct env +# vars because the bug ships inside the image, not in any runtime +# volume. +# +# Also exclude editor / backup suffixes (config.php.bak, .old, …). Those +# often carry the same DB + JWT secrets and are NOT denied by a bare +# `` Apache rule. Keep config.php.template — it is +# the documented blank for tarball / non-Docker installs. +web/config.php +web/config.php.* +!web/config.php.template + +# Host-side env files (repo root or under web/). Never meaningful inside +# the runtime image; a stray `web/.env` would otherwise COPY in with +# credentials. +.env +.env.* +**/.env +**/.env.* + +# Regenerated by the builder stage's `composer install`; shipping the +# host's (possibly dev-only, possibly stale) vendor tree would both +# bloat the build context and risk skew from what composer.lock pins. +web/includes/vendor/ + +# Runtime-writable directories the Dockerfile pre-creates empty and +# the compose file bind-mounts volumes over. Never meaningful to ship +# from the host. +web/cache/ +web/templates_c/ +web/demos/ + +# Never ship VCS metadata or host-side tooling artifacts into the +# build context. +.git/ +.github/ +docs/ +game/ +node_modules/ +web/node_modules/ +web/tests/e2e/node_modules/ +*.log diff --git a/docker-compose.yml b/docker-compose.yml index a8f60eb76..30e8ce59f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -47,6 +47,10 @@ services: # `web/../docker/...` resolves naturally there; this mount keeps # the local `./sbpp.sh test` runner symmetric with CI. - ./docker:/var/www/html/docker:ro + # `.dockerignore` lives at the repo root (Docker build context). + # Mount it so DockerIgnoreSecretsTest can read it the same way + # ProdApacheConfigTest reads `docker/apache/sbpp-prod.conf`. + - ./.dockerignore:/var/www/html/.dockerignore:ro # Read-only mounts of the docs tree + the two root-level markdown # files (AGENTS.md / CHANGELOG.md) for the same reason as the # docker/ mount above: file-shape integration tests under diff --git a/docker/README.md b/docker/README.md index 92a7d992b..ee478aa24 100644 --- a/docker/README.md +++ b/docker/README.md @@ -67,11 +67,11 @@ tests under `web/tests/integration/` can read production-only configs (`docker/apache/sbpp-prod.conf`, `docker/Dockerfile.prod`, etc.) the runtime panel never touches; CI's `actions/checkout@v4` pulls the full repo, so this mount keeps the local `./sbpp.sh test` runner symmetric -with the CI gate. The `./docs` tree, `AGENTS.md`, and `CHANGELOG.md` -are mounted read-only at the same level for the same reason — gates -like `DocsUpgradeLinkRegressionTest` (#1474) verify that -panel-side deep-links to `sbpp.github.io` still resolve against the -matching docs source file on disk. +with the CI gate. The `./docs` tree, `AGENTS.md`, `CHANGELOG.md`, and +`.dockerignore` are mounted read-only at the same level for the same +reason — gates like `DocsUpgradeLinkRegressionTest` (#1474) and +`DockerIgnoreSecretsTest` verify panel-side deep-links and build-context +exclusions against the matching source files on disk. ## Common tasks diff --git a/docker/apache/sbpp-prod.conf b/docker/apache/sbpp-prod.conf index e6366d614..70b655be3 100644 --- a/docker/apache/sbpp-prod.conf +++ b/docker/apache/sbpp-prod.conf @@ -63,10 +63,15 @@ # `config.php` is the install-state sentinel. It carries DB credentials, -# the JWT secret key, and the Steam API key. Never serve it. - +# the JWT secret key, and the Steam API key. Deny the live file AND +# common editor / backup suffixes (`config.php.bak`, `.old`, `.save`, +# `.swp`, …) plus `config.php.template` (placeholder values only, but +# still not a browser asset). Basename-only `` — the +# `config.php` prefix does not collide with any published asset under +# `web/scripts/` or `web/themes/default/js/`. + Require all denied - + # `composer.json` / `composer.lock` are checked-in but should never be # fetched (they reveal exact dependency versions for vulnerability diff --git a/docker/php/prod-entrypoint.sh b/docker/php/prod-entrypoint.sh index 2e4afa88b..11280a3ca 100755 --- a/docker/php/prod-entrypoint.sh +++ b/docker/php/prod-entrypoint.sh @@ -361,6 +361,59 @@ validate_identifiers() { die "DB_CHARSET='${DB_CHARSET:-}' must match [A-Za-z0-9_]+." ;; esac + + # SB_SECRET_KEY is NOT validated here. Env may be stale or unset while + # an existing config.php carries the real key (or the reverse). The + # effective key PHP will load is validated after render_config via + # validate_effective_secret_key. Pre-write env checks live inside + # render_config so a bad env value cannot be persisted into a fresh + # config.php. +} + +# --------------------------------------------------------------------------- +# SB_SECRET_KEY gate (shared by render_config + validate_effective_*) +# --------------------------------------------------------------------------- +# +# HMAC-SHA256 needs >= 32 decoded bytes. JWT::signingKeyFromSecret is the +# single PHP-side source of truth (invalid base64 AND short-but-valid +# base64). Pass the candidate via SB_SECRET_KEY_CHECK so shell quoting +# cannot mangle the secret. +assert_sb_secret_key() { + secret="$1" + context="$2" + if [ -z "$secret" ]; then + die "SB_SECRET_KEY is empty (${context}). Generate one with: openssl rand -base64 47." + fi + if ! SB_SECRET_KEY_CHECK="$secret" php -r ' + require "/var/www/html/web/includes/vendor/autoload.php"; + \Sbpp\Auth\JWT::signingKeyFromSecret((string) getenv("SB_SECRET_KEY_CHECK")); + ' >/dev/null 2>&1; then + die "SB_SECRET_KEY must be base64 that decodes to at least 32 bytes (${context}). Generate one with: openssl rand -base64 47. Then set SB_SECRET_KEY / fix config.php and restart." + fi +} + +# After render_config, config.php is the install-state sentinel and the +# panel's request-time source of truth for SB_SECRET_KEY (env is unset +# before apache starts). Validate THAT value — not getenv — so a stale +# invalid env cannot block a healthy persisted install, and a corrected +# env cannot mask a bad key still sitting in config.php. +validate_effective_secret_key() { + log "validating effective SB_SECRET_KEY from ${SBPP_CONFIG_PATH}" + if [ ! -s "${SBPP_CONFIG_PATH}" ]; then + die "${SBPP_CONFIG_PATH} missing after render_config — cannot validate JWT signing key." + fi + if ! php -r ' + require "/var/www/html/web/includes/vendor/autoload.php"; + define("IN_SB", true); + require getenv("SBPP_CONFIG_PATH"); + if (!defined("SB_SECRET_KEY")) { + fwrite(STDERR, "SB_SECRET_KEY constant missing\n"); + exit(1); + } + \Sbpp\Auth\JWT::signingKeyFromSecret((string) SB_SECRET_KEY); + ' >/dev/null 2>&1; then + die "Effective SB_SECRET_KEY in ${SBPP_CONFIG_PATH} is not usable (must be base64 decoding to at least 32 bytes). Fix or delete the file, set SB_SECRET_KEY, and restart. Generate with: openssl rand -base64 47." + fi } # --------------------------------------------------------------------------- @@ -506,6 +559,10 @@ render_config() { SB_SECRET_KEY="$(openssl rand -base64 47 | tr -d '\n')" log "step 4: minted fresh SB_SECRET_KEY (47-byte base64) — persist by re-reading from this file or set SB_SECRET_KEY env var" export SB_SECRET_KEY + else + # Fail before writing so a bad env value cannot become a sticky + # install-state sentinel that every restart re-loads. + assert_sb_secret_key "$SB_SECRET_KEY" "environment before writing config.php" fi # Single-quote string literals; escape `'` and `\` to defend the @@ -920,6 +977,7 @@ main() { configure_apache # step 2 wait_for_db # step 3 render_config # step 4 + validate_effective_secret_key # step 4b — key PHP will actually load first_boot_install # step 5 run_pending_migrations # step 6 strip_install_dirs # step 7 diff --git a/docs/src/content/docs/getting-started/quickstart-docker.mdx b/docs/src/content/docs/getting-started/quickstart-docker.mdx index f87b6b2ca..d50a7a007 100644 --- a/docs/src/content/docs/getting-started/quickstart-docker.mdx +++ b/docs/src/content/docs/getting-started/quickstart-docker.mdx @@ -387,6 +387,7 @@ Common failures and fixes: | `step 4: minted fresh SB_SECRET_KEY` on every restart. | You haven't set `SB_SECRET_KEY` in `.env`. The first boot wrote a randomly-generated key into the writable layer's `config.php`, but a container recreation rebuilds the layer fresh. Set the env var to persist. | | Healthcheck flips to `unhealthy` repeatedly. | Run `curl https:///health.php` to see the failure reason. If it's a DB connect error, the panel and DB containers have lost their network. `docker compose ... restart` usually resolves it. | | Panel renders but Steam OpenID login fails. | `STEAMAPIKEY` is empty or wrong. Set it in `.env`; bring the panel back with `docker compose ... up -d`. | +| Login fatals with `CannotDecodeContent` / "invalid base64" / a plain `SB_SECRET_KEY must be base64 that decodes to at least 32 bytes` page (or the entrypoint dies with the same text). | The key PHP loads must be base64 decoding to at least 32 bytes. On Docker, `config.php` wins once it exists (the entrypoint validates that file, not a stale env var). Generate with `openssl rand -base64 47`, set `SB_SECRET_KEY` (or fix/delete `config.php`), restart. Rotating the key logs every admin out. | | Apache logs `mod_remoteip` warnings about `RemoteIPInternalProxy`. | The `SBPP_TRUSTED_PROXIES` value isn't a valid CIDR. The entrypoint passes the value through verbatim; check for typos. | For panel-level errors (login fails after Steam OAuth, "Driver not diff --git a/web/includes/Auth/JWT.php b/web/includes/Auth/JWT.php index 82cf2d475..8bae44fdd 100644 --- a/web/includes/Auth/JWT.php +++ b/web/includes/Auth/JWT.php @@ -3,17 +3,16 @@ namespace Sbpp\Auth; use DateTimeImmutable; -use Lcobucci\JWT\Builder; use Lcobucci\JWT\Configuration; +use Lcobucci\JWT\Encoding\CannotDecodeContent; use Lcobucci\JWT\Signer\Hmac\Sha256; -use Lcobucci\JWT\Parser; +use Lcobucci\JWT\Signer\InvalidKeyProvided; use Lcobucci\JWT\Signer\Key\InMemory; use Lcobucci\JWT\Token; use Lcobucci\JWT\Validation\Constraint\IdentifiedBy; use Lcobucci\JWT\Validation\Constraint\IssuedBy; use Lcobucci\JWT\Validation\Constraint\PermittedFor; use Lcobucci\JWT\Validation\Constraint\SignedWith; -use Lcobucci\JWT\ValidationData; final class JWT { @@ -54,9 +53,72 @@ public static function validate(Token $token): bool return $config->validator()->validate($token, ...$constrains); } + /** + * Minimum decoded length for {@see SB_SECRET_KEY}. HMAC-SHA256 + * needs a 256-bit key; shorter values decode as base64 but fail + * later inside Lcobucci's signer. + */ + public const MIN_SECRET_BYTES = 32; + + /** + * Decode an operator-supplied SB_SECRET_KEY into a signing key. + * + * Public so tests can pin the invalid-key operator message without + * redefining the SB_SECRET_KEY constant mid-process. Decodes once, + * then rejects keys shorter than {@see MIN_SECRET_BYTES}. + */ + public static function signingKeyFromSecret(string $secret): InMemory + { + try { + $key = InMemory::base64Encoded($secret); + } catch (CannotDecodeContent|InvalidKeyProvided $e) { + self::failInvalidSecretKey($e); + } + + if (strlen($key->contents()) < self::MIN_SECRET_BYTES) { + self::failInvalidSecretKey(new \InvalidArgumentException( + 'decoded SB_SECRET_KEY is shorter than ' . self::MIN_SECRET_BYTES . ' bytes', + )); + } + + return $key; + } + private static function getConfig(): Configuration { - return Configuration::forSymmetricSigner(new Sha256(), InMemory::base64Encoded(SB_SECRET_KEY)); + return Configuration::forSymmetricSigner(new Sha256(), self::signingKeyFromSecret(SB_SECRET_KEY)); + } + + /** + * Operator-facing abort when SB_SECRET_KEY cannot be used to sign. + * + * Covers non-base64 values (UUID / hex / random password) and + * base64 that decodes to fewer than {@see MIN_SECRET_BYTES}. Both + * used to surface as late Lcobucci exceptions on first login; + * surface the fix instead of the library stack. + */ + private static function failInvalidSecretKey(\Throwable $cause): never + { + $message = "SB_SECRET_KEY must be base64 that decodes to at least " + . self::MIN_SECRET_BYTES . " bytes (256 bits for HMAC-SHA256).\n" + . "The panel's session cookies are signed with that value, so login cannot continue.\n" + . "\n" + . "Generate a valid key:\n" + . " openssl rand -base64 47\n" + . "\n" + . "Then set SB_SECRET_KEY to that output (env var or config.php) and restart.\n" + . "Rotating the key logs every admin out."; + + error_log('[Sbpp\Auth\JWT] invalid SB_SECRET_KEY: ' . $cause->getMessage()); + + if (PHP_SAPI === 'cli') { + fwrite(STDERR, $message . "\n"); + exit(1); + } + + http_response_code(500); + header('Content-Type: text/plain; charset=UTF-8'); + die($message); } } diff --git a/web/includes/Auth/UserManager.php b/web/includes/Auth/UserManager.php index da6b176f3..b5a8d0fe5 100644 --- a/web/includes/Auth/UserManager.php +++ b/web/includes/Auth/UserManager.php @@ -59,8 +59,22 @@ public function GetUserArray(?int $aid = null): array|false return false; // ohnoes some type of db error } + $user = $this->mapAdminRow($aid, $res); + $this->admins[$aid] = $user; + return $user; + } + + /** + * Shared row-to-user-array mapping for {@see GetUserArray()} and + * {@see GetAllAdmins()} so the two call sites can't drift on the + * shape of the cached admin array. + * + * @param array $res + * @return array + */ + private function mapAdminRow(int $aid, array $res): array + { $user = []; - //$user['user'] = stripslashes($res[0]); $user['aid'] = $aid; //immediately obvious $user['user'] = $res['user']; $user['authid'] = $res['authid']; @@ -81,7 +95,7 @@ public function GetUserArray(?int $aid = null): array|false $user['srv_flags'] = $res['srv_flags'] . $res['sgflags']; $user['group_name'] = $res['wgname']; $user['lastvisit'] = $res['lastvisit']; - $this->admins[$aid] = $user; + return $user; } @@ -182,13 +196,31 @@ public function GetAid(): int } + /** + * Loads every admin row in one query (the same JOIN `GetUserArray()` + * uses, minus the `WHERE aid = :aid` filter) and warms the in-memory + * admin cache. + */ public function GetAllAdmins(): array { - $this->dbh->query('SELECT aid FROM `:prefix_admins`'); - $res = $this->dbh->resultset(); - foreach ($res as $admin) { - $this->GetUserArray($admin['aid']); + $this->dbh->query( + "SELECT adm.aid aid, adm.user user, adm.authid authid, adm.password password, adm.gid gid, adm.email email, adm.validate validate, adm.extraflags extraflags, + adm.immunity admimmunity,sg.immunity sgimmunity, adm.srv_password srv_password, adm.srv_group srv_group, adm.srv_flags srv_flags,sg.flags sgflags, + wg.flags wgflags, wg.name wgname, adm.lastvisit lastvisit + FROM `:prefix_admins` AS adm + LEFT JOIN `:prefix_groups` AS wg ON adm.gid = wg.gid + LEFT JOIN `:prefix_srvgroups` AS sg ON adm.srv_group = sg.name" + ); + $rows = $this->dbh->resultset(); + + foreach ($rows as $res) { + $aid = (int) $res['aid']; + if ($aid <= 0) { + continue; + } + $this->admins[$aid] = $this->mapAdminRow($aid, $res); } + return $this->admins; } diff --git a/web/includes/Db/Database.php b/web/includes/Db/Database.php index 528165601..a091c754b 100644 --- a/web/includes/Db/Database.php +++ b/web/includes/Db/Database.php @@ -18,6 +18,36 @@ final class Database private ?PDOStatement $stmt = null; + /** + * Running count of `query()` calls, i.e. distinct SQL statements + * prepared since the last {@see resetQueryCount()}. Every call + * site in the codebase funnels through `query()` before it can + * `execute()` / `resultset()` / `single()` / `iterate()`, so this + * is a single choke point for counting logical database round + * trips regardless of which page handler, API handler, or helper + * issued the SQL. + * + * Static (not per-instance) because pages construct `Database` + * once per request via `$GLOBALS['PDO']`, but tests that build a + * fresh instance (e.g. to probe a specific query in isolation) + * still want the count visible from the same place. The counter + * is a single `int` increment per call: negligible on production + * request paths, and it exists so PHPUnit tests can assert a + * page/handler issues a bounded number of queries independent of + * row count, instead of relying on flaky wall-clock timing. + */ + private static int $queryCount = 0; + + public static function resetQueryCount(): void + { + self::$queryCount = 0; + } + + public static function getQueryCount(): int + { + return self::$queryCount; + } + public function __construct(string $host, int $port, string $dbname, string $user, string $password, string $prefix, string $charset = 'utf8') { $this->prefix = $prefix; @@ -42,7 +72,23 @@ public function __construct(string $host, int $port, string $dbname, string $use try { $this->dbh = new PDO($dsn, $user, $password, $options); } catch (PDOException $e) { - die($e->getMessage()); + // `die($e->getMessage())` used to sit here. Two problems with + // that shape: (1) it leaks the raw PDO message — hostname, + // db name, sometimes the DSN — to whatever's reading the + // process output (a page visitor's browser on the web SAPI, + // or a shell script's captured stdout on the CLI SAPI); (2) + // `exit($string)` exits with status 0, not a failure code, so + // any caller checking the exit status (e.g. the production + // entrypoint's headless updater-migration runner) sees + // "succeeded" and continues booting against a DB it never + // actually reached. + error_log('[Sbpp\Db\Database] connection failed: ' . $e->getMessage()); + if (PHP_SAPI === 'cli') { + fwrite(STDERR, "Database connection failed. See error log for detail.\n"); + exit(1); + } + http_response_code(500); + die('Database connection failed. Please contact the site administrator.'); } } @@ -67,6 +113,7 @@ private function setPrefix(string $query): string */ public function query(string $query): self { + self::$queryCount++; $query = $this->setPrefix($query); $this->stmt = $this->dbh->prepare($query); return $this; diff --git a/web/pages/admin.admins.php b/web/pages/admin.admins.php index b3b918d7e..793ea5be0 100644 --- a/web/pages/admin.admins.php +++ b/web/pages/admin.admins.php @@ -463,6 +463,28 @@ $AdminsEnd = $admin_count; } +$userbank->GetAllAdmins(); + +$adminAids = array_map(static fn ($a) => (int) $a['aid'], $admins); +$banCountByAid = []; +$nodemoCountByAid = []; +if ($adminAids !== []) { + $placeholders = implode(',', array_fill(0, count($adminAids), '?')); + $banCountRows = $GLOBALS['PDO']->query( + "SELECT aid, count(authid) AS num FROM `:prefix_bans` WHERE aid IN ($placeholders) GROUP BY aid" + )->resultset($adminAids); + foreach ($banCountRows as $banCountRow) { + $banCountByAid[(int) $banCountRow['aid']] = (int) $banCountRow['num']; + } + + $nodemoCountRows = $GLOBALS['PDO']->query( + "SELECT B.aid AS aid, count(B.bid) AS num FROM `:prefix_bans` AS B WHERE B.aid IN ($placeholders) AND NOT EXISTS (SELECT D.demid FROM `:prefix_demos` AS D WHERE D.demid = B.bid) GROUP BY B.aid" + )->resultset($adminAids); + foreach ($nodemoCountRows as $nodemoCountRow) { + $nodemoCountByAid[(int) $nodemoCountRow['aid']] = (int) $nodemoCountRow['num']; + } +} + // List Page $admin_list = []; foreach ($admins as $admin) { @@ -475,16 +497,9 @@ if (empty($admin['server_group']) || $admin['server_group'] == " ") { $admin['server_group'] = "No Group/Individual Permissions"; } - $GLOBALS['PDO']->query("SELECT count(authid) AS num FROM `:prefix_bans` WHERE aid = :aid"); - $GLOBALS['PDO']->bind(':aid', $admin['aid']); - $num = $GLOBALS['PDO']->single(); - $admin['bancount'] = $num['num']; - - $GLOBALS['PDO']->query("SELECT count(B.bid) AS num FROM `:prefix_bans` AS B WHERE aid = :aid AND NOT EXISTS (SELECT D.demid FROM `:prefix_demos` AS D WHERE D.demid = B.bid)"); - $GLOBALS['PDO']->bind(':aid', $admin['aid']); - $nodem = $GLOBALS['PDO']->single(); + $admin['bancount'] = $banCountByAid[(int) $admin['aid']] ?? 0; $admin['aid'] = $admin['aid']; - $admin['nodemocount'] = $nodem['num']; + $admin['nodemocount'] = $nodemoCountByAid[(int) $admin['aid']] ?? 0; $admin['name'] = stripslashes($admin['user']); $admin['server_flag_string'] = SmFlagsToSb($userbank->GetProperty("srv_flags", $admin['aid'])); diff --git a/web/pages/admin.bans.php b/web/pages/admin.bans.php index 59487f573..6d7510f7e 100644 --- a/web/pages/admin.bans.php +++ b/web/pages/admin.bans.php @@ -640,15 +640,42 @@ function ProcessBan() $delete = []; $protest_list = []; + + $protestBids = array_map(static fn ($p) => (int) $p['bid'], $protests); + $protestBanDetailsByBid = []; + if ($protestBids !== []) { + $placeholders = implode(',', array_fill(0, count($protestBids), '?')); + $banRows = $GLOBALS['PDO']->query( + "SELECT bid, ba.ip, ba.authid, ba.name, created, ends, length, reason, ba.aid, ba.sid AS ba_sid, email, ad.user, CONCAT(se.ip,':',se.port) AS server_addr, se.sid AS se_sid + FROM `:prefix_bans` AS ba + LEFT JOIN `:prefix_admins` AS ad ON ba.aid = ad.aid + LEFT JOIN `:prefix_servers` AS se ON se.sid = ba.sid + WHERE bid IN ($placeholders)" + )->resultset($protestBids); + foreach ($banRows as $banRow) { + $protestBanDetailsByBid[(int) $banRow['bid']] = $banRow; + } + } + + $protestPids = array_map(static fn ($p) => (int) $p['pid'], $protests); + $protestCommentsByPid = []; + if ($protestPids !== []) { + $placeholders = implode(',', array_fill(0, count($protestPids), '?')); + $cRows = $GLOBALS['PDO']->query( + "SELECT bid, cid, aid, commenttxt, added, edittime, + (SELECT user FROM `:prefix_admins` WHERE aid = C.aid) AS comname, + (SELECT user FROM `:prefix_admins` WHERE aid = C.editaid) AS editname + FROM `:prefix_comments` AS C + WHERE type = 'P' AND bid IN ($placeholders) ORDER BY added desc" + )->resultset($protestPids); + foreach ($cRows as $cRow) { + $protestCommentsByPid[(int) $cRow['bid']][] = $cRow; + } + } + foreach ($protests as $prot) { $prot['reason'] = wordwrap(htmlspecialchars($prot['reason']), 55, "
\n", true); - $GLOBALS['PDO']->query("SELECT bid, ba.ip, ba.authid, ba.name, created, ends, length, reason, ba.aid, ba.sid AS ba_sid, email, ad.user, CONCAT(se.ip,':',se.port) AS server_addr, se.sid AS se_sid - FROM `:prefix_bans` AS ba - LEFT JOIN `:prefix_admins` AS ad ON ba.aid = ad.aid - LEFT JOIN `:prefix_servers` AS se ON se.sid = ba.sid - WHERE bid = :bid"); - $GLOBALS['PDO']->bind(':bid', (int) $prot['bid']); - $protestb = $GLOBALS['PDO']->single(); + $protestb = $protestBanDetailsByBid[(int) $prot['bid']] ?? null; if (!$protestb) { $delete[] = $prot['bid']; continue; @@ -665,13 +692,7 @@ function ProcessBan() $prot['server'] = $protestb['server_addr'] ? $protestb['server_addr'] : "Web Ban"; $prot['datesubmitted'] = Config::time($prot['datesubmitted']); - $GLOBALS['PDO']->query("SELECT cid, aid, commenttxt, added, edittime, - (SELECT user FROM `:prefix_admins` WHERE aid = C.aid) AS comname, - (SELECT user FROM `:prefix_admins` WHERE aid = C.editaid) AS editname - FROM `:prefix_comments` AS C - WHERE type = 'P' AND bid = :bid ORDER BY added desc"); - $GLOBALS['PDO']->bind(':bid', (int) $prot['pid']); - $commentres = $GLOBALS['PDO']->resultset(); + $commentres = $protestCommentsByPid[(int) $prot['pid']] ?? []; $prot['commentdata'] = bansBuildComments($commentres, $userbank, (int) $prot['pid'], 'P'); $prot['protaddcomment'] = CreateLinkR(' Add Comment', 'index.php?p=banlist&comment=' . (int) $prot['pid'] . '&ctype=P'); @@ -730,20 +751,51 @@ function ProcessBan() } $protest_list_archiv = []; + + $protestArchivBids = []; + foreach ($protestsarchiv as $prot) { + if ($prot['archiv'] != "2") { + $protestArchivBids[] = (int) $prot['bid']; + } + } + $protestArchivBanDetailsByBid = []; + if ($protestArchivBids !== []) { + $placeholders = implode(',', array_fill(0, count($protestArchivBids), '?')); + $banRows = $GLOBALS['PDO']->query( + "SELECT bid, ba.ip, ba.authid, ba.name, created, ends, length, reason, ba.aid, ba.sid AS ba_sid, email, ad.user, CONCAT(se.ip,':',se.port) AS server_addr, se.sid AS se_sid + FROM `:prefix_bans` AS ba + LEFT JOIN `:prefix_admins` AS ad ON ba.aid = ad.aid + LEFT JOIN `:prefix_servers` AS se ON se.sid = ba.sid + WHERE bid IN ($placeholders)" + )->resultset($protestArchivBids); + foreach ($banRows as $banRow) { + $protestArchivBanDetailsByBid[(int) $banRow['bid']] = $banRow; + } + } + + $protestArchivPids = array_map(static fn ($p) => (int) $p['pid'], $protestsarchiv); + $protestArchivCommentsByPid = []; + if ($protestArchivPids !== []) { + $placeholders = implode(',', array_fill(0, count($protestArchivPids), '?')); + $cRows = $GLOBALS['PDO']->query( + "SELECT bid, cid, aid, commenttxt, added, edittime, + (SELECT user FROM `:prefix_admins` WHERE aid = C.aid) AS comname, + (SELECT user FROM `:prefix_admins` WHERE aid = C.editaid) AS editname + FROM `:prefix_comments` AS C + WHERE type = 'P' AND bid IN ($placeholders) ORDER BY added desc" + )->resultset($protestArchivPids); + foreach ($cRows as $cRow) { + $protestArchivCommentsByPid[(int) $cRow['bid']][] = $cRow; + } + } + + $protestArchivToMarkDeleted = []; foreach ($protestsarchiv as $prot) { $prot['reason'] = wordwrap(htmlspecialchars($prot['reason']), 55, "
\n", true); if ($prot['archiv'] != "2") { - $GLOBALS['PDO']->query("SELECT bid, ba.ip, ba.authid, ba.name, created, ends, length, reason, ba.aid, ba.sid AS ba_sid, email, ad.user, CONCAT(se.ip,':',se.port) AS server_addr, se.sid AS se_sid - FROM `:prefix_bans` AS ba - LEFT JOIN `:prefix_admins` AS ad ON ba.aid = ad.aid - LEFT JOIN `:prefix_servers` AS se ON se.sid = ba.sid - WHERE bid = :bid"); - $GLOBALS['PDO']->bind(':bid', (int) $prot['bid']); - $protestb = $GLOBALS['PDO']->single(); + $protestb = $protestArchivBanDetailsByBid[(int) $prot['bid']] ?? null; if (!$protestb) { - $GLOBALS['PDO']->query("UPDATE `:prefix_protests` SET archiv = '2' WHERE pid = :pid"); - $GLOBALS['PDO']->bind(':pid', (int) $prot['pid']); - $GLOBALS['PDO']->execute(); + $protestArchivToMarkDeleted[] = (int) $prot['pid']; $prot['archiv'] = "2"; $prot['archive'] = "ban has been deleted."; } else { @@ -768,18 +820,17 @@ function ProcessBan() } $prot['datesubmitted'] = Config::time($prot['datesubmitted']); - $GLOBALS['PDO']->query("SELECT cid, aid, commenttxt, added, edittime, - (SELECT user FROM `:prefix_admins` WHERE aid = C.aid) AS comname, - (SELECT user FROM `:prefix_admins` WHERE aid = C.editaid) AS editname - FROM `:prefix_comments` AS C - WHERE type = 'P' AND bid = :bid ORDER BY added desc"); - $GLOBALS['PDO']->bind(':bid', (int) $prot['pid']); - $commentres = $GLOBALS['PDO']->resultset(); + $commentres = $protestArchivCommentsByPid[(int) $prot['pid']] ?? []; $prot['commentdata'] = bansBuildComments($commentres, $userbank, (int) $prot['pid'], 'P'); $prot['protaddcomment'] = CreateLinkR(' Add Comment', 'index.php?p=banlist&comment=' . (int) $prot['pid'] . '&ctype=P'); array_push($protest_list_archiv, $prot); } + if ($protestArchivToMarkDeleted !== []) { + $placeholders = implode(',', array_fill(0, count($protestArchivToMarkDeleted), '?')); + $GLOBALS['PDO']->query("UPDATE `:prefix_protests` SET archiv = '2' WHERE pid IN ($placeholders)") + ->execute($protestArchivToMarkDeleted); + } \Sbpp\View\Renderer::render($theme, new \Sbpp\View\AdminBansProtestsArchivView( permission_protests: $userbank->HasAccess(WebPermission::mask(WebPermission::Owner, WebPermission::BanProtests)), @@ -856,33 +907,66 @@ function ProcessBan() } $submission_list = []; + + $submissionSubids = array_map(static fn ($s) => (int) $s['subid'], $submissions); + + $submissionDemoFilenameBySubid = []; + if ($submissionSubids !== []) { + $placeholders = implode(',', array_fill(0, count($submissionSubids), '?')); + $demRows = $GLOBALS['PDO']->query( + "SELECT demid, filename FROM `:prefix_demos` WHERE demtype = 'S' AND demid IN ($placeholders)" + )->resultset($submissionSubids); + foreach ($demRows as $demRow) { + $submissionDemoFilenameBySubid[(int) $demRow['demid']] = $demRow['filename']; + } + } + + $submissionModIds = []; + foreach ($submissions as $sub) { + $submissionModIds[(int) $sub['ModID']] = true; + } + $submissionModNameById = []; + if ($submissionModIds !== []) { + $modIds = array_keys($submissionModIds); + $placeholders = implode(',', array_fill(0, count($modIds), '?')); + $modRows = $GLOBALS['PDO']->query( + "SELECT mid, name FROM `:prefix_mods` WHERE mid IN ($placeholders)" + )->resultset($modIds); + foreach ($modRows as $modRow) { + $submissionModNameById[(int) $modRow['mid']] = $modRow['name']; + } + } + + $submissionCommentsBySubid = []; + if ($submissionSubids !== []) { + $placeholders = implode(',', array_fill(0, count($submissionSubids), '?')); + $cRows = $GLOBALS['PDO']->query( + "SELECT bid, cid, aid, commenttxt, added, edittime, + (SELECT user FROM `:prefix_admins` WHERE aid = C.aid) AS comname, + (SELECT user FROM `:prefix_admins` WHERE aid = C.editaid) AS editname + FROM `:prefix_comments` AS C + WHERE type = 'S' AND bid IN ($placeholders) ORDER BY added desc" + )->resultset($submissionSubids); + foreach ($cRows as $cRow) { + $submissionCommentsBySubid[(int) $cRow['bid']][] = $cRow; + } + } + foreach ($submissions as $sub) { $sub['name'] = wordwrap(htmlspecialchars($sub['name']), 55, "
", true); $sub['reason'] = wordwrap(htmlspecialchars($sub['reason']), 55, "
", true); - $GLOBALS['PDO']->query("SELECT filename FROM `:prefix_demos` WHERE demtype = 'S' AND demid = :subid"); - $GLOBALS['PDO']->bind(':subid', (int) $sub['subid']); - $dem = $GLOBALS['PDO']->single(); - - $sub['demo'] = ($dem && !empty($dem['filename']) && @file_exists(SB_DEMOS . "/" . $dem['filename'])) + $demoFilename = $submissionDemoFilenameBySubid[(int) $sub['subid']] ?? null; + $sub['demo'] = (!empty($demoFilename) && @file_exists(SB_DEMOS . "/" . $demoFilename)) ? ' Get Demo' : " No Demo"; $sub['submitted'] = Config::time($sub['submitted']); - $GLOBALS['PDO']->query("SELECT m.name FROM `:prefix_submissions` AS s LEFT JOIN `:prefix_mods` AS m ON m.mid = s.ModID WHERE s.subid = :subid"); - $GLOBALS['PDO']->bind(':subid', (int) $sub['subid']); - $mod = $GLOBALS['PDO']->single(); - $sub['mod'] = $mod['name']; + $sub['mod'] = $submissionModNameById[(int) $sub['ModID']] ?? null; $sub['hostname'] = empty($sub['server']) ? 'Other server...' : ""; - $GLOBALS['PDO']->query("SELECT cid, aid, commenttxt, added, edittime, - (SELECT user FROM `:prefix_admins` WHERE aid = C.aid) AS comname, - (SELECT user FROM `:prefix_admins` WHERE aid = C.editaid) AS editname - FROM `:prefix_comments` AS C - WHERE type = 'S' AND bid = :bid ORDER BY added desc"); - $GLOBALS['PDO']->bind(':bid', (int) $sub['subid']); - $commentres = $GLOBALS['PDO']->resultset(); + $commentres = $submissionCommentsBySubid[(int) $sub['subid']] ?? []; $sub['commentdata'] = bansBuildComments($commentres, $userbank, (int) $sub['subid'], 'S'); $sub['subaddcomment'] = CreateLinkR(' Add Comment', 'index.php?p=banlist&comment=' . (int) $sub['subid'] . '&ctype=S'); @@ -935,24 +1019,63 @@ function ProcessBan() } $submission_list_archiv = []; + + $submissionArchivSubids = array_map(static fn ($s) => (int) $s['subid'], $submissionsarchiv); + + $submissionArchivDemoFilenameBySubid = []; + if ($submissionArchivSubids !== []) { + $placeholders = implode(',', array_fill(0, count($submissionArchivSubids), '?')); + $demRows = $GLOBALS['PDO']->query( + "SELECT demid, filename FROM `:prefix_demos` WHERE demtype = 'S' AND demid IN ($placeholders)" + )->resultset($submissionArchivSubids); + foreach ($demRows as $demRow) { + $submissionArchivDemoFilenameBySubid[(int) $demRow['demid']] = $demRow['filename']; + } + } + + $submissionArchivModIds = []; + foreach ($submissionsarchiv as $sub) { + $submissionArchivModIds[(int) $sub['ModID']] = true; + } + $submissionArchivModNameById = []; + if ($submissionArchivModIds !== []) { + $modIds = array_keys($submissionArchivModIds); + $placeholders = implode(',', array_fill(0, count($modIds), '?')); + $modRows = $GLOBALS['PDO']->query( + "SELECT mid, name FROM `:prefix_mods` WHERE mid IN ($placeholders)" + )->resultset($modIds); + foreach ($modRows as $modRow) { + $submissionArchivModNameById[(int) $modRow['mid']] = $modRow['name']; + } + } + + $submissionArchivCommentsBySubid = []; + if ($submissionArchivSubids !== []) { + $placeholders = implode(',', array_fill(0, count($submissionArchivSubids), '?')); + $cRows = $GLOBALS['PDO']->query( + "SELECT bid, cid, aid, commenttxt, added, edittime, + (SELECT user FROM `:prefix_admins` WHERE aid = C.aid) AS comname, + (SELECT user FROM `:prefix_admins` WHERE aid = C.editaid) AS editname + FROM `:prefix_comments` AS C + WHERE type = 'S' AND bid IN ($placeholders) ORDER BY added desc" + )->resultset($submissionArchivSubids); + foreach ($cRows as $cRow) { + $submissionArchivCommentsBySubid[(int) $cRow['bid']][] = $cRow; + } + } + foreach ($submissionsarchiv as $sub) { $sub['name'] = wordwrap(htmlspecialchars($sub['name']), 55, "
", true); $sub['reason'] = wordwrap(htmlspecialchars($sub['reason']), 55, "
", true); - $GLOBALS['PDO']->query("SELECT filename FROM `:prefix_demos` WHERE demtype = 'S' AND demid = :subid"); - $GLOBALS['PDO']->bind(':subid', (int) $sub['subid']); - $dem = $GLOBALS['PDO']->single(); - - $sub['demo'] = ($dem && !empty($dem['filename']) && @file_exists(SB_DEMOS . "/" . $dem['filename'])) + $demoFilename = $submissionArchivDemoFilenameBySubid[(int) $sub['subid']] ?? null; + $sub['demo'] = (!empty($demoFilename) && @file_exists(SB_DEMOS . "/" . $demoFilename)) ? ' Get Demo' : " No Demo"; $sub['submitted'] = Config::time($sub['submitted']); - $GLOBALS['PDO']->query("SELECT m.name FROM `:prefix_submissions` AS s LEFT JOIN `:prefix_mods` AS m ON m.mid = s.ModID WHERE s.subid = :subid"); - $GLOBALS['PDO']->bind(':subid', (int) $sub['subid']); - $mod = $GLOBALS['PDO']->single(); - $sub['mod'] = $mod['name']; + $sub['mod'] = $submissionArchivModNameById[(int) $sub['ModID']] ?? null; $sub['hostname'] = empty($sub['server']) ? 'Other server...' : ""; if ($sub['archiv'] == "3") { $sub['archive'] = "player has been banned."; @@ -962,13 +1085,7 @@ function ProcessBan() $sub['archive'] = "submission has been archived."; } - $GLOBALS['PDO']->query("SELECT cid, aid, commenttxt, added, edittime, - (SELECT user FROM `:prefix_admins` WHERE aid = C.aid) AS comname, - (SELECT user FROM `:prefix_admins` WHERE aid = C.editaid) AS editname - FROM `:prefix_comments` AS C - WHERE type = 'S' AND bid = :bid ORDER BY added desc"); - $GLOBALS['PDO']->bind(':bid', (int) $sub['subid']); - $commentres = $GLOBALS['PDO']->resultset(); + $commentres = $submissionArchivCommentsBySubid[(int) $sub['subid']] ?? []; $sub['commentdata'] = bansBuildComments($commentres, $userbank, (int) $sub['subid'], 'S'); $sub['subaddcomment'] = CreateLinkR(' Add Comment', 'index.php?p=banlist&comment=' . (int) $sub['subid'] . '&ctype=S'); diff --git a/web/pages/admin.groups.php b/web/pages/admin.groups.php index c23b179eb..ca8d96cd5 100644 --- a/web/pages/admin.groups.php +++ b/web/pages/admin.groups.php @@ -78,6 +78,19 @@ // the same per-group queries below. // ------------------------------------------------------------------ $web_group_rows = $GLOBALS['PDO']->query("SELECT * FROM `:prefix_groups` WHERE type != '3'")->resultset(); + +$webGroupIds = array_map(static fn ($r) => (int) $r['gid'], $web_group_rows); +$webGroupMembersByGid = []; +if ($webGroupIds !== []) { + $placeholders = implode(',', array_fill(0, count($webGroupIds), '?')); + $memberRows = $GLOBALS['PDO']->query( + "SELECT aid, user, authid, gid FROM `:prefix_admins` WHERE gid IN ($placeholders)" + )->resultset($webGroupIds); + foreach ($memberRows as $memberRow) { + $webGroupMembersByGid[(int) $memberRow['gid']][] = $memberRow; + } +} + $web_group_list = []; $web_admins = []; $web_admins_list = []; @@ -86,14 +99,8 @@ $row['flags'] = (int) $row['flags']; $row['permissions'] = BitToString($row['flags']); - $cnt = $GLOBALS['PDO']->query("SELECT COUNT(gid) AS cnt FROM `:prefix_admins` WHERE gid = :gid"); - $GLOBALS['PDO']->bind(':gid', $row['gid']); - $cnt = $GLOBALS['PDO']->single(); - $row['member_count'] = (int) $cnt['cnt']; - - $GLOBALS['PDO']->query("SELECT aid, user, authid FROM `:prefix_admins` WHERE gid = :gid"); - $GLOBALS['PDO']->bind(':gid', $row['gid']); - $members = $GLOBALS['PDO']->resultset(); + $members = $webGroupMembersByGid[$row['gid']] ?? []; + $row['member_count'] = count($members); $web_group_list[] = $row; $web_admins[] = $row['member_count']; @@ -105,6 +112,31 @@ // Server admin groups (`:prefix_srvgroups`). // ------------------------------------------------------------------ $server_admin_group_rows = $GLOBALS['PDO']->query("SELECT * FROM `:prefix_srvgroups`")->resultset(); + +$srvGroupNames = array_map(static fn ($r) => (string) $r['name'], $server_admin_group_rows); +$srvGroupMembersByName = []; +if ($srvGroupNames !== []) { + $placeholders = implode(',', array_fill(0, count($srvGroupNames), '?')); + $memberRows = $GLOBALS['PDO']->query( + "SELECT aid, user, authid, srv_group FROM `:prefix_admins` WHERE srv_group IN ($placeholders)" + )->resultset($srvGroupNames); + foreach ($memberRows as $memberRow) { + $srvGroupMembersByName[$memberRow['srv_group']][] = $memberRow; + } +} + +$srvGroupIds = array_map(static fn ($r) => (int) $r['id'], $server_admin_group_rows); +$srvGroupOverridesByGroupId = []; +if ($srvGroupIds !== []) { + $placeholders = implode(',', array_fill(0, count($srvGroupIds), '?')); + $overrideRows = $GLOBALS['PDO']->query( + "SELECT type, name, access, group_id FROM `:prefix_srvgroups_overrides` WHERE group_id IN ($placeholders)" + )->resultset($srvGroupIds); + foreach ($overrideRows as $overrideRow) { + $srvGroupOverridesByGroupId[(int) $overrideRow['group_id']][] = $overrideRow; + } +} + $server_group_list = []; $server_admins = []; $server_admins_list = []; @@ -114,18 +146,10 @@ $row['immunity'] = (int) ($row['immunity'] ?? 0); $row['permissions'] = SmFlagsToSb($row['flags']); - $GLOBALS['PDO']->query("SELECT COUNT(aid) AS cnt FROM `:prefix_admins` WHERE srv_group = :srv_group"); - $GLOBALS['PDO']->bind(':srv_group', $row['name']); - $cnt = $GLOBALS['PDO']->single(); - $row['member_count'] = (int) $cnt['cnt']; + $members = $srvGroupMembersByName[$row['name']] ?? []; + $row['member_count'] = count($members); - $GLOBALS['PDO']->query("SELECT aid, user, authid FROM `:prefix_admins` WHERE srv_group = :srv_group"); - $GLOBALS['PDO']->bind(':srv_group', $row['name']); - $members = $GLOBALS['PDO']->resultset(); - - $GLOBALS['PDO']->query("SELECT type, name, access FROM `:prefix_srvgroups_overrides` WHERE group_id = :gid"); - $GLOBALS['PDO']->bind(':gid', $row['id']); - $overrides = $GLOBALS['PDO']->resultset(); + $overrides = $srvGroupOverridesByGroupId[$row['id']] ?? []; $server_group_list[] = $row; $server_admins[] = $row['member_count']; @@ -153,6 +177,23 @@ // action is registered for this surface. // ------------------------------------------------------------------ $server_group_rows = $GLOBALS['PDO']->query("SELECT * FROM `:prefix_groups` WHERE type = '3'")->resultset(); + +$serverGroupIds = array_map(static fn ($r) => (int) $r['gid'], $server_group_rows); +$serverRowsByGroupId = []; +if ($serverGroupIds !== []) { + $placeholders = implode(',', array_fill(0, count($serverGroupIds), '?')); + $groupServerRows = $GLOBALS['PDO']->query( + "SELECT S.sid, S.ip, S.port, S.enabled, SG.group_id + FROM `:prefix_servers_groups` AS SG + INNER JOIN `:prefix_servers` AS S ON S.sid = SG.server_id + WHERE SG.group_id IN ($placeholders) + ORDER BY S.sid ASC" + )->resultset($serverGroupIds); + foreach ($groupServerRows as $groupServerRow) { + $serverRowsByGroupId[(int) $groupServerRow['group_id']][] = $groupServerRow; + } +} + $server_list = []; $server_counts = []; foreach ($server_group_rows as $row) { @@ -177,15 +218,7 @@ // `Actions.ServersHostPlayers` against a server the panel // already knows is offline by config. Mirrors the sibling // contract in `page_admin_servers_list.tpl`. - $GLOBALS['PDO']->query( - "SELECT S.sid, S.ip, S.port, S.enabled - FROM `:prefix_servers_groups` AS SG - INNER JOIN `:prefix_servers` AS S ON S.sid = SG.server_id - WHERE SG.group_id = :gid - ORDER BY S.sid ASC" - ); - $GLOBALS['PDO']->bind(':gid', $row['gid']); - $serverRows = $GLOBALS['PDO']->resultset(); + $serverRows = $serverRowsByGroupId[$row['gid']] ?? []; $row['servers'] = array_map(static fn (array $s): array => [ 'sid' => (int) $s['sid'], diff --git a/web/pages/page.banlist.php b/web/pages/page.banlist.php index ba07a2f74..1ce92642c 100644 --- a/web/pages/page.banlist.php +++ b/web/pages/page.banlist.php @@ -783,6 +783,101 @@ function setPostKey() $canEditComment = false; $view_comments = false; $bans = []; + +$banIds = []; +foreach ($res as $row) { + $banIds[] = (int) $row['ban_id']; +} + +$removedByAdminIds = []; +foreach ($res as $row) { + if ($row['RemovedBy'] !== null) { + $removedByAdminIds[(int) $row['RemovedBy']] = true; + } +} +$removedByNames = []; +if ($removedByAdminIds !== []) { + $ids = array_keys($removedByAdminIds); + $placeholders = implode(',', array_fill(0, count($ids), '?')); + $adminRows = $GLOBALS['PDO']->query( + "SELECT aid, user FROM `:prefix_admins` WHERE aid IN ($placeholders)" + )->resultset($ids); + foreach ($adminRows as $adminRow) { + $removedByNames[(int) $adminRow['aid']] = $adminRow['user']; + } +} + +$activeSteamCounts = []; +$activeIpCounts = []; +$steamAuthidsToCheck = []; +$ipsToCheck = []; +foreach ($res as $row) { + $effectiveSteamId = (string) $row['authid']; + if ($effectiveSteamId !== '' && !\SteamID\SteamID::isValidID($effectiveSteamId)) { + $effectiveSteamId = 'STEAM_0:0:00000000'; + } + $rowBanTypeForCheck = BanType::tryFrom((int) $row['type']) ?? BanType::Steam; + if ($rowBanTypeForCheck === BanType::Steam) { + $steamAuthidsToCheck[$effectiveSteamId] = true; + } else { + $ipsToCheck[(string) $row['ban_ip']] = true; + } +} +if ($steamAuthidsToCheck !== []) { + $ids = array_keys($steamAuthidsToCheck); + $placeholders = implode(',', array_fill(0, count($ids), '?')); + $countRows = $GLOBALS['PDO']->query( + "SELECT authid, COUNT(bid) as cnt FROM `:prefix_bans` WHERE authid IN ($placeholders) AND (length = 0 OR ends > UNIX_TIMESTAMP()) AND RemovedBy IS NULL AND type = '0' GROUP BY authid" + )->resultset($ids); + foreach ($countRows as $countRow) { + $activeSteamCounts[$countRow['authid']] = (int) $countRow['cnt']; + } +} +if ($ipsToCheck !== []) { + $ids = array_keys($ipsToCheck); + $placeholders = implode(',', array_fill(0, count($ids), '?')); + $countRows = $GLOBALS['PDO']->query( + "SELECT ip, COUNT(bid) as cnt FROM `:prefix_bans` WHERE ip IN ($placeholders) AND (length = 0 OR ends > UNIX_TIMESTAMP()) AND RemovedBy IS NULL AND type = '1' GROUP BY ip" + )->resultset($ids); + foreach ($countRows as $countRow) { + $activeIpCounts[$countRow['ip']] = (int) $countRow['cnt']; + } +} + +$banlogByBid = []; +if ($banIds !== []) { + $placeholders = implode(',', array_fill(0, count($banIds), '?')); + $blRows = $GLOBALS['PDO']->query( + "SELECT bl.bid, bl.time, bl.name, s.ip, s.port FROM `:prefix_banlog` AS bl LEFT JOIN `:prefix_servers` AS s ON s.sid = bl.sid WHERE bl.bid IN ($placeholders)" + )->resultset($banIds); + foreach ($blRows as $blRow) { + $banlogByBid[(int) $blRow['bid']][] = $blRow; + } +} + +$viewCommentsEnabled = Config::getBool('config.enablepubliccomments') || $userbank->is_admin(); +$commentsByBid = []; +if ($viewCommentsEnabled && $banIds !== []) { + $placeholders = implode(',', array_fill(0, count($banIds), '?')); + $cRows = $GLOBALS['PDO']->query( + "SELECT bid, cid, aid, editaid, commenttxt, added, edittime, + (SELECT user FROM `:prefix_admins` WHERE aid = C.aid) AS comname, + (SELECT user FROM `:prefix_admins` WHERE aid = C.editaid) AS editname + FROM `:prefix_comments` AS C + WHERE type = 'B' AND bid IN ($placeholders) ORDER BY bid, added desc" + )->resultset($banIds); + foreach ($cRows as $cRow) { + $commentsByBid[(int) $cRow['bid']][] = $cRow; + } + $view_comments = true; +} + +// GeoIP lookups are done inline (no network round trip, MaxMind is a +// local DB file), but the write-back that caches the resolved country +// on `:prefix_bans` is queued here and flushed as ONE batched UPDATE +// after the loop instead of one UPDATE per row. +$pendingCountryUpdates = []; + foreach ($res as $row) { $data = []; @@ -793,11 +888,7 @@ function setPostKey() $data['country'] = '' . $row['ban_country'] . ''; } elseif (!Config::getBool('banlist.nocountryfetch')) { $country = FetchIp($row['ban_ip']); - $GLOBALS['PDO']->query("UPDATE `:prefix_bans` SET country = ? - WHERE bid = ?")->execute([ - $country, - $row['ban_id'], - ]); + $pendingCountryUpdates[(int) $row['ban_id']] = $country; $countryFlag = empty($country) ? 'zz' : strtolower($country); $data['country'] = '' . $country . ''; @@ -873,12 +964,10 @@ function setPostKey() $data['ureason'] = stripslashes($row['unban_reason'] ?? ''); - $GLOBALS['PDO']->query("SELECT user FROM `:prefix_admins` WHERE aid = :aid"); - $GLOBALS['PDO']->bind(':aid', $row['RemovedBy']); - $removedby = $GLOBALS['PDO']->single(); - $data['removedby'] = ""; - if (!empty($removedby['user']) && $data['admin']) { - $data['removedby'] = $removedby['user']; + $removedByUser = $removedByNames[(int) $row['RemovedBy']] ?? ''; + $data['removedby'] = ""; + if ($removedByUser !== '' && $data['admin']) { + $data['removedby'] = $removedByUser; } } // Don't need this stuff. @@ -892,15 +981,6 @@ function setPostKey() $data['layer_id'] = 'layer_' . $row['ban_id']; $rowBanType = BanType::tryFrom((int) $data['type']) ?? BanType::Steam; - if ($rowBanType === BanType::Steam) { - $GLOBALS['PDO']->query("SELECT count(bid) as count FROM `:prefix_bans` WHERE authid = :authid AND (length = 0 OR ends > UNIX_TIMESTAMP()) AND RemovedBy IS NULL AND type = '0'"); - $GLOBALS['PDO']->bind(':authid', $data['steamid']); - $alrdybnd = $GLOBALS['PDO']->single(); - } else { - $GLOBALS['PDO']->query("SELECT count(bid) as count FROM `:prefix_bans` WHERE ip = :ip AND (length = 0 OR ends > UNIX_TIMESTAMP()) AND RemovedBy IS NULL AND type = '1'"); - $GLOBALS['PDO']->bind(':ip', $row['ban_ip']); - $alrdybnd = $GLOBALS['PDO']->single(); - } // `has_active_sibling` is the v2.0 template's hook for hiding the // Re-apply affordance when the player is already actively banned by // another row (which the duplicate-check in `bans.add` would @@ -911,7 +991,9 @@ function setPostKey() // states the row itself never matches the active-check predicate // (`(length=0 OR ends > now) AND RemovedBy IS NULL`), so this // count is "siblings only" without an explicit `bid !=` exclusion. - $hasActiveSibling = (int) $alrdybnd['count'] > 0; + $hasActiveSibling = $rowBanType === BanType::Steam + ? (($activeSteamCounts[$data['steamid']] ?? 0) > 0) + : (($activeIpCounts[(string) $row['ban_ip']] ?? 0) > 0); $data['has_active_sibling'] = $hasActiveSibling; if (!$hasActiveSibling) { // #1275 — admin-bans is Pattern A; the legacy `#^0` fragment @@ -988,9 +1070,7 @@ function setPostKey() $data['server_id'] = $row['ban_server']; - $GLOBALS['PDO']->query("SELECT bl.time, bl.name, s.ip, s.port FROM `:prefix_banlog` AS bl LEFT JOIN `:prefix_servers` AS s ON s.sid = bl.sid WHERE bid = :bid"); - $GLOBALS['PDO']->bind(':bid', $data['ban_id']); - $banlog = $GLOBALS['PDO']->resultset(); + $banlog = $banlogByBid[(int) $data['ban_id']] ?? []; $data['blockcount'] = sizeof($banlog); $logstring = ""; foreach ($banlog as $logged) { @@ -1003,20 +1083,14 @@ function setPostKey() //COMMENT STUFF //----------------------------------- - if (Config::getBool('config.enablepubliccomments') || $userbank->is_admin()) { + if ($viewCommentsEnabled) { $view_comments = true; // #1500: comment author/editor are admin usernames. Null them at the // data layer for public viewers when banlist.hideadminname is on, so a // third-party theme that renders the name directly can't re-leak it // (parity with the focal $data['admin'] = false gate above). $commentsHideAdmin = Config::getBool('banlist.hideadminname') && !$userbank->is_admin(); - $GLOBALS['PDO']->query("SELECT cid, aid, commenttxt, added, edittime, - (SELECT user FROM `:prefix_admins` WHERE aid = C.aid) AS comname, - (SELECT user FROM `:prefix_admins` WHERE aid = C.editaid) AS editname - FROM `:prefix_comments` AS C - WHERE type = 'B' AND bid = :bid ORDER BY added desc"); - $GLOBALS['PDO']->bind(':bid', $data['ban_id']); - $commentres = $GLOBALS['PDO']->resultset(); + $commentres = $commentsByBid[(int) $data['ban_id']] ?? []; if (count($commentres) > 0) { $comment = []; @@ -1164,6 +1238,28 @@ function setPostKey() array_push($bans, $data); } +// Flush the GeoIP country write-back queued during the loop above as a +// single batched UPDATE (one round trip for the whole page) instead of +// one UPDATE per row. +if ($pendingCountryUpdates !== []) { + $caseParts = []; + $whenArgs = []; + $idPlaceholders = []; + $idArgs = []; + foreach ($pendingCountryUpdates as $bid => $country) { + $caseParts[] = "WHEN ? THEN ?"; + $whenArgs[] = $bid; + $whenArgs[] = $country; + $idPlaceholders[] = "?"; + $idArgs[] = $bid; + } + $caseSql = implode(' ', $caseParts); + $idSql = implode(',', $idPlaceholders); + $GLOBALS['PDO']->query( + "UPDATE `:prefix_bans` SET country = CASE bid $caseSql END WHERE bid IN ($idSql)" + )->execute(array_merge($whenArgs, $idArgs)); +} + if (isset($_GET['advSearch'])) { $advSearchString = "&advSearch=" . urlencode(isset($_GET['advSearch']) ? $_GET['advSearch'] : '') . "&advType=" . urlencode(isset($_GET['advType']) ? $_GET['advType'] : ''); } else { diff --git a/web/pages/page.commslist.php b/web/pages/page.commslist.php index 586c69feb..704d317e2 100644 --- a/web/pages/page.commslist.php +++ b/web/pages/page.commslist.php @@ -637,6 +637,65 @@ function setPostKey() $view_comments = false; $bans = []; + +$removedByAdminIds = []; +foreach ($res as $row) { + if ($row['RemovedBy'] !== null) { + $removedByAdminIds[(int) $row['RemovedBy']] = true; + } +} +$removedByNames = []; +if ($removedByAdminIds !== []) { + $ids = array_keys($removedByAdminIds); + $placeholders = implode(',', array_fill(0, count($ids), '?')); + $adminRows = $GLOBALS['PDO']->query( + "SELECT aid, user FROM `:prefix_admins` WHERE aid IN ($placeholders)" + )->resultset($ids); + foreach ($adminRows as $adminRow) { + $removedByNames[(int) $adminRow['aid']] = $adminRow['user']; + } +} + +$bidList = []; +$siblingAuthidsToCheck = []; +foreach ($res as $row) { + $bidList[] = (int) $row['ban_id']; + + $effectiveSteamId = (string) $row['authid']; + if (!SteamID::isValidID($effectiveSteamId)) { + $effectiveSteamId = 'STEAM_0:0:00000000'; + } + $siblingAuthidsToCheck[$effectiveSteamId] = true; +} + +$activeSiblingCounts = []; +if ($siblingAuthidsToCheck !== []) { + $authids = array_keys($siblingAuthidsToCheck); + $placeholders = implode(',', array_fill(0, count($authids), '?')); + $countRows = $GLOBALS['PDO']->query( + "SELECT authid, type, COUNT(bid) as cnt FROM `:prefix_comms` WHERE authid IN ($placeholders) AND RemovedBy IS NULL AND (length = 0 OR ends > UNIX_TIMESTAMP()) GROUP BY authid, type" + )->resultset($authids); + foreach ($countRows as $countRow) { + $activeSiblingCounts[$countRow['authid'] . '|' . (int) $countRow['type']] = (int) $countRow['cnt']; + } +} + +$commentsByBidComm = []; +$viewCommentsEnabled = Config::getBool('config.enablepubliccomments') || $userbank->is_admin(); +if ($viewCommentsEnabled && $bidList !== []) { + $placeholders = implode(',', array_fill(0, count($bidList), '?')); + $cRows = $GLOBALS['PDO']->query( + "SELECT bid, cid, aid, commenttxt, added, edittime, + (SELECT user FROM `:prefix_admins` WHERE aid = C.aid) AS comname, + (SELECT user FROM `:prefix_admins` WHERE aid = C.editaid) AS editname + FROM `:prefix_comments` AS C + WHERE C.type = 'C' AND bid IN ($placeholders) ORDER BY bid, added desc" + )->resultset($bidList); + foreach ($cRows as $cRow) { + $commentsByBidComm[(int) $cRow['bid']][] = $cRow; + } +} + foreach ($res as $row) { $data = []; @@ -722,12 +781,10 @@ function setPostKey() if (isset($row['unban_reason'])) $data['ureason'] = stripslashes($row['unban_reason']); - $GLOBALS['PDO']->query("SELECT user FROM `:prefix_admins` WHERE aid = :aid"); - $GLOBALS['PDO']->bind(':aid', $row['RemovedBy']); - $removedby = $GLOBALS['PDO']->single(); - $data['removedby'] = ""; - if (!empty($removedby['user']) && $data['admin']) { - $data['removedby'] = $removedby['user']; + $removedByUser = $removedByNames[(int) $row['RemovedBy']] ?? ''; + $data['removedby'] = ""; + if ($removedByUser !== '' && $data['admin']) { + $data['removedby'] = $removedByUser; } } else if ($data['ban_length'] == 'Permanent') { $data['class'] = "listtable_1_permanent"; @@ -744,13 +801,7 @@ function setPostKey() // Re-gag affordance must hide when the player already has an // active block of the same type (the duplicate-check in // `comms.add` would 4xx as `already_blocked`). - $GLOBALS['PDO']->query("SELECT count(bid) as count FROM `:prefix_comms` WHERE authid = :authid AND RemovedBy IS NULL AND type = :type AND (length = 0 OR ends > UNIX_TIMESTAMP())"); - $GLOBALS['PDO']->bindMultiple([ - ':authid' => $data['steamid'], - ':type' => $data['type'], - ]); - $alrdybnd = $GLOBALS['PDO']->single(); - $hasActiveSibling = (int) $alrdybnd['count'] > 0; + $hasActiveSibling = ($activeSiblingCounts[$data['steamid'] . '|' . (int) $data['type']] ?? 0) > 0; $data['has_active_sibling'] = $hasActiveSibling; if (!$hasActiveSibling) { // #1275 — admin-comms is single-section Pattern A; the legacy @@ -821,13 +872,7 @@ function setPostKey() // third-party theme that renders the name directly can't re-leak it // (parity with the focal admin-name gate above). $commentsHideAdmin = Config::getBool('banlist.hideadminname') && !$userbank->is_admin(); - $GLOBALS['PDO']->query("SELECT cid, aid, commenttxt, added, edittime, - (SELECT user FROM `:prefix_admins` WHERE aid = C.aid) AS comname, - (SELECT user FROM `:prefix_admins` WHERE aid = C.editaid) AS editname - FROM `:prefix_comments` AS C - WHERE C.type = 'C' AND bid = :bid ORDER BY added desc"); - $GLOBALS['PDO']->bind(':bid', $data['ban_id']); - $commentres = $GLOBALS['PDO']->resultset(); + $commentres = $commentsByBidComm[(int) $data['ban_id']] ?? []; if (count($commentres) > 0) { if ($mute_count > 0 || $gag_count > 0) { diff --git a/web/phpstan-baseline.neon b/web/phpstan-baseline.neon index 32a0fe498..5d2c9931c 100644 --- a/web/phpstan-baseline.neon +++ b/web/phpstan-baseline.neon @@ -108,12 +108,6 @@ parameters: count: 1 path: includes/Auth/JWT.php - - - message: '#^Parameter \#1 \$contents of static method Lcobucci\\JWT\\Signer\\Key\\InMemory\:\:base64Encoded\(\) expects non\-empty\-string, '''' given\.$#' - identifier: argument.type - count: 1 - path: includes/Auth/JWT.php - - message: '#^Call to an undefined method Lcobucci\\JWT\\Token\:\:claims\(\)\.$#' identifier: method.notFound diff --git a/web/tests/QueryCountAssertions.php b/web/tests/QueryCountAssertions.php new file mode 100644 index 000000000..a784aff81 --- /dev/null +++ b/web/tests/QueryCountAssertions.php @@ -0,0 +1,99 @@ + $baseline, 'grown' => $grown, 'delta' => $delta]; + } +} diff --git a/web/tests/bootstrap.php b/web/tests/bootstrap.php index 2d5e560bd..4df9cf3f8 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__ . '/QueryCountAssertions.php'; // DB bring-up is lazy: ApiTestCase::setUp() calls Fixture::reset(), // which calls Fixture::install() the first time it's invoked. This diff --git a/web/tests/integration/AdminPagesQueryCountTest.php b/web/tests/integration/AdminPagesQueryCountTest.php new file mode 100644 index 000000000..0a3486e40 --- /dev/null +++ b/web/tests/integration/AdminPagesQueryCountTest.php @@ -0,0 +1,273 @@ +makeStubTheme(); + $GLOBALS['userbank'] = $GLOBALS['userbank'] ?? new \CUserManager(null); + $GLOBALS['username'] = $GLOBALS['username'] ?? 'tester'; + $_SERVER['REMOTE_ADDR'] = $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1'; + } + + private function renderPage(string $relativePath): void + { + ob_start(); + try { + (function () use ($relativePath): void { + global $userbank, $theme; + $userbank = $GLOBALS['userbank']; + $theme = $GLOBALS['theme']; + require ROOT . $relativePath; + })(); + } finally { + ob_end_clean(); + } + } + + /** @return array */ + public static function rowCountProvider(): array + { + return [ + '3 rows' => [3], + '15 rows' => [15], + ]; + } + + private function seedExtraAdmins(int $count): void + { + $pdo = Fixture::rawPdo(); + $hash = password_hash('x', PASSWORD_BCRYPT); + $stmt = $pdo->prepare(sprintf( + 'INSERT INTO `%s_admins` (user, authid, password, gid, email, validate, extraflags, immunity) + VALUES (?, ?, ?, -1, ?, NULL, ?, 0)', + DB_PREFIX, + )); + $banStmt = $pdo->prepare(sprintf( + 'INSERT INTO `%s_bans` (type, ip, authid, name, created, ends, length, reason, aid) + VALUES (0, NULL, ?, ?, ?, 0, 0, ?, ?)', + DB_PREFIX, + )); + $now = time(); + for ($i = 0; $i < $count; $i++) { + $stmt->execute([ + 'AdminQcFixture' . $i, + 'STEAM_0:1:' . (81000 + $i), + $hash, + 'adminqc' . $i . '@example.test', + 0, + ]); + $aid = (int) $pdo->lastInsertId(); + $banStmt->execute([ + 'STEAM_0:1:' . (91000 + $i), + 'AdminQcBan' . $i, + $now - $i, + 'seed', + $aid, + ]); + } + } + + private function seedProtests(int $count): void + { + $pdo = Fixture::rawPdo(); + $now = time(); + $aid = Fixture::adminAid(); + $banInsert = $pdo->prepare(sprintf( + 'INSERT INTO `%s_bans` (type, ip, authid, name, created, ends, length, reason, aid) + VALUES (0, NULL, ?, ?, ?, 0, 0, ?, ?)', + DB_PREFIX, + )); + $protestInsert = $pdo->prepare(sprintf( + 'INSERT INTO `%s_protests` (bid, datesubmitted, reason, email, archiv, archivedby, pip) + VALUES (?, ?, ?, ?, 0, NULL, ?)', + DB_PREFIX, + )); + for ($i = 0; $i < $count; $i++) { + $banInsert->execute([ + 'STEAM_0:1:' . (92000 + $i), + 'ProtestQcBan' . $i, + $now - $i, + 'seed', + $aid, + ]); + $bid = (int) $pdo->lastInsertId(); + $protestInsert->execute([ + $bid, + $now - $i, + 'protest reason ' . $i, + 'protest' . $i . '@example.test', + '127.0.0.1', + ]); + } + } + + private function seedSubmissions(int $count): void + { + $pdo = Fixture::rawPdo(); + $now = time(); + $insert = $pdo->prepare(sprintf( + 'INSERT INTO `%s_submissions` (submitted, SteamId, name, email, ModID, reason, ip, subname, sip, archiv, archivedby) + VALUES (?, ?, ?, ?, 0, ?, ?, ?, ?, 0, NULL)', + DB_PREFIX, + )); + for ($i = 0; $i < $count; $i++) { + $insert->execute([ + $now - $i, + 'STEAM_0:1:' . (93000 + $i), + 'SubmissionQc' . $i, + 'sub' . $i . '@example.test', + 'reason ' . $i, + '127.0.0.' . (($i % 200) + 1), + 'reporter' . $i, + '10.0.0.' . (($i % 200) + 1), + ]); + } + } + + private function seedWebGroups(int $count): void + { + $pdo = Fixture::rawPdo(); + $groupInsert = $pdo->prepare(sprintf( + 'INSERT INTO `%s_groups` (type, name, flags) + VALUES (1, ?, ?)', + DB_PREFIX, + )); + $hash = password_hash('x', PASSWORD_BCRYPT); + $adminInsert = $pdo->prepare(sprintf( + 'INSERT INTO `%s_admins` (user, authid, password, gid, email, validate, extraflags, immunity) + VALUES (?, ?, ?, ?, ?, NULL, ?, 0)', + DB_PREFIX, + )); + for ($i = 0; $i < $count; $i++) { + $groupInsert->execute(['GroupQc' . $i, (string) (1 << ($i % 8))]); + $gid = (int) $pdo->lastInsertId(); + $adminInsert->execute([ + 'GroupQcAdmin' . $i, + 'STEAM_0:1:' . (94000 + $i), + $hash, + $gid, + 'groupqc' . $i . '@example.test', + 0, + ]); + } + } + + #[DataProvider('rowCountProvider')] + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] + public function testAdminAdminsListQueryCountStaysBounded(int $rowCount): void + { + $this->loginAsAdmin(); + $this->bootRenderHarness(); + $this->seedExtraAdmins($rowCount); + + $_GET = ['p' => 'admin', 'c' => 'admins', 'section' => 'admins']; + $_SESSION = $_SESSION ?? []; + + $this->assertQueryCountAtMost( + self::MAX_ADMINS_QUERIES, + fn () => $this->renderPage('pages/admin.admins.php'), + "admin admins list with {$rowCount} extra admins must not scale queries with row count", + ); + } + + #[DataProvider('rowCountProvider')] + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] + public function testAdminProtestsQueryCountStaysBounded(int $rowCount): void + { + $this->loginAsAdmin(); + $this->bootRenderHarness(); + $this->seedProtests($rowCount); + + $_GET = ['p' => 'admin', 'c' => 'bans', 'section' => 'protests']; + $_SESSION = $_SESSION ?? []; + + $this->assertQueryCountAtMost( + self::MAX_PROTESTS_QUERIES, + fn () => $this->renderPage('pages/admin.bans.php'), + "admin protests with {$rowCount} rows must not scale queries with row count", + ); + } + + #[DataProvider('rowCountProvider')] + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] + public function testAdminSubmissionsQueryCountStaysBounded(int $rowCount): void + { + $this->loginAsAdmin(); + $this->bootRenderHarness(); + $this->seedSubmissions($rowCount); + + $_GET = ['p' => 'admin', 'c' => 'bans', 'section' => 'submissions']; + $_SESSION = $_SESSION ?? []; + + $this->assertQueryCountAtMost( + self::MAX_SUBMISSIONS_QUERIES, + fn () => $this->renderPage('pages/admin.bans.php'), + "admin submissions with {$rowCount} rows must not scale queries with row count", + ); + } + + #[DataProvider('rowCountProvider')] + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] + public function testAdminGroupsListQueryCountStaysBounded(int $rowCount): void + { + $this->loginAsAdmin(); + $this->bootRenderHarness(); + $this->seedWebGroups($rowCount); + + $_GET = ['p' => 'admin', 'c' => 'groups', 'section' => 'list']; + $_SESSION = $_SESSION ?? []; + + $this->assertQueryCountAtMost( + self::MAX_GROUPS_QUERIES, + fn () => $this->renderPage('pages/admin.groups.php'), + "admin groups list with {$rowCount} extra groups must not scale queries with row count", + ); + } +} diff --git a/web/tests/integration/BanlistQueryCountTest.php b/web/tests/integration/BanlistQueryCountTest.php new file mode 100644 index 000000000..bd7d799b7 --- /dev/null +++ b/web/tests/integration/BanlistQueryCountTest.php @@ -0,0 +1,131 @@ +makeStubTheme(); + $GLOBALS['userbank'] = $GLOBALS['userbank'] ?? new \CUserManager(null); + $GLOBALS['username'] = $GLOBALS['username'] ?? 'tester'; + } + + private function renderBanlistPage(): void + { + ob_start(); + try { + require ROOT . 'pages/page.banlist.php'; + } finally { + ob_end_clean(); + } + } + + /** + * Seed $count active permanent bans with distinct authids/names + * so each row is a genuinely separate lookup target (not + * deduplicated by any cache keyed on a repeated value). + */ + private function seedBans(int $count): void + { + $pdo = Fixture::rawPdo(); + $now = time(); + $aid = Fixture::adminAid(); + $insert = $pdo->prepare(sprintf( + 'INSERT INTO `%s_bans` (type, ip, authid, name, created, ends, length, reason, ureason, aid, RemovedBy, RemovedOn, RemoveType) + VALUES (0, NULL, ?, ?, ?, 0, 0, ?, NULL, ?, NULL, NULL, NULL)', + DB_PREFIX, + )); + + for ($i = 0; $i < $count; $i++) { + $insert->execute([ + 'STEAM_0:1:' . (60000 + $i), + 'QueryCountFixture' . $i, + $now - $i, + 'reason ' . $i, + $aid, + ]); + } + } + + /** @return array */ + public static function rowCountProvider(): array + { + return [ + '3 bans' => [3], + '10 bans' => [10], + '25 bans' => [25], + ]; + } + + #[DataProvider('rowCountProvider')] + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] + public function testBanlistQueryCountStaysBoundedRegardlessOfRowCount(int $rowCount): void + { + $this->loginAsAdmin(); + $this->bootRenderHarness(); + $this->seedBans($rowCount); + + $_GET = ['p' => 'banlist']; + $_SESSION = $_SESSION ?? []; + + $this->assertQueryCountAtMost( + self::MAX_EXPECTED_QUERIES, + fn () => $this->renderBanlistPage(), + "banlist with {$rowCount} bans must not scale its query count with row count", + ); + } +} diff --git a/web/tests/integration/BanlistRemovedByConsoleTest.php b/web/tests/integration/BanlistRemovedByConsoleTest.php new file mode 100644 index 000000000..fc9ac6a6a --- /dev/null +++ b/web/tests/integration/BanlistRemovedByConsoleTest.php @@ -0,0 +1,155 @@ + */ + public array $captured = []; + + /** @phpstan-ignore method.childParameterType */ + public function assign($tpl_var, $value = null, $nocache = false, $scope = null) + { + if (is_string($tpl_var)) { + $this->captured[$tpl_var] = $value; + } + + return $this; + } + + public function display($template = null, $cache_id = null, $compile_id = null) + { + return ''; + } + }; + + $GLOBALS['theme'] = $theme; + $GLOBALS['userbank'] = $GLOBALS['userbank'] ?? new \CUserManager(null); + $GLOBALS['username'] = $GLOBALS['username'] ?? 'tester'; + + return $theme; + } + + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] + public function testBanlistResolvesConsoleRemovedByName(): void + { + $this->loginAsAdmin(); + $theme = $this->bootCapturingTheme(); + + $now = time(); + $pdo = Fixture::rawPdo(); + $pdo->prepare(sprintf( + 'INSERT INTO `%s_bans` (type, ip, authid, name, created, ends, length, reason, ureason, aid, RemovedBy, RemovedOn, RemoveType) + VALUES (0, NULL, ?, ?, ?, ?, ?, ?, NULL, ?, 0, ?, ?)', + DB_PREFIX, + ))->execute([ + 'STEAM_0:1:88001', + 'ConsoleRemovedByBan', + $now - 7200, + $now - 3600, + 3600, + 'expired for console removedby', + Fixture::adminAid(), + $now - 3600, + \BanRemoval::Expired->value, + ]); + + $_GET = ['p' => 'banlist', 'state' => 'expired']; + $_SESSION = $_SESSION ?? []; + + ob_start(); + try { + require ROOT . 'pages/page.banlist.php'; + } finally { + ob_end_clean(); + } + + $banList = $theme->captured['ban_list'] ?? null; + $this->assertIsArray($banList); + $this->assertNotSame([], $banList); + + $match = null; + foreach ($banList as $ban) { + if (($ban['steam'] ?? '') === 'STEAM_0:1:88001') { + $match = $ban; + break; + } + } + + $this->assertNotNull($match, 'expired ban with RemovedBy=0 must appear under ?state=expired'); + $this->assertSame('CONSOLE', $match['removedby'] ?? null); + } + + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] + public function testCommslistResolvesConsoleRemovedByName(): void + { + $this->loginAsAdmin(); + $theme = $this->bootCapturingTheme(); + + $now = time(); + $pdo = Fixture::rawPdo(); + $pdo->prepare(sprintf( + 'INSERT INTO `%s_comms` (type, authid, name, created, ends, length, reason, ureason, aid, RemovedBy, RemovedOn, RemoveType) + VALUES (1, ?, ?, ?, ?, ?, ?, NULL, ?, 0, ?, ?)', + DB_PREFIX, + ))->execute([ + 'STEAM_0:1:88002', + 'ConsoleRemovedByComm', + $now - 7200, + $now - 3600, + 3600, + 'expired for console removedby', + Fixture::adminAid(), + $now - 3600, + \BanRemoval::Expired->value, + ]); + + $_GET = ['p' => 'commslist']; + $_SESSION = $_SESSION ?? []; + unset($_SESSION['hideinactive']); + + ob_start(); + try { + require ROOT . 'pages/page.commslist.php'; + } finally { + ob_end_clean(); + } + + $banList = $theme->captured['ban_list'] ?? null; + $this->assertIsArray($banList); + $this->assertNotSame([], $banList); + + $match = null; + foreach ($banList as $comm) { + if (($comm['steam'] ?? '') === 'STEAM_0:1:88002') { + $match = $comm; + break; + } + } + + $this->assertNotNull($match, 'expired comm with RemovedBy=0 must appear under ?state=expired'); + $this->assertSame('CONSOLE', $match['removedby'] ?? null); + } +} diff --git a/web/tests/integration/CommslistQueryCountTest.php b/web/tests/integration/CommslistQueryCountTest.php new file mode 100644 index 000000000..0839dc059 --- /dev/null +++ b/web/tests/integration/CommslistQueryCountTest.php @@ -0,0 +1,128 @@ +makeStubTheme(); + $GLOBALS['userbank'] = $GLOBALS['userbank'] ?? new \CUserManager(null); + $GLOBALS['username'] = $GLOBALS['username'] ?? 'tester'; + } + + private function renderCommslistPage(): void + { + ob_start(); + try { + require ROOT . 'pages/page.commslist.php'; + } finally { + ob_end_clean(); + } + } + + /** + * Seed $count active permanent mute blocks with distinct authids/ + * names so each row is a genuinely separate lookup target. + */ + private function seedComms(int $count): void + { + $pdo = Fixture::rawPdo(); + $now = time(); + $aid = Fixture::adminAid(); + $insert = $pdo->prepare(sprintf( + 'INSERT INTO `%s_comms` (type, authid, name, created, ends, length, reason, ureason, aid, RemovedBy, RemovedOn, RemoveType) + VALUES (1, ?, ?, ?, 0, 0, ?, NULL, ?, NULL, NULL, NULL)', + DB_PREFIX, + )); + + for ($i = 0; $i < $count; $i++) { + $insert->execute([ + 'STEAM_0:1:' . (61000 + $i), + 'QueryCountCommFixture' . $i, + $now - $i, + 'reason ' . $i, + $aid, + ]); + } + } + + /** @return array */ + public static function rowCountProvider(): array + { + return [ + '3 comms' => [3], + '10 comms' => [10], + '25 comms' => [25], + ]; + } + + #[DataProvider('rowCountProvider')] + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] + public function testCommslistQueryCountStaysBoundedRegardlessOfRowCount(int $rowCount): void + { + $this->loginAsAdmin(); + $this->bootRenderHarness(); + $this->seedComms($rowCount); + + $_GET = ['p' => 'commslist']; + $_SESSION = $_SESSION ?? []; + + $this->assertQueryCountAtMost( + self::MAX_EXPECTED_QUERIES, + fn () => $this->renderCommslistPage(), + "commslist with {$rowCount} comms must not scale its query count with row count", + ); + } +} diff --git a/web/tests/integration/DockerIgnoreSecretsTest.php b/web/tests/integration/DockerIgnoreSecretsTest.php new file mode 100644 index 000000000..3791528b9 --- /dev/null +++ b/web/tests/integration/DockerIgnoreSecretsTest.php @@ -0,0 +1,43 @@ +assertNotEmpty($contents, '.dockerignore must be readable.'); + + foreach ([ + 'web/config.php', + 'web/config.php.*', + '!web/config.php.template', + '.env', + '.env.*', + '**/.env', + '**/.env.*', + ] as $line) { + $this->assertMatchesRegularExpression( + '/^' . preg_quote($line, '/') . '\s*$/m', + $contents, + ".dockerignore must include the line `{$line}`", + ); + } + } +} diff --git a/web/tests/integration/ProdApacheConfigTest.php b/web/tests/integration/ProdApacheConfigTest.php index fcc497c03..03c86812b 100644 --- a/web/tests/integration/ProdApacheConfigTest.php +++ b/web/tests/integration/ProdApacheConfigTest.php @@ -60,9 +60,9 @@ * regex + asset basename pair named in the message. * 2. {@see testFilesExactNamesDoNotShadowPublishedBrowserAssets} * — sister gate for the `` shape. The conf - * currently uses one (``); a future - * contributor adding `` directly would - * bypass the FilesMatch gate but hit this one. + * may use zero or more exact-name denies; a future contributor + * adding `` would bypass the FilesMatch + * gate but hit this one. * 3. {@see testHistoricalApiContractDenyPatternStaysOut} — * forward-looking spot-check pinning the literal substring * `api-contract` doesn't reappear inside any `` / @@ -111,9 +111,9 @@ private static function readFilesMatchRegexes(): array /** * Pull every `` block (literal basename match, - * not regex). The conf currently uses one — `` - * — but a future addition that lands an exact-name deny on a - * published asset basename would be the same bug class. + * not regex). Exact-name denies are optional (config.php backups + * ride a ``); this still catches a future contributor + * who lands `` directly. * * @return list */ @@ -238,9 +238,8 @@ public function testFilesMatchRegexesDoNotShadowPublishedBrowserAssets(): void } /** - * Sister gate to (1) for the `` shape. The - * conf today only carries ``; this test - * catches a future contributor who lands `` + * Sister gate to (1) for the `` shape. Catches + * a future contributor who lands `` * directly (which would bypass the regex-based gate above). */ public function testFilesExactNamesDoNotShadowPublishedBrowserAssets(): void @@ -324,6 +323,10 @@ public function testIntendedDenyTargetsStillMatch(): void 'phpunit.xml.dist', 'package.json', 'tsconfig.json', + 'config.php', + 'config.php.bak', + 'config.php.old', + 'config.php.template', ]; $missing = []; diff --git a/web/tests/integration/UserManagerGetAllAdminsQueryCountTest.php b/web/tests/integration/UserManagerGetAllAdminsQueryCountTest.php new file mode 100644 index 000000000..cb8ff18cb --- /dev/null +++ b/web/tests/integration/UserManagerGetAllAdminsQueryCountTest.php @@ -0,0 +1,104 @@ +prepare(sprintf( + 'INSERT INTO `%s_admins` (user, authid, password, gid, email, validate, extraflags, immunity) + VALUES (?, ?, ?, -1, ?, NULL, ?, 0)', + DB_PREFIX, + )); + + for ($i = 0; $i < $count; $i++) { + $stmt->execute([ + 'GetAllAdminsFixture' . bin2hex(random_bytes(4)), + 'STEAM_0:1:' . (70000 + $i), + $hash, + 'getalladmins' . $i . bin2hex(random_bytes(4)) . '@example.test', + 0, + ]); + } + } + + public function testGetAllAdminsQueryCountDoesNotScaleWithAdminCount(): void + { + $this->seedAdmins(2); + + $manager = new UserManager(null); + + $result = $this->assertQueryCountDelta( + 0, + function () use ($manager): void { + $manager->GetAllAdmins(); + }, + function () use ($manager): void { + $this->seedAdmins(15); + $manager->GetAllAdmins(); + }, + 'GetAllAdmins() must not scale its query count with admin count', + ); + + $this->assertSame( + 1, + $result['baseline'], + 'GetAllAdmins() should issue exactly one query for the base admin set', + ); + $this->assertSame( + 1, + $result['grown'], + 'GetAllAdmins() should issue exactly one query even after seeding more admins', + ); + } + + public function testGetAllAdminsReturnsSameShapeAsGetUserArray(): void + { + $manager = new UserManager(null); + $expected = $manager->GetUserArray(Fixture::adminAid()); + $this->assertIsArray($expected); + + $all = $manager->GetAllAdmins(); + + $this->assertArrayHasKey(Fixture::adminAid(), $all); + $this->assertSame($expected, $all[Fixture::adminAid()]); + } + + public function testGetAllAdminsExcludesConsoleAidZero(): void + { + $pdo = Fixture::rawPdo(); + $consoleExists = (int) $pdo->query(sprintf( + 'SELECT COUNT(*) FROM `%s_admins` WHERE aid = 0', + DB_PREFIX, + ))->fetchColumn(); + $this->assertSame(1, $consoleExists, 'fixture must seed the CONSOLE aid 0 row'); + + $all = (new UserManager(null))->GetAllAdmins(); + + $this->assertArrayNotHasKey(0, $all); + $this->assertArrayHasKey(Fixture::adminAid(), $all); + } +} diff --git a/web/tests/unit/JwtSecretKeyTest.php b/web/tests/unit/JwtSecretKeyTest.php new file mode 100644 index 000000000..3934a64a7 --- /dev/null +++ b/web/tests/unit/JwtSecretKeyTest.php @@ -0,0 +1,116 @@ +assertInstanceOf(InMemory::class, $key); + $this->assertGreaterThanOrEqual(JWT::MIN_SECRET_BYTES, strlen($key->contents())); + } + + public function testExactlyMinSecretBytesIsAccepted(): void + { + $secret = base64_encode(random_bytes(JWT::MIN_SECRET_BYTES)); + $key = JWT::signingKeyFromSecret($secret); + + $this->assertSame(JWT::MIN_SECRET_BYTES, strlen($key->contents())); + } + + /** + * @return list + */ + public static function rejectedSecretProvider(): array + { + return [ + 'non-base64' => ['not-valid-base64!!!'], + 'short valid base64 (16 bytes)' => [base64_encode(random_bytes(16))], + 'one byte under minimum' => [base64_encode(random_bytes(JWT::MIN_SECRET_BYTES - 1))], + ]; + } + + #[DataProvider('rejectedSecretProvider')] + public function testRejectedSecretAbortsWithOperatorMessage(string $secret): void + { + // failInvalidSecretKey uses exit(1) on the CLI SAPI, which + // would kill the PHPUnit worker. Drive it in a subprocess so + // the abort is observable. + $autoload = dirname(__DIR__, 2) . '/includes/vendor/autoload.php'; + $jwtFile = dirname(__DIR__, 2) . '/includes/Auth/JWT.php'; + + $script = <<<'PHP' +require $argv[1]; +require $argv[2]; +\Sbpp\Auth\JWT::signingKeyFromSecret($argv[3]); +fwrite(STDERR, "EXPECTED_ABORT_MISSING\n"); +exit(2); +PHP; + + $cmd = [ + PHP_BINARY, + '-r', + $script, + '--', + $autoload, + $jwtFile, + $secret, + ]; + + $descriptors = [ + 0 => ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ]; + $proc = proc_open($cmd, $descriptors, $pipes); + $this->assertIsResource($proc); + + fclose($pipes[0]); + $stdout = stream_get_contents($pipes[1]); + $stderr = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + $exitCode = proc_close($proc); + + $combined = $stdout . "\n" . $stderr; + + $this->assertSame( + 1, + $exitCode, + "rejected SB_SECRET_KEY must exit 1; output was:\n{$combined}", + ); + $this->assertStringContainsString( + 'SB_SECRET_KEY must be base64 that decodes to at least ' . JWT::MIN_SECRET_BYTES . ' bytes', + $combined, + "subprocess output was:\n{$combined}", + ); + $this->assertStringContainsString( + 'openssl rand -base64 47', + $combined, + "subprocess output was:\n{$combined}", + ); + $this->assertStringNotContainsString( + 'EXPECTED_ABORT_MISSING', + $combined, + ); + } +}