diff --git a/AGENTS.md b/AGENTS.md index 26a533a45..a40d4bde4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -494,6 +494,21 @@ top-level `class Foo {}` in `web/includes/` (see "Anti-patterns"). `Database::query()` rewrites the placeholder. Never inline the prefix. - Pattern: `query` → `bind` → `execute` / `single` / `resultset`. - ADOdb was fully removed (commit `b9c812b2`). **Do not reintroduce it.** +- Row-derived `IN (...)` lists go through + `Database::resultsetInList()` / `executeInList()`. Both methods split + values into 10,000-item statements so native MySQL / MariaDB prepares + stay below the 65,535-placeholder ceiling. Do not build an unbounded + placeholder string with `array_fill(count($rows), '?')`: an install + with 75,000 bans fails during `PDO::prepare()` with error 1390 before + any values are bound. Compile-time constant lists (for example the + export subsystem's short forbidden-setting-key list) may stay inline. + Chunked SELECT ordering is only per statement; regroup returned rows + by key instead of relying on one globally ordered result. The helper + accepts only list-shaped `PDO::FETCH_ASSOC` / `PDO::FETCH_COLUMN` + results; keyed modes cannot be merged safely across chunks. Pass + `atomic: true` to `executeInList()` when splitting a formerly single + write must preserve all-or-nothing behavior; the helper owns the + transaction in that mode, so callers must not open a nested one. - Each named placeholder (`:name`) inside one query needs as many `bind()` calls as occurrences. The panel runs PDO with `PDO::ATTR_EMULATE_PREPARES => false` (`Sbpp\Db\Database::__construct` diff --git a/web/includes/Db/Database.php b/web/includes/Db/Database.php index a091c754b..511dce1c2 100644 --- a/web/includes/Db/Database.php +++ b/web/includes/Db/Database.php @@ -12,6 +12,16 @@ */ final class Database { + public const MAX_PREPARED_STATEMENT_PLACEHOLDERS = 65_535; + + /** + * Keep generated IN-list statements comfortably below MySQL / + * MariaDB's 65,535-placeholder prepared-statement ceiling. Fixed + * parameters that appear before or after the list also count toward + * that ceiling, so callers must not build the list themselves. + */ + public const IN_LIST_CHUNK_SIZE = 10_000; + private readonly string $prefix; private PDO $dbh; @@ -160,6 +170,140 @@ public function single(?array $inputParams = null, int $fetchType = PDO::FETCH_A return $this->stmt->fetch($fetchType); } + /** + * Execute a SELECT once per bounded slice of an IN-list and merge + * the rows. Ordering is guaranteed within each slice only; callers + * should consume the result as a set or regroup it by key. + * + * `$sqlBeforeValues` must end immediately before the first generated + * placeholder and `$sqlAfterValues` must begin immediately after the + * last one. For example: + * + * $db->resultsetInList( + * 'SELECT aid, user FROM `:prefix_admins` WHERE aid IN (', + * $aids, + * ')', + * ); + * + * @param list $values + * @param list $paramsBefore + * @param list $paramsAfter + * @return list + * @throws \InvalidArgumentException When a keyed PDO fetch mode is requested. + */ + public function resultsetInList( + string $sqlBeforeValues, + array $values, + string $sqlAfterValues = '', + array $paramsBefore = [], + array $paramsAfter = [], + int $fetchType = PDO::FETCH_ASSOC, + ): array { + if (!in_array($fetchType, [PDO::FETCH_ASSOC, PDO::FETCH_COLUMN], true)) { + throw new \InvalidArgumentException( + 'Chunked IN-list SELECTs support only PDO::FETCH_ASSOC and PDO::FETCH_COLUMN.' + ); + } + + $rows = []; + foreach ($this->inListChunks($values, count($paramsBefore) + count($paramsAfter)) as $chunk) { + $placeholders = implode(',', array_fill(0, count($chunk), '?')); + $chunkRows = $this + ->query($sqlBeforeValues . $placeholders . $sqlAfterValues) + ->resultset([...$paramsBefore, ...$chunk, ...$paramsAfter], $fetchType); + array_push($rows, ...$chunkRows); + } + + return $rows; + } + + /** + * Execute a write once per bounded slice of an IN-list. + * + * Pass `$atomic = true` when every chunk must commit or roll back as + * one operation. The caller must not already have a transaction open + * in that mode because PDO does not support nested transactions. + * + * @param list $values + * @param list $paramsBefore + * @param list $paramsAfter + */ + public function executeInList( + string $sqlBeforeValues, + array $values, + string $sqlAfterValues = '', + array $paramsBefore = [], + array $paramsAfter = [], + bool $atomic = false, + ): int { + $chunks = $this->inListChunks($values, count($paramsBefore) + count($paramsAfter)); + if ($chunks === []) { + return 0; + } + + $affected = 0; + $transactionOpen = false; + if ($atomic) { + if ($this->dbh->inTransaction()) { + throw new \LogicException('Atomic IN-list execution cannot start inside an existing transaction.'); + } + $this->beginTransaction(); + $transactionOpen = true; + } + try { + foreach ($chunks as $chunk) { + $placeholders = implode(',', array_fill(0, count($chunk), '?')); + $this + ->query($sqlBeforeValues . $placeholders . $sqlAfterValues) + ->execute([...$paramsBefore, ...$chunk, ...$paramsAfter]); + $affected += $this->rowCount(); + } + if ($atomic) { + $this->endTransaction(); + $transactionOpen = false; + } + } catch (\Throwable $e) { + if ($transactionOpen) { + $this->cancelTransaction(); + } + throw $e; + } + + return $affected; + } + + /** + * @param list $values + * @return list> + */ + private function inListChunks(array $values, int $reservedPlaceholders): array + { + if ($values === []) { + return []; + } + + $available = self::MAX_PREPARED_STATEMENT_PLACEHOLDERS - $reservedPlaceholders; + if ($available < 1) { + throw new \InvalidArgumentException('IN-list query has no placeholder capacity left after fixed parameters.'); + } + + $unique = []; + $seen = []; + foreach ($values as $value) { + // MariaDB compares 5 and '5' as equal, so dedupe on the same + // terms: otherwise both could land in different chunks and + // return the same row twice. + $key = serialize(is_int($value) ? (string) $value : $value); + if (isset($seen[$key])) { + continue; + } + $seen[$key] = true; + $unique[] = $value; + } + + return array_chunk($unique, min(self::IN_LIST_CHUNK_SIZE, $available)); + } + /** * Yields rows one at a time so callers can stream large result sets * without materialising the full set in PHP memory like resultset() does. diff --git a/web/includes/system-functions.php b/web/includes/system-functions.php index 858789c01..24d0e0648 100644 --- a/web/includes/system-functions.php +++ b/web/includes/system-functions.php @@ -323,47 +323,52 @@ function PruneBans(): void $pdo->bind(':id', $adminId); $pdo->execute(); - // Two single-column SELECTs are intentionally separate from the - // composite UPDATE below: `UPDATE … WHERE` locks every row it - // examines for the predicate, not just the rows it changes. We - // surface the candidate `subid`s with a SELECT first so the - // UPDATE only locks rows it'll mutate. - $steamIds = $pdo - ->query('SELECT DISTINCT authid FROM `:prefix_bans` WHERE `type` = 0 AND `RemoveType` IS NULL') - ->resultset(null, PDO::FETCH_COLUMN); - $banIps = $pdo - ->query('SELECT ip FROM `:prefix_bans` WHERE type = 1 AND RemoveType IS NULL') - ->resultset(null, PDO::FETCH_COLUMN); - - if ($steamIds === [] && $banIps === []) { - return; - } - - $clauses = []; - $args = []; - if ($steamIds !== []) { - $clauses[] = 'SteamId IN (' . implode(',', array_fill(0, count($steamIds), '?')) . ')'; - array_push($args, ...$steamIds); - } - if ($banIps !== []) { - $clauses[] = 'sip IN (' . implode(',', array_fill(0, count($banIps), '?')) . ')'; - array_push($args, ...$banIps); - } - + // Keep the candidate lookup read-only: a composite UPDATE would + // lock every submissions row examined, not just rows it changes. + // Two set-based arms avoid materialising every active ban identifier + // as one prepared-statement IN-list (MariaDB rejects statements above + // 65,535 placeholders). UNION DISTINCT de-duplicates a submission + // that happens to match both its Steam ID and IP. Keeping the arms + // separate lets MariaDB probe type_authid / type_ip directly for each + // submission instead of materialising all active identifiers first. + // No FORCE INDEX: the (type, authid) / (type, ip) equality join picks + // those indexes on its own, and a hint would turn an install missing + // either index (e.g. a half-applied updater 702) into error 1176 on + // every banlist render, ban add/edit and GET /api/v1/bans. $subIds = $pdo - ->query('SELECT `subid` FROM `:prefix_submissions` WHERE `archiv` = 0 AND (' . implode(' OR ', $clauses) . ')') - ->resultset($args, PDO::FETCH_COLUMN); + ->query( + 'SELECT S.`subid` + FROM `:prefix_submissions` AS S + INNER JOIN `:prefix_bans` AS BSteam + ON BSteam.`type` = 0 + AND BSteam.`authid` = S.`SteamId` + AND BSteam.`RemoveType` IS NULL + WHERE S.`archiv` = 0 + UNION DISTINCT + SELECT S.`subid` + FROM `:prefix_submissions` AS S + INNER JOIN `:prefix_bans` AS BIp + ON BIp.`type` = 1 + AND BIp.`ip` = S.`sip` + AND BIp.`RemoveType` IS NULL + WHERE S.`archiv` = 0' + ) + ->resultset(null, PDO::FETCH_COLUMN); if ($subIds === []) { return; } - $pdo - ->query('UPDATE `:prefix_submissions` - SET `archiv` = 3, - `archivedby` = ? - WHERE `subid` IN (' . implode(',', array_fill(0, count($subIds), '?')) . ')') - ->execute([$adminId, ...$subIds]); + $pdo->executeInList( + 'UPDATE `:prefix_submissions` + SET `archiv` = 3, + `archivedby` = ? + WHERE `subid` IN (', + $subIds, + ')', + [$adminId], + atomic: true, + ); } /** diff --git a/web/pages/admin.admins.php b/web/pages/admin.admins.php index 585e527ae..0b2abd417 100644 --- a/web/pages/admin.admins.php +++ b/web/pages/admin.admins.php @@ -170,6 +170,8 @@ $whereParams = []; $joinAdminsServersGroups = false; $joinServersGroups = false; +$joinWebGroups = false; +$joinSrvAdminGroups = false; /** @var array> $activeFilters */ $activeFilters = []; @@ -256,8 +258,11 @@ // 7) Web permission flags (multi). Submitted as either `admwebflag[]=X&admwebflag[]=Y` // or the legacy comma-joined string. Resolve each name to its bit and -// OR-combine into a single bitmask, then narrow ADM.aid to admins with -// access — same per-admin permission probe the legacy code used. +// OR-combine into a single bitmask. The SQL predicate mirrors +// UserManager::HasAccess(): direct extraflags OR inherited web-group +// flags, with any requested bit counting as a match. Unlike HasAccess() +// it ignores `enabled`: the ?view= filter below already scopes the list, +// so ?view=inactive&admwebflag[]=X lists deactivated admins holding X. $rawWebFlags = $_GET['admwebflag'] ?? null; if (is_string($rawWebFlags)) { $rawWebFlags = explode(',', $rawWebFlags); @@ -272,28 +277,18 @@ } if (!empty($webFlagNames)) { $flagBits = array_map(fn(string $name): int => (int) constant($name), $webFlagNames); - $flagstring = implode('|', $flagBits); - $alladmins = $GLOBALS['PDO']->query("SELECT aid FROM `:prefix_admins` WHERE aid > 0")->resultset(); - $accessAids = []; - foreach ($alladmins as $row) { - if ($userbank->HasAccess($flagstring, $row['aid'])) { - $accessAids[] = (int) $row['aid']; - } - } - if (empty($accessAids)) { - $where .= " AND 0"; - } else { - $placeholders = implode(',', array_fill(0, count($accessAids), '?')); - $where .= " AND ADM.aid IN($placeholders)"; - $whereParams = array_merge($whereParams, $accessAids); - } + $flagMask = array_reduce($flagBits, static fn (int $mask, int $bit): int => $mask | $bit, 0); + $joinWebGroups = true; + $where .= ' AND (((ADM.extraflags | COALESCE(WG.flags, 0)) & ?) <> 0)'; + $whereParams[] = $flagMask; $activeFilters['admwebflag'] = $webFlagNames; } } // 8) Server permission flags (multi). SM_* constants are single-char -// strings (`SM_ROOT` = `z`); pass them to HasAccess as strings so the -// srv_flags path runs. SM_ROOT implies every other server flag. +// strings (`SM_ROOT` = `z`). Match against the concatenated direct + +// inherited group flags, mirroring UserManager::HasAccess(). SM_ROOT +// implies every requested server flag. $rawSrvFlags = $_GET['admsrvflag'] ?? null; if (is_string($rawSrvFlags)) { $rawSrvFlags = explode(',', $rawSrvFlags); @@ -309,31 +304,18 @@ if (!empty($srvFlagNames)) { /** @var list $flagChars */ $flagChars = array_map(fn(string $name): string => (string) constant($name), $srvFlagNames); - $alladmins = $GLOBALS['PDO']->query("SELECT aid FROM `:prefix_admins` WHERE aid > 0")->resultset(); - $accessAids = []; - foreach ($alladmins as $row) { - $aid = (int) $row['aid']; - $matched = false; - foreach ($flagChars as $fla) { - if ($userbank->HasAccess($fla, $aid)) { - $matched = true; - break; - } - } - if (!$matched && $userbank->HasAccess(SM_ROOT, $aid)) { - $matched = true; - } - if ($matched) { - $accessAids[] = $aid; - } - } - if (empty($accessAids)) { - $where .= " AND 0"; - } else { - $placeholders = implode(',', array_fill(0, count($accessAids), '?')); - $where .= " AND ADM.aid IN($placeholders)"; - $whereParams = array_merge($whereParams, $accessAids); + $flagChars[] = SM_ROOT; + $flagChars = array_values(array_unique($flagChars)); + $joinSrvAdminGroups = true; + $serverFlagClauses = []; + foreach ($flagChars as $flagChar) { + // SourceMod flags are case-sensitive in UserManager::HasAccess(). + // BINARY prevents the table's case-insensitive collation from + // treating `A` as the lower-case `a` reserved-slot flag. + $serverFlagClauses[] = "INSTR(BINARY CONCAT(COALESCE(ADM.srv_flags, ''), COALESCE(SAG.flags, '')), BINARY ?) > 0"; + $whereParams[] = $flagChar; } + $where .= ' AND (' . implode(' OR ', $serverFlagClauses) . ')'; $activeFilters['admsrvflag'] = $srvFlagNames; } } @@ -358,6 +340,12 @@ if ($joinServersGroups) { $join .= " LEFT JOIN `:prefix_servers_groups` AS SGS ON SGS.group_id = ASG.srv_group_id"; } +if ($joinWebGroups) { + $join .= " LEFT JOIN `:prefix_groups` AS WG ON WG.gid = ADM.gid"; +} +if ($joinSrvAdminGroups) { + $join .= " LEFT JOIN `:prefix_srvgroups` AS SAG ON SAG.name = ADM.srv_group"; +} // Soft-retire filter (#1509): default to active admins only. $view = (string) ($_GET['view'] ?? 'active'); @@ -380,30 +368,20 @@ // natively, so multi-select filters round-trip without manual joining. $advSearchString = empty($activeFilters) ? '' : '&' . http_build_query($activeFilters); $viewLink = $view === 'active' ? '' : '&view=' . rawurlencode($view); +$offset = (int) (($page - 1) * $AdminsPerPage); +// DISTINCT runs before LIMIT so joins (server, server-group, web-group, +// SourceMod-group) cannot consume page slots with duplicate admins — +// replaces the old dedupe-after-the-fact loop, which only ran for the +// `server` filter and left the LIMIT window short a row whenever it +// removed a duplicate. The count uses the same one-row-per-aid contract. $admins = $GLOBALS['PDO']->query( - "SELECT * FROM `:prefix_admins` AS ADM" . $join + "SELECT DISTINCT ADM.* FROM `:prefix_admins` AS ADM" . $join . " WHERE ADM.aid > 0" . $enabledWhere . $where - . " ORDER BY user LIMIT " . (int) (($page - 1) * $AdminsPerPage) . "," . (int) $AdminsPerPage + . " ORDER BY ADM.user LIMIT " . $offset . "," . (int) $AdminsPerPage )->resultset($whereParams); -// The server filter joins through `:prefix_admins_servers_groups` and -// `:prefix_servers_groups`, which can produce duplicate ADM.aid rows -// when an admin reaches the same server via multiple paths. Dedupe -// here to keep the rendered list one-row-per-admin. -if (isset($activeFilters['server'])) { - $aadm = []; - $num = 0; - foreach ($admins as $aadmin) { - if (!in_array($aadmin['aid'], $aadm)) { - $aadm[] = $aadmin['aid']; - } else { - unset($admins[$num]); - } - $num++; - } -} $query = $GLOBALS['PDO']->query( - "SELECT COUNT(ADM.aid) AS cnt FROM `:prefix_admins` AS ADM" . $join + "SELECT COUNT(DISTINCT ADM.aid) AS cnt FROM `:prefix_admins` AS ADM" . $join . " WHERE ADM.aid > 0" . $enabledWhere . $where )->single($whereParams); $admin_count = $query['cnt']; @@ -424,17 +402,20 @@ $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); + $banCountRows = $GLOBALS['PDO']->resultsetInList( + 'SELECT aid, count(authid) AS num FROM `:prefix_bans` WHERE aid IN (', + $adminAids, + ') GROUP BY aid', + ); 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); + $nodemoCountRows = $GLOBALS['PDO']->resultsetInList( + 'SELECT B.aid AS aid, count(B.bid) AS num FROM `:prefix_bans` AS B WHERE B.aid IN (', + $adminAids, + ') AND NOT EXISTS (SELECT D.demid FROM `:prefix_demos` AS D WHERE D.demid = B.bid) GROUP BY B.aid', + ); foreach ($nodemoCountRows as $nodemoCountRow) { $nodemoCountByAid[(int) $nodemoCountRow['aid']] = (int) $nodemoCountRow['num']; } diff --git a/web/pages/admin.bans.php b/web/pages/admin.bans.php index 017d75f66..0b9b72383 100644 --- a/web/pages/admin.bans.php +++ b/web/pages/admin.bans.php @@ -570,14 +570,15 @@ function ProcessBan() $protestBids = array_map(static fn ($p) => (int) $p['bid'], $protests); $protestBanDetailsByBid = []; if ($protestBids !== []) { - $placeholders = implode(',', array_fill(0, count($protestBids), '?')); - $banRows = $GLOBALS['PDO']->query( + $banRows = $GLOBALS['PDO']->resultsetInList( "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); + WHERE bid IN (", + $protestBids, + ')', + ); foreach ($banRows as $banRow) { $protestBanDetailsByBid[(int) $banRow['bid']] = $banRow; } @@ -586,14 +587,15 @@ function ProcessBan() $protestPids = array_map(static fn ($p) => (int) $p['pid'], $protests); $protestCommentsByPid = []; if ($protestPids !== []) { - $placeholders = implode(',', array_fill(0, count($protestPids), '?')); - $cRows = $GLOBALS['PDO']->query( + $cRows = $GLOBALS['PDO']->resultsetInList( "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); + WHERE type = 'P' AND bid IN (", + $protestPids, + ') ORDER BY added desc', + ); foreach ($cRows as $cRow) { $protestCommentsByPid[(int) $cRow['bid']][] = $cRow; } @@ -625,8 +627,11 @@ function ProcessBan() } if (count($delete) > 0) { $cnt = count($delete); - $placeholders = implode(',', array_fill(0, $cnt, '?')); - $GLOBALS['PDO']->query("UPDATE `:prefix_protests` SET archiv = '2' WHERE bid IN($placeholders) LIMIT $cnt")->execute($delete); + $GLOBALS['PDO']->executeInList( + "UPDATE `:prefix_protests` SET archiv = '2' WHERE bid IN(", + $delete, + ") LIMIT $cnt", + ); } \Sbpp\View\Renderer::render($theme, new \Sbpp\View\AdminBansProtestsView( @@ -685,14 +690,15 @@ function ProcessBan() } $protestArchivBanDetailsByBid = []; if ($protestArchivBids !== []) { - $placeholders = implode(',', array_fill(0, count($protestArchivBids), '?')); - $banRows = $GLOBALS['PDO']->query( + $banRows = $GLOBALS['PDO']->resultsetInList( "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); + WHERE bid IN (", + $protestArchivBids, + ')', + ); foreach ($banRows as $banRow) { $protestArchivBanDetailsByBid[(int) $banRow['bid']] = $banRow; } @@ -701,14 +707,15 @@ function ProcessBan() $protestArchivPids = array_map(static fn ($p) => (int) $p['pid'], $protestsarchiv); $protestArchivCommentsByPid = []; if ($protestArchivPids !== []) { - $placeholders = implode(',', array_fill(0, count($protestArchivPids), '?')); - $cRows = $GLOBALS['PDO']->query( + $cRows = $GLOBALS['PDO']->resultsetInList( "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); + WHERE type = 'P' AND bid IN (", + $protestArchivPids, + ') ORDER BY added desc', + ); foreach ($cRows as $cRow) { $protestArchivCommentsByPid[(int) $cRow['bid']][] = $cRow; } @@ -751,9 +758,11 @@ function ProcessBan() 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); + $GLOBALS['PDO']->executeInList( + "UPDATE `:prefix_protests` SET archiv = '2' WHERE pid IN (", + $protestArchivToMarkDeleted, + ')', + ); } \Sbpp\View\Renderer::render($theme, new \Sbpp\View\AdminBansProtestsArchivView( @@ -834,10 +843,11 @@ function ProcessBan() $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); + $demRows = $GLOBALS['PDO']->resultsetInList( + "SELECT demid, filename FROM `:prefix_demos` WHERE demtype = 'S' AND demid IN (", + $submissionSubids, + ')', + ); foreach ($demRows as $demRow) { $submissionDemoFilenameBySubid[(int) $demRow['demid']] = $demRow['filename']; } @@ -849,11 +859,12 @@ function ProcessBan() } $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); + $modIds = array_keys($submissionModIds); + $modRows = $GLOBALS['PDO']->resultsetInList( + 'SELECT mid, name FROM `:prefix_mods` WHERE mid IN (', + $modIds, + ')', + ); foreach ($modRows as $modRow) { $submissionModNameById[(int) $modRow['mid']] = $modRow['name']; } @@ -861,14 +872,15 @@ function ProcessBan() $submissionCommentsBySubid = []; if ($submissionSubids !== []) { - $placeholders = implode(',', array_fill(0, count($submissionSubids), '?')); - $cRows = $GLOBALS['PDO']->query( + $cRows = $GLOBALS['PDO']->resultsetInList( "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); + WHERE type = 'S' AND bid IN (", + $submissionSubids, + ') ORDER BY added desc', + ); foreach ($cRows as $cRow) { $submissionCommentsBySubid[(int) $cRow['bid']][] = $cRow; } @@ -945,10 +957,11 @@ function ProcessBan() $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); + $demRows = $GLOBALS['PDO']->resultsetInList( + "SELECT demid, filename FROM `:prefix_demos` WHERE demtype = 'S' AND demid IN (", + $submissionArchivSubids, + ')', + ); foreach ($demRows as $demRow) { $submissionArchivDemoFilenameBySubid[(int) $demRow['demid']] = $demRow['filename']; } @@ -960,11 +973,12 @@ function ProcessBan() } $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); + $modIds = array_keys($submissionArchivModIds); + $modRows = $GLOBALS['PDO']->resultsetInList( + 'SELECT mid, name FROM `:prefix_mods` WHERE mid IN (', + $modIds, + ')', + ); foreach ($modRows as $modRow) { $submissionArchivModNameById[(int) $modRow['mid']] = $modRow['name']; } @@ -972,14 +986,15 @@ function ProcessBan() $submissionArchivCommentsBySubid = []; if ($submissionArchivSubids !== []) { - $placeholders = implode(',', array_fill(0, count($submissionArchivSubids), '?')); - $cRows = $GLOBALS['PDO']->query( + $cRows = $GLOBALS['PDO']->resultsetInList( "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); + WHERE type = 'S' AND bid IN (", + $submissionArchivSubids, + ') ORDER BY added desc', + ); foreach ($cRows as $cRow) { $submissionArchivCommentsBySubid[(int) $cRow['bid']][] = $cRow; } diff --git a/web/pages/admin.groups.php b/web/pages/admin.groups.php index 696cfd825..1637d9bf6 100644 --- a/web/pages/admin.groups.php +++ b/web/pages/admin.groups.php @@ -55,10 +55,11 @@ $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); + $memberRows = $GLOBALS['PDO']->resultsetInList( + 'SELECT aid, user, authid, gid FROM `:prefix_admins` WHERE gid IN (', + $webGroupIds, + ')', + ); foreach ($memberRows as $memberRow) { $webGroupMembersByGid[(int) $memberRow['gid']][] = $memberRow; } @@ -89,10 +90,11 @@ $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); + $memberRows = $GLOBALS['PDO']->resultsetInList( + 'SELECT aid, user, authid, srv_group FROM `:prefix_admins` WHERE srv_group IN (', + $srvGroupNames, + ')', + ); foreach ($memberRows as $memberRow) { $srvGroupMembersByName[$memberRow['srv_group']][] = $memberRow; } @@ -101,10 +103,11 @@ $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); + $overrideRows = $GLOBALS['PDO']->resultsetInList( + 'SELECT type, name, access, group_id FROM `:prefix_srvgroups_overrides` WHERE group_id IN (', + $srvGroupIds, + ')', + ); foreach ($overrideRows as $overrideRow) { $srvGroupOverridesByGroupId[(int) $overrideRow['group_id']][] = $overrideRow; } @@ -154,14 +157,14 @@ $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 + $groupServerRows = $GLOBALS['PDO']->resultsetInList( + '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); + WHERE SG.group_id IN (', + $serverGroupIds, + ') ORDER BY S.sid ASC', + ); foreach ($groupServerRows as $groupServerRow) { $serverRowsByGroupId[(int) $groupServerRow['group_id']][] = $groupServerRow; } diff --git a/web/pages/page.banlist.php b/web/pages/page.banlist.php index 681722c65..52d8e3900 100644 --- a/web/pages/page.banlist.php +++ b/web/pages/page.banlist.php @@ -783,11 +783,12 @@ function setPostKey() } $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); + $ids = array_keys($removedByAdminIds); + $adminRows = $GLOBALS['PDO']->resultsetInList( + 'SELECT aid, user FROM `:prefix_admins` WHERE aid IN (', + $ids, + ')', + ); foreach ($adminRows as $adminRow) { $removedByNames[(int) $adminRow['aid']] = $adminRow['user']; } @@ -810,21 +811,23 @@ function setPostKey() } } 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); + $ids = array_keys($steamAuthidsToCheck); + $countRows = $GLOBALS['PDO']->resultsetInList( + 'SELECT authid, COUNT(bid) as cnt FROM `:prefix_bans` WHERE authid IN (', + $ids, + ") AND (length = 0 OR ends > UNIX_TIMESTAMP()) AND RemovedBy IS NULL AND type = '0' GROUP BY authid", + ); 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); + $ids = array_keys($ipsToCheck); + $countRows = $GLOBALS['PDO']->resultsetInList( + 'SELECT ip, COUNT(bid) as cnt FROM `:prefix_bans` WHERE ip IN (', + $ids, + ") AND (length = 0 OR ends > UNIX_TIMESTAMP()) AND RemovedBy IS NULL AND type = '1' GROUP BY ip", + ); foreach ($countRows as $countRow) { $activeIpCounts[$countRow['ip']] = (int) $countRow['cnt']; } @@ -832,10 +835,11 @@ function setPostKey() $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); + $blRows = $GLOBALS['PDO']->resultsetInList( + '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 (', + $banIds, + ')', + ); foreach ($blRows as $blRow) { $banlogByBid[(int) $blRow['bid']][] = $blRow; } @@ -844,14 +848,15 @@ function setPostKey() $viewCommentsEnabled = Config::getBool('config.enablepubliccomments') || $userbank->is_admin(); $commentsByBid = []; if ($viewCommentsEnabled && $banIds !== []) { - $placeholders = implode(',', array_fill(0, count($banIds), '?')); - $cRows = $GLOBALS['PDO']->query( + $cRows = $GLOBALS['PDO']->resultsetInList( "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); + WHERE type = 'B' AND bid IN (", + $banIds, + ') ORDER BY bid, added desc', + ); foreach ($cRows as $cRow) { $commentsByBid[(int) $cRow['bid']][] = $cRow; } diff --git a/web/pages/page.commslist.php b/web/pages/page.commslist.php index 361eb1128..b64381d7d 100644 --- a/web/pages/page.commslist.php +++ b/web/pages/page.commslist.php @@ -635,11 +635,12 @@ function setPostKey() } $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); + $ids = array_keys($removedByAdminIds); + $adminRows = $GLOBALS['PDO']->resultsetInList( + 'SELECT aid, user FROM `:prefix_admins` WHERE aid IN (', + $ids, + ')', + ); foreach ($adminRows as $adminRow) { $removedByNames[(int) $adminRow['aid']] = $adminRow['user']; } @@ -659,11 +660,12 @@ function setPostKey() $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); + $authids = array_keys($siblingAuthidsToCheck); + $countRows = $GLOBALS['PDO']->resultsetInList( + 'SELECT authid, type, COUNT(bid) as cnt FROM `:prefix_comms` WHERE authid IN (', + $authids, + ') AND RemovedBy IS NULL AND (length = 0 OR ends > UNIX_TIMESTAMP()) GROUP BY authid, type', + ); foreach ($countRows as $countRow) { $activeSiblingCounts[$countRow['authid'] . '|' . (int) $countRow['type']] = (int) $countRow['cnt']; } @@ -672,14 +674,15 @@ function setPostKey() $commentsByBidComm = []; $viewCommentsEnabled = Config::getBool('config.enablepubliccomments') || $userbank->is_admin(); if ($viewCommentsEnabled && $bidList !== []) { - $placeholders = implode(',', array_fill(0, count($bidList), '?')); - $cRows = $GLOBALS['PDO']->query( + $cRows = $GLOBALS['PDO']->resultsetInList( "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); + WHERE C.type = 'C' AND bid IN (", + $bidList, + ') ORDER BY bid, added desc', + ); foreach ($cRows as $cRow) { $commentsByBidComm[(int) $cRow['bid']][] = $cRow; } diff --git a/web/tests/integration/AdminAdminsSearchTest.php b/web/tests/integration/AdminAdminsSearchTest.php index cc1c22053..64c1f8abd 100644 --- a/web/tests/integration/AdminAdminsSearchTest.php +++ b/web/tests/integration/AdminAdminsSearchTest.php @@ -266,6 +266,51 @@ public function testWebFlagMultiFilterArrayShape(): void $this->assertStringContainsString('>charlie<', $html, 'charlie has ADMIN_OWNER'); } + public function testWebFlagMultiFilterCombinesSelectedBitsWithOr(): void + { + $_GET = [ + 'p' => 'admin', + 'c' => 'admins', + 'admwebflag' => ['ADMIN_OWNER', 'ADMIN_ADD_BAN'], + ]; + + $html = $this->renderAdminsPage(); + + $this->assertSame(3, $this->extractAdminCount($html)); + $this->assertStringContainsString('>admin<', $html); + $this->assertStringContainsString('>alice<', $html); + $this->assertStringContainsString('>charlie<', $html); + $this->assertStringNotContainsString('>bob<', $html); + } + + /** + * Flag filters are scoped by ?view=, not by `enabled` (the SQL + * predicate replaced a HasAccess() loop that returned false for + * every deactivated admin, so ?view=inactive + a flag was always + * empty). + */ + public function testWebFlagFilterMatchesDeactivatedAdminsInInactiveView(): void + { + Fixture::rawPdo()->prepare(sprintf( + 'UPDATE `%s_admins` SET enabled = 0 WHERE aid = ?', DB_PREFIX, + ))->execute([$this->charlieAid]); + + $_GET = [ + 'p' => 'admin', + 'c' => 'admins', + 'view' => 'inactive', + 'admwebflag' => ['ADMIN_OWNER'], + ]; + $html = $this->renderAdminsPage(); + $this->assertSame(1, $this->extractAdminCount($html)); + $this->assertStringContainsString('>charlie<', $html); + + $_GET['view'] = 'active'; + $html = $this->renderAdminsPage(); + $this->assertSame(1, $this->extractAdminCount($html)); + $this->assertStringNotContainsString('>charlie<', $html); + } + /** * #1303 — default render is collapsed. * @@ -543,6 +588,58 @@ public function testServerFlagFilterMatchesSrvFlagsAndDoesNotCrash(): void $this->assertStringNotContainsString('>charlie<', $html); } + public function testServerFlagFilterIsCaseSensitiveLikeHasAccess(): void + { + $pdo = Fixture::rawPdo(); + $pdo->prepare(sprintf( + 'UPDATE `%s_admins` SET srv_flags = "A" WHERE aid = ?', + DB_PREFIX, + ))->execute([$this->aliceAid]); + + $_GET = [ + 'p' => 'admin', + 'c' => 'admins', + 'admsrvflag' => ['SM_RESERVED_SLOT'], + ]; + + $html = $this->renderAdminsPage(); + + $this->assertSame(0, $this->extractAdminCount($html)); + $this->assertStringNotContainsString('>alice<', $html); + } + + public function testServerJoinCountsEachAdminOnceWhenMembershipRowsDuplicate(): void + { + $pdo = Fixture::rawPdo(); + $pdo->prepare(sprintf( + 'INSERT INTO `%s_servers` (ip, port, rcon, modid, enabled) + VALUES ("server.example.test", 27015, "secret", 0, 1)', + DB_PREFIX, + ))->execute(); + $sid = (int) $pdo->lastInsertId(); + + $membership = $pdo->prepare(sprintf( + 'INSERT INTO `%s_admins_servers_groups` + (admin_id, group_id, srv_group_id, server_id) + VALUES (?, -1, -1, ?)', + DB_PREFIX, + )); + $membership->execute([$this->aliceAid, $sid]); + $membership->execute([$this->aliceAid, $sid]); + + $_GET = [ + 'p' => 'admin', + 'c' => 'admins', + 'server' => (string) $sid, + ]; + + $html = $this->renderAdminsPage(); + + $this->assertSame(1, $this->extractAdminCount($html)); + $this->assertSame(1, $this->countAdminRows($html)); + $this->assertStringContainsString('>alice<', $html); + } + /** * SM_ROOT on srv_flags implies every server permission, so a * reserved-slot filter still returns root holders. @@ -567,6 +664,31 @@ public function testServerFlagFilterIncludesSmRootHolders(): void $this->assertStringContainsString('>charlie<', $html); } + public function testServerFlagFilterIncludesInheritedGroupFlags(): void + { + $pdo = Fixture::rawPdo(); + $pdo->prepare(sprintf( + 'INSERT INTO `%s_srvgroups` (flags, immunity, name, groups_immune) + VALUES (?, 0, "Inherited Reserved", "")', + DB_PREFIX, + ))->execute([SM_RESERVED_SLOT]); + $pdo->prepare(sprintf( + 'UPDATE `%s_admins` SET srv_group = "Inherited Reserved" WHERE aid = ?', + DB_PREFIX, + ))->execute([$this->bobAid]); + + $_GET = [ + 'p' => 'admin', + 'c' => 'admins', + 'admsrvflag' => ['SM_RESERVED_SLOT'], + ]; + + $html = $this->renderAdminsPage(); + + $this->assertSame(1, $this->extractAdminCount($html)); + $this->assertStringContainsString('>bob<', $html); + } + /** * SM_CUSTOM1…6 end in a digit. The allowlist regex must accept * digits or those flags are dropped from both the SQL filter and diff --git a/web/tests/integration/DatabaseInListChunkTest.php b/web/tests/integration/DatabaseInListChunkTest.php new file mode 100644 index 000000000..ca1f2dbfc --- /dev/null +++ b/web/tests/integration/DatabaseInListChunkTest.php @@ -0,0 +1,190 @@ +insertBoundaryAdmins(); + $values = range(1, 75_000); + + Database::resetQueryCount(); + $aids = $GLOBALS['PDO']->resultsetInList( + 'SELECT aid FROM `:prefix_admins` WHERE aid IN (', + $values, + ') ORDER BY aid', + fetchType: PDO::FETCH_COLUMN, + ); + + $this->assertSame( + [Fixture::adminAid(), ...$boundaryAids], + array_map(static fn ($aid): int => (int) $aid, $aids), + ); + $this->assertSame(8, Database::getQueryCount()); + } + + public function testResultsetInListSkipsEmptyValuesWithoutPreparingSql(): void + { + Database::resetQueryCount(); + + $rows = $GLOBALS['PDO']->resultsetInList( + 'SELECT aid FROM `:prefix_admins` WHERE aid IN (', + [], + ')', + ); + + $this->assertSame([], $rows); + $this->assertSame(0, Database::getQueryCount()); + } + + public function testResultsetInListRejectsKeyedFetchModes(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('PDO::FETCH_ASSOC and PDO::FETCH_COLUMN'); + + $GLOBALS['PDO']->resultsetInList( + 'SELECT aid, user FROM `:prefix_admins` WHERE aid IN (', + range(1, 20_001), + ')', + fetchType: PDO::FETCH_KEY_PAIR, + ); + } + + public function testExecuteInListChunksSeventyFiveThousandValuesWithFixedParameter(): void + { + $boundaryAids = $this->insertBoundaryAdmins(); + $values = range(1, 75_000); + + Database::resetQueryCount(); + $affected = $GLOBALS['PDO']->executeInList( + 'UPDATE `:prefix_admins` SET lastvisit = ? WHERE aid IN (', + $values, + ')', + [1_750_000_000], + ); + + $this->assertSame(4, $affected); + $this->assertSame(8, Database::getQueryCount()); + $updatedRows = Fixture::rawPdo()->query(sprintf( + 'SELECT aid, lastvisit FROM `%s_admins` WHERE aid > 0 ORDER BY aid', + DB_PREFIX, + ))->fetchAll(PDO::FETCH_KEY_PAIR); + $this->assertSame( + [Fixture::adminAid(), ...$boundaryAids], + array_map('intval', array_keys($updatedRows)), + ); + foreach ($updatedRows as $lastVisit) { + $this->assertSame(1_750_000_000, (int) $lastVisit); + } + } + + public function testAtomicExecuteInListRollsBackEarlierChunkWhenLaterChunkFails(): void + { + $pdo = Fixture::rawPdo(); + $pdo->prepare(sprintf( + 'INSERT INTO `%s_admins` + (aid, user, password, gid, email, extraflags) + VALUES (10001, "boundary-10001", "", -1, "boundary-10001@example.test", 0)', + DB_PREFIX, + ))->execute(); + + $failure = null; + try { + $GLOBALS['PDO']->executeInList( + 'UPDATE `:prefix_admins` SET user = "chunk-collision" WHERE aid IN (', + range(1, 10_001), + ')', + atomic: true, + ); + } catch (\PDOException $e) { + $failure = $e; + } + + $this->assertInstanceOf(\PDOException::class, $failure); + $users = $pdo->query(sprintf( + 'SELECT aid, user FROM `%s_admins` WHERE aid IN (1, 10001) ORDER BY aid', + DB_PREFIX, + ))->fetchAll(PDO::FETCH_KEY_PAIR); + $this->assertSame([1 => 'admin', 10001 => 'boundary-10001'], $users); + } + + public function testPruneBansArchivesOnlySubmissionsMatchingActiveBans(): void + { + $pdo = Fixture::rawPdo(); + $aid = Fixture::adminAid(); + $now = time(); + + $insertBan = $pdo->prepare(sprintf( + 'INSERT INTO `%s_bans` + (type, ip, authid, name, created, ends, length, reason, aid, adminIp, sid) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, "127.0.0.1", 0)', + DB_PREFIX, + )); + $insertBan->execute([0, null, 'STEAM_0:1:75001', 'active-steam', $now, 0, 0, 'test', $aid]); + $insertBan->execute([1, '203.0.113.75', '', 'active-ip', $now, 0, 0, 'test', $aid]); + $insertBan->execute([0, null, 'STEAM_0:1:75002', 'expired-steam', $now - 120, $now - 60, 60, 'test', $aid]); + + $insertSubmission = $pdo->prepare(sprintf( + 'INSERT INTO `%s_submissions` + (submitted, ModID, SteamId, name, email, reason, ip, sip, archiv) + VALUES (?, 0, ?, ?, "player@example.com", "test", "127.0.0.1", ?, 0)', + DB_PREFIX, + )); + $insertSubmission->execute([$now, 'STEAM_0:1:75001', 'steam-match', null]); + $insertSubmission->execute([$now, '', 'ip-match', '203.0.113.75']); + $insertSubmission->execute([$now, 'STEAM_0:1:75002', 'expired-no-match', null]); + $insertSubmission->execute([$now, 'STEAM_0:1:75999', 'unmatched', null]); + + \PruneBans(); + + $submissionRows = $pdo->query(sprintf( + 'SELECT name, archiv, archivedby FROM `%s_submissions` ORDER BY subid', + DB_PREFIX, + ))->fetchAll(PDO::FETCH_ASSOC); + $submissions = array_column($submissionRows, null, 'name'); + + $this->assertSame(3, (int) $submissions['steam-match']['archiv']); + $this->assertSame(3, (int) $submissions['ip-match']['archiv']); + $this->assertSame(0, (int) $submissions['steam-match']['archivedby']); + $this->assertSame(0, (int) $submissions['expired-no-match']['archiv']); + $this->assertSame(0, (int) $submissions['unmatched']['archiv']); + + $expired = $pdo->query(sprintf( + "SELECT RemoveType FROM `%s_bans` WHERE authid = 'STEAM_0:1:75002'", + DB_PREFIX, + ))->fetch(PDO::FETCH_ASSOC); + $this->assertSame('E', $expired['RemoveType']); + } + + /** + * @return list + */ + private function insertBoundaryAdmins(): array + { + $aids = [10_000, 10_001, 75_000]; + $insert = Fixture::rawPdo()->prepare(sprintf( + 'INSERT INTO `%s_admins` + (aid, user, password, gid, email, extraflags) + VALUES (?, ?, "", -1, ?, 0)', + DB_PREFIX, + )); + foreach ($aids as $aid) { + $insert->execute([$aid, 'boundary-' . $aid, 'boundary-' . $aid . '@example.test']); + } + + return $aids; + } +}