diff --git a/web/api/handlers/_register.php b/web/api/handlers/_register.php index 584af2641..0938b471c 100644 --- a/web/api/handlers/_register.php +++ b/web/api/handlers/_register.php @@ -69,6 +69,7 @@ Api::register('bans.add_comment', 'api_bans_add_comment', 0, true); Api::register('bans.edit_comment', 'api_bans_edit_comment', 0, true); Api::register('bans.remove_comment', 'api_bans_remove_comment', ADMIN_OWNER); +Api::register('bans.remove_demo', 'api_bans_remove_demo', 0, true); Api::register('bans.group_ban', 'api_bans_group_ban', ADMIN_OWNER | ADMIN_ADD_BAN); Api::register('bans.ban_member_of_group', 'api_bans_ban_member_of_group', ADMIN_OWNER | ADMIN_ADD_BAN); Api::register('bans.ban_friends', 'api_bans_ban_friends', ADMIN_OWNER | ADMIN_ADD_BAN); diff --git a/web/api/handlers/bans.php b/web/api/handlers/bans.php index 9495c1135..d2cdc9cf0 100644 --- a/web/api/handlers/bans.php +++ b/web/api/handlers/bans.php @@ -349,6 +349,67 @@ function api_bans_edit_comment(array $params): array ]; } +function api_bans_remove_demo(array $params): array +{ + global $userbank, $username; + + $bid = (int) ($params['bid'] ?? 0); + if ($bid <= 0) { + throw new ApiError('validation', 'Missing or invalid ban id.', 'bid'); + } + + $row = $GLOBALS['PDO'] + ->query( + 'SELECT ba.aid, ad.gid + FROM `:prefix_bans` AS ba + LEFT JOIN `:prefix_admins` AS ad ON ba.aid = ad.aid + WHERE ba.bid = :bid' + ) + ->single([':bid' => $bid]); + + if (!$row) { + throw new ApiError('not_found', 'Ban not found.'); + } + + $canEdit = $userbank->HasAccess(WebPermission::mask(WebPermission::Owner, WebPermission::EditAllBans)) + || ($userbank->HasAccess(WebPermission::EditOwnBans) && (int) $row['aid'] === $userbank->GetAid()) + || ($userbank->HasAccess(WebPermission::EditGroupBans) && (int) $row['gid'] === (int) $userbank->GetProperty('gid')); + + if (!$canEdit) { + throw new ApiError('forbidden', 'You do not have permission to edit this ban.'); + } + + $demo = $GLOBALS['PDO'] + ->query( + "SELECT filename FROM `:prefix_demos` + WHERE demid = :bid AND UPPER(demtype) = 'B'" + ) + ->single([':bid' => $bid]); + + if (!$demo) { + throw new ApiError('not_found', 'No demo is attached to this ban.'); + } + + $onDisk = basename((string) $demo['filename']); + $path = SB_DEMOS . '/' . $onDisk; + $listing = is_dir(SB_DEMOS) ? scandir(SB_DEMOS) : false; + if ($onDisk !== '' && $listing !== false && in_array($onDisk, $listing, true) && is_file($path)) { + if (!unlink($path)) { + throw new ApiError('server_error', 'Unable to delete demo file from disk.'); + } + } + + $GLOBALS['PDO'] + ->query("DELETE FROM `:prefix_demos` WHERE demid = :bid AND UPPER(demtype) = 'B'") + ->execute([':bid' => $bid]); + + Log::add(LogType::Message, 'Demo Removed', "$username removed the demo from ban #$bid"); + + return [ + 'removed' => true, + ]; +} + function api_bans_remove_comment(array $params): array { global $username; diff --git a/web/includes/View/AdminBansEditView.php b/web/includes/View/AdminBansEditView.php index 37563d45b..600cacac2 100644 --- a/web/includes/View/AdminBansEditView.php +++ b/web/includes/View/AdminBansEditView.php @@ -16,10 +16,14 @@ * - `$ban_name` Current player name on the ban row. * - `$ban_authid` Current Steam2 authid on the ban row. * - `$ban_ip` Current IP on the ban row. - * - `$ban_demo` Pre-built "Uploaded: safename" snippet - * (or empty), htmlspecialchars'd in the page handler - * per #1113. Rendered with `{nofilter}`; the safety - * annotation lives at the call site in the template. + * - `$ban_id` Ban row id (`:prefix_bans.bid`); drives the demo + * download URL and the remove-demo JSON action. + * - `$has_demo` Whether a demo row exists for this ban. + * - `$ban_demo` Pre-built "Uploaded: safename" + * snippet (or empty), htmlspecialchars'd in the + * page handler per #1113. Rendered with + * `{nofilter}`; the safety annotation lives at the + * call site in the template. * - `$customreason` `false` when custom reasons are disabled, otherwise * the list of reason strings from `bans.customreasons`. * @@ -38,6 +42,8 @@ final class AdminBansEditView extends View */ public function __construct( public readonly bool $can_edit_ban, + public readonly int $ban_id, + public readonly bool $has_demo, public readonly string $ban_name, public readonly string $ban_authid, public readonly string $ban_ip, diff --git a/web/pages/admin.edit.ban.php b/web/pages/admin.edit.ban.php index 57e3ff49f..d51fa8d8d 100644 --- a/web/pages/admin.edit.ban.php +++ b/web/pages/admin.edit.ban.php @@ -67,7 +67,7 @@ function emitEditBanToastAndRedirect(string $kind, string $title, string $body, // names and bind both. The subquery's `:demo_bid` and the outer // `:bid` both pull from `$_GET['id']`, which is already int-cast above. $GLOBALS['PDO']->query(" - SELECT bid, ba.ip, ba.type, ba.authid, ba.name, created, ends, length, reason, ba.aid, ba.sid AS ba_sid, ad.user, ad.gid, CONCAT(se.ip,':',se.port) AS server_addr, se.sid AS se_sid, mo.icon, (SELECT origname FROM `:prefix_demos` WHERE demtype = 'b' AND demid = :demo_bid) AS dname + SELECT bid, ba.ip, ba.type, ba.authid, ba.name, created, ends, length, reason, ba.aid, ba.sid AS ba_sid, ad.user, ad.gid, CONCAT(se.ip,':',se.port) AS server_addr, se.sid AS se_sid, mo.icon, (SELECT origname FROM `:prefix_demos` WHERE UPPER(demtype) = 'B' AND demid = :demo_bid) AS dname 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 @@ -338,22 +338,31 @@ function emitEditBanToastAndRedirect(string $kind, string $title, string $body, : false; /** @var false|list $customReason */ -\Sbpp\View\Renderer::render($theme, new \Sbpp\View\AdminBansEditView( - can_edit_ban: $canEditBan, - ban_name: (string) $res['name'], - ban_authid: trim((string) $res['authid']), - ban_ip: (string) $res['ip'], +$hasDemo = !empty($res['dname']); +$banDemoHtml = ''; +if ($hasDemo) { // Issue #1113: dname is the admin-supplied original filename of the // demo (POST'd by whoever edited the ban + uploaded the demo, stored // as `:prefix_demos.origname`). It used to be interpolated raw into // HTML rendered with `nofilter`, so a filename like // `` turned the edit-ban page into stored XSS // for any admin viewing that ban. htmlspecialchars + ENT_QUOTES so - // the value is safe inside both the surrounding `` text and - // the `nofilter` render in the template. - ban_demo: !empty($res['dname']) - ? 'Uploaded: ' . htmlspecialchars((string) $res['dname'], ENT_QUOTES, 'UTF-8') . '' - : '', + // the value is safe inside the link text. The href is server-built + // from the int-cast bid only (#1464). + $safeName = htmlspecialchars((string) $res['dname'], ENT_QUOTES, 'UTF-8'); + $demoUrl = 'getdemo.php?type=B&id=' . rawurlencode((string) $_GET['id']); + $banDemoHtml = 'Uploaded: ' + . $safeName . ''; +} + +\Sbpp\View\Renderer::render($theme, new \Sbpp\View\AdminBansEditView( + can_edit_ban: $canEditBan, + ban_id: (int) $_GET['id'], + has_demo: $hasDemo, + ban_name: (string) $res['name'], + ban_authid: trim((string) $res['authid']), + ban_ip: (string) $res['ip'], + ban_demo: $banDemoHtml, customreason: $customReason, )); @@ -380,6 +389,7 @@ function emitEditBanToastAndRedirect(string $kind, string $title, string $body, $redirectUrl = 'index.php?p=banlist' . $pagelink; $redirectUrlJs = json_encode($redirectUrl, JSON_THROW_ON_ERROR | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT); $postSuccessJs = $postSuccess ? 'true' : 'false'; +$banIdJs = json_encode((int) $_GET['id'], JSON_THROW_ON_ERROR); ?>