Release history
Changelog
Every new feature, fix and speed-up on s&box Explorer, newest first.
- Last 30 days
- 90
- Features shipped
- 233
- Fixes
- 218
- Days with changes
- 66
218 changes Clear filters
September 2026
14
-
Fix
4:14 PM
Deny the dev tooling still sitting in the docroot
Details
composer.lock, package-lock.json, phpstan.neon, phpunit.xml, router.php and three JS configs were uploaded by hand before the deploy system existed and were serving 200 to anyone. .deployignore keeps them out of new deploys but the rsync has no --delete, so it cannot remove them. The lockfiles are the part that matters: they hand out exact dependency versions to check against known CVEs.
Named individually rather than by extension, because manifest.json and locales/*.json are fetched by the site itself.
743ec9d -
Fix
9:58 AM
Repair org search, package paging and the MCP org field
Details
Three faults surfaced by the production error log.
org/list has returned 404 since at least June and Facepunch has no bulk replacement (organization/<ident> serves one org at a time). Both callers caught the failure and returned an empty array, so organisation search quietly found nothing rather than reporting a problem, then cached that empty result for an hour. Orgs are now recovered from package search: q=org:<ident> is an exact lookup and a plain term surfaces the orgs owning matching packages. Verified live: "face" finds facepunch, "rat" finds rats and ratman, "studio" finds six, where all three returned nothing before. Both call sites now share one implementation.
package/find silently ignores page and count; only skip and take work. Four call sites sent page/count, so /player/packages returned the same first results on every page and handed back 200 items where the caller asked for 20, while the response advertised correct pagination. Now skip/take, with the offset from Pagination::offset so the existing int32 clamp applies.
mcp/tools/package.php read Org as a string in five places. It is an object everywhere else in the codebase, which is why the log carried a steady drip of "Array to string conversion" and the tool printed Array where the organisation name belongs.
Also stops the settings API writing two INFO lines to the error log on every request, which was most of that file's volume.
tests/test_api_params.php locks all of it in.
42dbb8c -
Fix
9:34 AM
Confirm dialog swallowed the click on form-level data-confirm
Details
A submit button inside a form carrying data-confirm resolves through closest() to the form, not the button. The click handler then called .click() on the form, which has no activation behaviour, so nothing happened: the dialog appeared, you pressed Confirm, and the delete never ran. Fourteen actions across eight pages were affected (announcements, cleanup-invalid-users, donations, manifest-removals x3, mcp-api-keys x5, mcp-knowledge, moderation-logs, webhook-settings). The nine that put data-confirm on the button itself worked, which is why the dashboard and player-history tested fine.
The click handler now defers to the submit handler for forms.
tests/admin-confirm.test.js covers both entry points in jsdom, including cancel and Escape. Verified it fails without the fix.
91505cc -
Fix
4:39 AM
Close CSRF and XSS holes in the admin panel
Details
CSRF: player-history, steam-friends-cache and warmup-redis handled POST with no token check at all. player-history's actions include TRUNCATE of all three player_history tables and exec() of the collector scripts, so a logged-in admin visiting a hostile page could be made to wipe that data. All three now validate and carry a token field, and badge-management drops its local 16-byte, !== comparison in favour of the shared 32-byte hash_equals helpers (its version also left csrf_token_time unset, disabling token expiry for every other page sharing the session).
XSS: Advanced Analytics rendered Steam usernames and avatar URLs into innerHTML unescaped, and Org Claims did the same for usernames, org idents and review notes, including an org ident spliced into an inline onclick="revokeOrg('...')". Steam personas and org idents are user controlled, so both were stored XSS into an authenticated admin session. The revoke button now passes its ident through a data attribute.
The activity heatmap's two third-party scripts get SRI hashes and crossorigin, so a compromised unpkg or d3js.org fails closed.
7491ab8 -
Fix
4:35 AM
Repair buttons, tables and icons that had no styles behind them
Details
- api-test.css was written against --card-bg, --border-color, --hover-bg and --text-color, none of which exist. Every rule fell through to a hardcoded dark value, so API Test ignored the theme and was unreadable on the light ones. Rewritten against the real tokens.
- 11 buttons across five pages used class="btn-primary" and friends without the base .btn, which only supplies colour: no padding, radius or alignment.
- .btn-action on Refresh Status was defined nowhere at all.
- table-responsive (also undefined) becomes admin-table-container, so those tables scroll instead of overflowing on narrow screens.
- 32 <i class="material-symbols-rounded"> become <span>, matching the rest of the codebase.
- Adds the .admin-badge-info, .badge-sm, .org-cell, .org-link, .timestamp and .pending-card-* rules that markup referenced but nothing defined.
bd35f08 -
Fix
4:31 AM
Drop the dead Migrate Schema control and route
Details
The Reputation page offered a 'Migrate Schema' button that POSTed to backend/api/migrate-reputation-schema.php, a file that does not exist and is not in git history; router.php carried a matching dead route. Removed both rather than inventing a schema migration.
Both reputation recalculation buttons now report through adminToast instead of alert(). Their endpoints are left alone: recalculate-reputation.php covers users with claimed orgs, recalculate-all-users-reputation.php covers everyone in batches, and they are not interchangeable.
198038e -
Fix
3:59 AM
Resolve /api/* by pattern so production matches the dev server
Details
/api/package-leaderboard had no rewrite rule, so it fell through the catch-all to backend/api/package-leaderboard, missed, and landed on the front controller. The browser asked for JSON and got the homepage back, at HTTP 200, so the In-Game Leaderboards panel showed zero entries with no error anywhere. It worked on localhost the whole time, because router.php has the route.
That is the real problem: router.php and .htaccess are two hand-kept lists of the same thing and they drifted, 13 routes against 5. Missing alongside package-leaderboard were manifest-removal-request, steam-friends, kofi-webhook and four reputation endpoints.
.htaccess now resolves by pattern, trying <name>-api.php then <name>.php, each guarded by an -f check so a name only resolves when the file exists. The character class allows no dot or slash, so neither rule can be walked out of the directory, and everything reachable this way was already reachable through the catch-all, so nothing is newly exposed.
Also drops the military_tech icon from the leaderboards heading: nine of the eleven package sections have no heading icon.
And wires ?refresh=1 into fetchSpecificPackage, admin-gated, matching the switch that already works on player profiles and org package lists. Package pages were the one place it silently did nothing, leaving no way to bypass the hour-long cache except waiting.
5aa42db -
Fix
3:38 AM
Cap pagination offset so crafted page numbers cannot 500
Details
A crawler walking synthetic pagination reached
package/find/2/?skip=7202577550&take=25&q=849152367+type:model
and every hit logged an upstream 500.
Probed the endpoint: Facepunch holds skip in a signed 32-bit int. skip=2147483647 returns 200, skip=2147483648 returns 500. Anything past TotalCount returns 200 with zero packages, so deep offsets are useless long before they are harmful.
Pagination::parse floored the page at 1 but had no ceiling, so any integer a visitor put in the URL became an offset. Every controller reads its page from there, so the cap goes in the one place rather than in each of them. Page is clamped rather than the offset, so the page number a caller gets back still matches the results it is given.
browse-api.php and code-search-api.php read skip straight from the query string and bypass the parser; code-search-api did not even floor it at zero. Both now go through Pagination::clampOffset.
MAX_OFFSET is 100000: far below the overflow, and more than ten times the largest package type (model, under 10000 entries), so it can only clip requests that would have returned nothing.
The test caught a bug in the first version of this fix: at PHP_INT_MAX the offset multiplication overflows to a float and the int-typed clamp threw a TypeError, which would have been a 500 of our own making.
9a2fd78 -
Fix
3:34 AM
Ignore build output so the clone stays deployable
Details
cPanel will not deploy a repository whose working tree is dirty, and .cpanel.yml runs npm run build inside that clone. frontend/css/dist was untracked rather than ignored, so the build left the tree dirty and cPanel reported deployable: 0 with no error anywhere: the push was accepted, the checkout advanced, and the tasks silently never ran.
Ignoring both dist directories keeps the tree clean across builds. The old negation lines were inert anyway, since nothing ignored those paths in the first place.
acc26c6 -
Fix
3:22 AM
Stop shipping .htaccess, cPanel owns it
Details
The repo copy lacks the cPanel-generated PHP handler block, which is marked 'do not edit' because cPanel regenerates it on any MultiPHP change. Deploying the repo version therefore removed the AddHandler directive, .php stopped executing, and the site served an Apache directory listing until the original was restored from backup.
Verified afterwards that the five /api/* rewrite rules the repo copy adds are redundant: /api/code-search, /api/package-changelists, /api/organization-details and /api/player-extended all return 200 on the restored file. The only other difference was a .dump deny, which belongs on the server copy.
84d191c -
Fix
3:05 AM
Stop calling the Facepunch storage endpoints that always 401
Details
The error log was filling with lines like
HttpService Error (401): - URL: .../storage/query?steamid=...&package=...
one per visit to the storage tab of the data explorer.
Probed the endpoint directly. storage/query and storage/get return 401 for every package and every steamid, including pcjr.oreworks, whose package/stats/2 data is public and returns 200 from the same host. They answer only to a Bearer token minted by a live in-game client session, and Steam OpenID sign-in cannot produce one. storage-explorer.php has said as much in its own UI copy since it was written; the code just never acted on it.
So the request could never succeed. Both service methods now return the failure locally without leaving the process, keeping the exact shapes their three callers already switch on: the 401 array for query, null for get. The explorer page renders identically, because it gates on the httpCode key either way.
storage-api.php was separately answering 200 with "success": true wrapped around the failure payload, which told callers nothing. It now returns 501 with the reason.
Test asserts the shapes hold and that neither method makes a network call, using elapsed time as the proof: a real round trip measured about 200ms, these return in microseconds.
b3ee80a -
Fix
2:19 AM
Sweep leaked scratch files, and guard the tool scripts
Details
Rate limit counters and refresh locks were only ever cleaned up lazily, by the next request for the same identifier. An IP that visits once, or an org refreshed once and never again, left its file on disk permanently. That had grown to roughly 8000 files, which had to be deleted by hand. Redis is disabled in production, so the file fallback is the live path, not a contingency.
RateLimiter::gc() removes counters whose window has long closed, and runs on roughly 1 in 500 fallback writes so the directory maintains itself. It reads mtime rather than opening files, so clearing a backlog does not parse thousands of JSON blobs. mt_rand rather than random_int for the sampling: it needs no entropy guarantee and sits in a path that must never throw.
Refresh locks leak the same way but are written from five places into three directories, so cache-sweeper.php covers all of them in one place. It sweeps at 300s against a 120s lock timeout, leaving a wide margin so it can never pull a lock out from under a refresh that is still running. The test plants a 5-second-old lock and asserts it survives.
The sweep piggybacks on the existing scheduled-refresh cron rather than needing an entry of its own, and runs before the refresh work so a lock left by a killed run does not block the current one.
Also closes two holes found while making cleanup-cache.php reachable over HTTP, since nothing in .htaccess blocks backend/tools/:
- notify-package-update.php checked only that the X-Cron-Key header was present, never its value, so any value at all got in
- check-package-updates.php had no guard whatsoever
Both send push notifications to every follower. tool-auth.php is now the single check all four scripts route through: CLI, a signed-in admin, or CRON_KEY compared with hash_equals. It fails closed when the key is unset or still the placeholder, so the window between deploying and configuring is shut rather than open.
cleanup-cache.php preflights its own dependencies by name, because production runs with display_errors off and this deploys over FTP, where a half-finished upload otherwise surfaces as an unexplained blank 500.
4298dff -
Fix
2:19 AM
Show every live game, and make the cards visible
Details
Two separate bugs made the page look mostly empty.
The trending grid rendered every card at opacity 0. The CSS gates card opacity on a .visible class that nothing in the codebase ever added, so the cards were in the DOM, taking up layout space, and invisible. Only readers with prefers-reduced-motion saw them, because that media query forces opacity back to 1, which is presumably how it shipped. filter() already knows which cards should show, so it toggles the class there rather than adding an IntersectionObserver; that covers first paint, tag filtering, search and Show More in one place.
The counts were also wrong. The API has no sort that surfaces live games: every sort: token it does not recognise (players, active, online, usersnow, hot) falls back to the same default order, and live games are scattered right through all 1600+ results. One take=100 page saw about a third of them. It now sweeps every page concurrently, with take=500 because that is the hard cap, deduplicating by FullIdent since pages are fetched in parallel and the result set shifts underneath them.
Playing Now 68 -> 148, Active Games 21 -> 63.
The grid slice was also a flat 50, which would cut live games off the bottom the moment more than 50 had players in them.
8fc4311 -
Fix
2:18 AM
Correct the Facepunch endpoint contract
Details
Four things were wrong about how we call public.facepunch.com:
The leaderboard endpoint was still on its pre-versioning shape and returned nothing. It now takes a stat name, defaulting to kills. The stat is validated against a character whitelist and, separately, rejected if it is made only of dots: curl normalises dot-segments in a path, so a stat of ".." would silently rewrite which endpoint the request actually hits.
The TTL map keyed leaderboard responses on "package/leaderboard", which stopped substring-matching once the stat moved into the path, quietly downgrading those entries from 600s to the 300s default. Keyed on "/leaderboard/" now.
package/find/2/ takes skip and take, not page and count, and caps take at 500 (take=1000 returns a 400). count=100 was being ignored, so the caller silently got the 200-item default.
Code search exposes the kind and year facets the API already supported. kind is matched against a whitelist because the API compares it case-sensitively and silently returns nothing for a near miss.
tests/test_facepunch_endpoints.php covers all of it, including that the kind buckets partition the total and the year buckets sum to it.
545ac53
July 2026
2
-
Fix
10:55 PM
Render changelog on package page and add .htaccess API rewrite rules
69c34b7 -
Fix
10:53 PM
Format package links in code search as canonical /org/ident/ URLs
427751f
2
-
Fix
4:49 AM
10/10 MCP cleanup — tool mappings, dead code, naming
Details
- Added migrate, refactor, test, tool_search to explain.php toolMappings
- Removed unused KnowledgeDB::deleteEntry() (just called delete())
- Renamed changelog.php → updates.php (matches registered tool name)
- Updated all references in index.php and MCP_TOOLS_AUDIT.md
25eb187 -
Fix
4:39 AM
Remove mock api_diff tool, fix silent error swallowing
Details
- Removed sbox_api_diff (returned hardcoded mock data, not useful)
- Added error logging to mcpResolveAuthor empty catch block
- Updated tool counts: 17 → 16 tools, 123 → 120 parameters
- Updated MCP_TOOLS_AUDIT.md and index.php references
e89b069
1
-
Fix
8:25 PM
MCP toolset overhaul — bug fixes, security hardening, new tools
Details
- Fix duplicate sbox_search_docs alias in Registry
- Fix kb.php slug key bug (slug→url in update mapping)
- Fix admin.php single-quote string interpolation
- Fix contribute.php silent exception swallowing
- Fix package.php operator precedence in FullIdent
- Fix migrate.php false positive regex with word boundaries
- Add try/catch wrappers to 6 tool callbacks (search, stats, scaffold, refactor, diagnose, explain)
- Fix &\ reference leak in search.php
- Replace define() constants with static auth context in Auth.php
- Add timing-safe hash_equals for API key comparison
- Add DNS rebinding protection (safeFetch) in KBService
- Fix trending sort by engagement in KnowledgeDB
- Fix PDO type binding in SemanticSearch
- Hide internal errors in VotingService
- Fix dry_run and sortBy in AdminService
- Add sbox_test tool (NUnit test scaffold generator)
- Add sbox_api_diff tool (API version comparison)
- Wire new tools into index.php with Claude Code tab
64e655c
1
-
Fix
10:07 PM
CSP policy, MCP key generation, presence settings, and service worker resilience
Details
- SecurityMiddleware.php: add missing CSP domains (Google Analytics regional, AdSense, Steam avatars, Google Fonts, Facepunch services) to connect-src and script-src; add data: to font-src
- .htaccess: sync report-only CSP with enforced policy
- mcp.php: fix apiSuccess envelope mismatch (data.key -> data.data.key) that caused 'Cannot read properties of undefined (reading substring)'
- legacy-compat.php: auto-create user_presence_settings table on first access; log actual DB errors instead of generic message
- profile-api.php: change requireAuth() to requireAuthApi() on all API endpoints so unauthenticated fetch() calls get JSON 401 instead of HTML 302 redirect
- settings.js: make presence save non-blocking so a presence failure doesn't kill the entire settings save
- service-worker.js: null-check request/url before accessing properties, wrap new URL() in try/catch, bump cache version to v2
fb94470
May 2026
3
-
Fix
6:03 AM
Move Helpful button code out of hero counter IIFE
Details
The Helpful button JS was inside the same IIFE as the hero stat counters, which has an early return when .kb-stat-pill elements are not found. On the entry detail page (where the Helpful button lives) there are no stat pills, so the entire IIFE exited early and the click handler was never registered.
Fix: split into two separate IIFEs so each feature initializes independently.
593b647 -
Fix
6:00 AM
Handle stale localStorage preventing Helpful button clicks
Details
The previous non-functional Helpful button saved to localStorage on click without actually recording the vote server-side. Users who previously clicked it had a stale localStorage entry that permanently blocked the button on page load.
Fix: only trust localStorage if server-side count > 0; otherwise clear the stale entry so the user can vote again.
48e9283 -
Fix
12:10 AM
SQL injection, add rate limiting, remove cache duplication, DRY up code
Details
- Fix SQL injection in CacheManager::flushStats() by using parameterized queries
- Add rate limiting to 12 legacy API endpoints via new legacy-rate-limiter.php
- Remove ~200 lines of duplicate cache logic from DatabaseManager (delegate to CacheManager)
- Clean up .gitignore: remove duplicates, fix conflicting *.md pattern, organize sections
- DRY up PackageController: extract findByFilter() helper for 5 near-identical methods
33c5ea0
1
-
Fix
7:32 AM
Fix unclosed main tags and standardize max-width to CSS variable
Details
- Fix unclosed <main> tags in credits, changelog, release-notes, donate, and api-doc-legacy pages that caused footer to be constrained
- Fix nested <main> tags in donate and api-doc-legacy (replaced with div)
- Replace all hardcoded max-width values (1400px, 1440px, 1600px, 1680px, 1800px) with var(--max-content-width) for consistency
- Set --max-content-width to 1680px globally in base.css
- Update legal page max-width to match global value
3f07ced
1
-
Fix
1:33 AM
AdminService method mismatches and add missing KnowledgeDB methods
488c63b
Generated from the project's commit history whenever the site is deployed. Times are server time.