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
40 changes in October 2026 Clear filters
October 2026
2
-
Fix
8:36 AM
Save the Steam presence toggle where the profile reads it
Details
The presence settings endpoint wrote user_presence_settings, which nothing reads, while the page and profile read user_settings.steam_presence_enabled through SteamIntegrationManager. The toggle appeared to save but reverted on reload. Both routes now use SteamIntegrationManager, and the save is read back before reporting success.
99f048e -
Fix
7:56 AM
Stop the settings page rotating the CSRF token after the header prints it
Details
mcp.php called generateCsrfToken(), which issues a new token on every call. The header had already emitted the previous one in the csrf-token meta tag, so every settings save from that page (theme included) sent a stale token and got a 403. getCsrfToken() reuses the session token, so the meta tag and the MCP forms agree.
515288d
3
-
Fix
2:57 PM
Show the install button on desktop too, and use 4px corners
Details
beforeinstallprompt was deferred with preventDefault() but only mobile got a button, so desktop never called prompt() and Chrome logged the banner warning on every page. The button was also a 50px pill.
06f7605 -
Fix
2:53 PM
Serve service-worker.js with no-cache
Details
The immutable year-long rule for .js also covered the service worker, so Cloudflare kept the old script and its old CSP header. A service worker's fetches are governed by the CSP on its own response, so the widened connect-src never reached it. The docroot .htaccess is not deployed; the same block was added there by hand.
089546a -
Fix
2:47 PM
Restore review modal, guard null steam id on profile, widen CSP connect-src
Details
openReviewModal/closeReviewModal were lost in the admin shell refactor, so Approve/Reject threw. The profile favorites count fataled when the profile had no valid steam id. connect-src now covers the hosts the service worker fetches (cdnjs, fonts.googleapis, steam header CDNs, files.facepunch).
93018e3
35
-
Fix
9:37 PM
Actually delete inactive session records after 30 days
Details
The privacy policy promises session data is deleted after 30 days of inactivity, but cleanupOldSessions() was never called. The scheduled refresh now runs it once a day around 03:00, and it matches on last_activity (falling back to login_time) so people who are still active are not removed. Covered by an integration test that seeds stale, long-login-but-active and recent rows. Also writes the audience key colour opacity as a percentage to satisfy stylelint.
af53781 -
Fix
8:52 PM
Stop logging expected review 404s and guard missing SecondsPlayed on profiles
Details
Package reviews return 404 for packages nobody has reviewed, so HttpService no longer logs them as errors. The Recently Played card skips entries with no Package and hides the hours line when SecondsPlayed is absent, which was raising undefined-key warnings on signed-in profiles.
28ec260 -
Fix
7:36 PM
Let Google see noindex instead of blocking the pages it indexes anyway
Details
Search Console reported pages indexed though blocked by robots.txt. They were URLs that public pages link to without nofollow, which robots.txt blocked, so Google indexed the bare URL and could never read a noindex: /auth/login.php and its ?return_to= variants (the sign-in link, on every page), /mcp/ (the MCP landing page) and /backend/api/docs.php?format=openapi (the API docs download).
robots.txt no longer blocks /auth/, and allows /mcp/ (exact) and /backend/api/docs.php, which are more specific than the existing Disallows. /auth/* and docs.php now send X-Robots-Tag: noindex, nofollow from sendNoindexHeader() in bootstrap, so the redirect and the JSON drop out of the index. Every sign-in link and the OpenAPI download get rel=nofollow. /mcp/ is a real page, so it stays indexable, gets a meta description and goes into the sitemap.
test_robots.php parses the real robots.txt with a most-specific-rule matcher (crawlable and blocked paths), checks the noindex paths, and that no sign-in anchor lacks nofollow.
da0bf73 -
Fix
7:33 PM
End the Steam launch link with a slash
Details
The link is now steam://run/590830//-rungame org.package/, matching the one sbox.game's own Play button uses, instead of ending at the package id.
af796dd -
Fix
7:11 PM
Stop the header and news meta reading array keys that are not there
Details
Production's config/config.php defines its own getCurrentUser(), which wins over the one in auth-helpers.php and returns is_stinky, not is_blocked. header.php read $user['is_blocked'] unguarded, so every signed-in page view logged an undefined-key warning (error logging became visible when production stopped using error_reporting(0)), and the Blocked badge never showed for blocked users. It now shows for either key.
meta-tags.php read $newsPost['title'] unguarded for the image alt text and keywords on a post with no title. Both now default to an empty string.
test_meta_news.php renders the news branch with and without a title and checks the header guard.
9e947ff -
Fix
6:11 PM
Default the retention and history card headings when the caller sets none
Details
retention-curve.php and platform-history.php read $retentionHeading / $historyHeading in the ternary's true branch, so a caller that never sets one (the package page's retention section) got an undefined variable, an empty tag name and a broken heading. They now default to h2. test_heading_default.php renders the partial with no heading, with h3 and with an invalid one.
b4f9aa2 -
Fix
5:40 PM
Name the package tabs on phones, where the label is hidden for inactive tabs
39220ce -
Fix
5:38 PM
Finish moving inline handlers to delegated actions, label the settings tabs, and tune the CSP report path
Details
- remaining JS-generated onclick/onerror handlers (reviews retry, PWA update banner, claimed-orgs retry, search retry, version compare, achievements expand and icon fallback) use data-action or listeners; tests for the review and achievement renderers cover hostile names and titles, fallbacks and error states
- settings tabs get ids (the panels' aria-labelledby pointed at nothing) and accessible names, which the phone layout hid with the visible text
- CSP: the service worker's fetch() of fonts and styles was missing from connect-src, found from the collector's own reports; reports use a separate 30/min bucket so they cannot use up the API allowance; the Report-Only header is sampled at 10% (CSP_REPORT_SAMPLE_PERCENT)
- README: the schema is not auto-created; run create_consolidated_schema.php on an empty database first
1caa6dc -
Fix
5:29 PM
Remove a duplicate obscureIpAddress() and add an authenticated smoke test
Details
The PHPStan commit's claim that obscureIpAddress() did not exist was wrong: it is defined in config/session_tracking.php, which analysis was not scanning. The duplicate added to helpers.php was a fatal redeclaration for every signed-in user, found by the post-remediation sweep. tests/test_authenticated_smoke.php drives signed-in pages and the recently-viewed API against a running site and fails on any 5xx or PHP error; it fails with the duplicate present.
85270ba -
Fix
5:16 PM
Consolidate recently viewed on RecentlyViewedManager
Details
- one store: the recently_viewed table behind RecentlyViewedManager, keyed by $_SESSION['steam_id']; package pages, recommendations and the profile page already used it
- the manager now creates its table on PostgreSQL (the only migration that did was SQLite-only), validates package ids, and takes an injectable clock
- /user-experience/recently-viewed POST wrote user_recently_viewed, which nothing read; it now tracks through the manager. DELETE had no route, so the profile page's Clear history button got a 404; it exists now
- user-experience-api.php no longer calls track() with a null user for anonymous POST/DELETE
- account deletion also removes recently_viewed rows
- remove backend/api/recently-viewed.php: wrong session key and a column that does not exist, no caller
- tests/_support.php: integration tests fail with a reason instead of silently passing; tests/test_recently_viewed.php
2ebdfa3 -
Fix
4:59 PM
Answer array-valued query parameters with 400 instead of a TypeError 500
Details
Nine legacy JSON endpoints (package-info, similar-packages, search-api, steam-friends, organization-details, package-changelists, package-achievements, package-manifest, announcement) passed ?x[]= straight into string functions. Bootstrap and the legacy rate-limit include now refuse them up front.
1248420 -
Fix
4:59 PM
Read text query parameters through queryString() so array-valued parameters stop causing 500s
Details
?tags[]=x on /t/game, ?type[]=x on /award-winners, ?packages[]=x on /compare and similar reached trim()/explode() as arrays and ended in a TypeError. queryString() treats anything that is not a string as absent.
439f80a -
Fix
4:58 PM
Let config.php set the admin and blocked lists, and log PHP errors in production
Details
- constants.php defined ADMIN_STEAM_IDS/BLOCKED_STEAM_IDS before config.php loaded, so the lists a deployment set in config.php were rejected as redefinitions and the built-in defaults were used. The defaults now apply after config.php.
- production ran error_reporting(0), which also stopped errors reaching the log; it now logs everything except notices and deprecations and still displays nothing
- say so in the log when config.example.php is being used in place of config.php
e67a0fb -
Fix
4:56 PM
Refuse placeholder and short MCP API keys; stop serving frontend/index.php with display_errors on
edbc8d9 -
Fix
4:56 PM
Cap saved profile blobs, validate recently-viewed ids, repair recently-viewed session lookup
Details
- profile widgets and layout are limited to 64 KB of JSON (413 beyond that)
- /user-experience/recently-viewed accepted any string and grew without bound; it takes org.package ids and keeps the latest 50
- backend/api/recently-viewed.php read $_SESSION['user'], which is never set, so it always answered 401
da4d33b -
Fix
4:55 PM
Validate profile event types, store only known fields, bound the table
Details
POST /profile-analytics accepted any type (an array raised an uncaught TypeError) and stored the whole request body. It now accepts the three event types the client sends, keeps validated fields only, prunes events older than 90 days, and notifications get a (steam_id, created_at) index for the list query.
7953bf0 -
Fix
4:54 PM
Only cache API responses the server allows to be cached
Details
The service worker stored every API GET and answered from it for up to five minutes, ignoring Cache-Control. /stats/live and the chart refresh returned stale numbers, and signed-in responses stayed in the browser after sign-out. no-store and private responses are no longer written, and the cache version is bumped so existing entries are purged.
8c3ec78 -
Fix
4:54 PM
Command palette accessibility and typing, contrast, and indexing of personal pages
Details
- command palette: '/' no longer swallows typing in inputs and textareas, the dialog/combobox/listbox roles and aria-activedescendant are set, Tab stays inside, focus returns to the opener, searches are debounced and a slower earlier response can no longer replace a newer one, recents store a slim record
- remove the 'g h' style shortcuts that could never match
- award-winners filter and release-notes button failed WCAG AA contrast
- noindex search, favorites, settings and notifications; robots.txt keeps crawlers off /backend/ and /mcp/
0a05404 -
Fix
4:48 PM
Bound client-supplied limits and rate-limit the public legacy endpoints
Details
- take/limit on player-extended, code-search, reputation history, announcements, notifications, package-types, similar-packages and leaderboard are clamped
- package-types built a cache key from the raw take, so every value made a new entry and a new upstream request
- 27 legacy endpoints that proxy Facepunch or read the database had no rate limit; they now use the existing legacy limiter
e3075f2 -
Fix
4:47 PM
Escape package, achievement, review and version-diff text rendered with innerHTML
Details
- reviewer name/avatar/steam id in package reviews went into markup and attributes raw
- achievement and package titles/descriptions on the profile dashboard were unescaped
- version comparison interpolated field names and values, and the version string into an onclick
- clear the three remaining ESLint errors
6183415 -
Fix
4:45 PM
Verify TLS on Facepunch calls, block cross-site writes, pin the canonical host, lock down init-user-reputation
Details
- four callers forced CURLOPT_SSL_VERIFYPEER off over HttpService's verified default
- bootstrap rejects POST/PUT/PATCH/DELETE that Sec-Fetch-Site or Origin mark as cross-site
- requestHost() allow-lists the Host header used for canonical, og:url and OpenAPI URLs
- init-user-reputation needed no login and accepted any string as a steam id
- escape REQUEST_URI in the UGC charts refresh link, encode the vanity name in the login redirect
- stop returning exception messages from the public themes and docs endpoints
99e63c1 -
Fix
4:24 PM
Semantics, a working phone layout, and live numbers that stop when the tab does
Details
Structure and accessibility
- Card headings were h2 inside h2 sections, and the range selector sat inside the chart's <h2> (a <div> in a heading, so its name read "Total players over time 24h 7d 30d"). Bands are h2, cards h3, and the selector is a button group with aria-pressed, matching the per-game widget.
- The headline numbers are a <dl>. They had aria-label on bare <span>s, which overrides the text for some screen readers so the number was never read, and aria-live on two values that changed every thirty seconds.
- The search box is type=search with a label; tag chips are type=button; card icons are hidden from assistive tech and the counts say "playing" and "favourites". "Day by day" KPI labels pass contrast (axe: 1 violation -> 0).
- The page set no description of its own; it now has one (via $pageMeta).
- Bands and jump links exist only for sections that have content, so a Facepunch outage no longer leaves headings over nothing, and the filter bar is not shown with no games to filter.
Layout
- Tag chips were crushed to ~30px on a phone ("ltipla", "hoote"). They scroll, with a fade, and carry a count.
- A phone showed one 16:9 poster per game, a ten-thousand-pixel page. Cards are rows there (7,600px); a desktop row is four cards and a page is 24, which divides evenly into 2, 3 and 4 columns.
- The sticky filter bar stuck at top:0, under the 60px nav. It sits below it and follows the nav as it hides on scroll; on phones it is not sticky.
- On a phone the 90-day card drew 30 columns under a heading naming 90 days. The date range now follows what is drawn.
- Cards were opacity:0 until script added .visible, so the section was blank without JS. They are visible by default; a <noscript> rule shows the ones past the first page.
- A footnote says why the chart (public games) and "Playing now" (the platform total) differ by a few percent.
Behaviour (frontend/js/global-stats.js, extracted from an inline script)
- Polling used to run in hidden tabs forever, fetched /stats (200 games summed) for three numbers, ignored a legitimate 0, and never refreshed the chart or the 24h peak. It now polls /stats/live only while the tab is visible, backs off on failure, says "reconnecting" after three misses, and refreshes the chart without a spinner.
- Search and tag filters show a result count, announce it, keep the filter in the URL (?tag=&q=) so it survives a reload or a shared link, include the #1 hero in the filter, and move focus when "Show more" runs out. Muted autoplay video is paused for prefers-reduced-motion.
Tests: 23 JS tests for the filter, count text, back-off and the DOM behaviour (hidden tab, outage, bad payload); the grid test now pins no-JS visibility.
4df8747 -
Fix
4:09 PM
Cache a slim game list, never cache a failed scan, drop duplicate KPIs
Details
The page cached the raw package/find responses - up to eight pages of five hundred full packages - to render about fifty cards, and a scan where every request failed was stored as "no games" for a minute, blanking the page for everyone. The scan, ranking and tag counting now live in backend/includes/global_stats.php: only the fields the page draws are cached, an empty scan is not cached and falls back to the last good one, and ties in the ranking resolve by votes then name so the order is stable between loads.
The catalogue totals came from a hand-rolled fetch with TLS verification switched off and a cache file written beside the code; they now go through fetchCachedEndpoint like every other Facepunch call.
The headline strip repeated the "Players per day" and "New players" figures (and their week-on-week deltas) that the Growth card shows a screen later, and "Games with players" / "Games tracked" were two spellings of one number. The strip is now live figures only, and "Games live" reads "of N games" using the size the API reports. "24h peak" said "today"; it is the rolling 24 hours.
Cards were gold, silver, bronze from #2, but #1 is the hero, so the podium was shifted a place; it now runs hero, silver, bronze.
557f31f -
Fix
3:53 PM
Span the x-axis over the selected range
Details
The axis was fitted to the first and last returned point, so with only a few hours of history every range drew the same chart. 24h/7d/30d/90d/1y now plot over the full window ending now, with the data where it falls; all still fits the data.
5e5edcb -
Fix
3:43 PM
Use whole-number y-axis ticks on small ranges
Details
The axis always drew six ticks across maxVal and rounded each label, so a game peaking at 1 player showed 1 1 1 0 0 0. maxVal is now a whole number and the tick count is capped at it, so a peak of 1 draws 0 and 1.
1c0a5af -
Fix
3:40 PM
Load the chart script through jsAsset so a stale edge copy cannot be served
Details
header.php loaded player-history-chart.js with no version query. Cloudflare caches static files as immutable, so after the chart rewrite the edge kept serving the old file, which has no PlayerHistoryChart.create, and Global Stats threw on load. jsAsset adds the content hash like the other scripts.
bebb8d1 -
Fix
3:33 PM
Close the chart fill along the baseline
Details
The fill path began with a moveTo from the baseline, then trace() issued its own moveTo, so closePath drew a diagonal from the last baseline point to the first data point. It was invisible when a series started at 0 and showed as a wedge under the line on Global Stats, where the total never reaches 0.
31ffd54 -
Fix
3:14 PM
Make the admin actions and docs match what the system does
Details
- the admin page called a bare 'php' for the collector and aggregator; use getCliPhpBinary() like the rest of the project, and force 'collect now'
- 'Prune Old Raw Data' deleted raw rows without rolling them up first, silently losing up to a week of hourly history; it now aggregates, then prunes
- ensure the tables exist so the page loads before the first collection
- document that only games with players are stored and that gaps are not zeros, and how the two schedules interact
- list the two tools in the README
992e7ec -
Fix
3:14 PM
One shared chart, honest states, no stale-response race
Details
- player-history-chart.js is now a shared core used by both the per-game widget and the global stats page; global-stats.php carried its own ~150-line copy of the same drawing and fetching code
- the latest range click wins: a slow earlier response can no longer overwrite a newer one while the range buttons show the newer one
- x is placed by timestamp, not by index, and the line breaks where the collector was not running instead of drawing a slope across the outage
- a failed request shows an error with a retry instead of 'no history yet'
- aggregated ranges show avg and peak in the tooltip and a low/peak band
- axis labels follow the span shown (no more 'Sep 29, Sep 29, Sep 29')
- the canvas is role=img with a summary label; status regions are announced
- drop the dead 'Reset Zoom' button, which was hidden by the script
1dd0d87 -
Fix
3:02 PM
One API, honest gaps, no double counting
Details
- delete backend/api/player-history-api.php: a second, broken copy of the controller (no dispatcher, undefined sendResponse, clashing class name) that /api/player-history was rewritten to
- move the queries into PlayerHistory and make the controller thin
- a game missing from a run that happened is a zero; no run means no point, so a collector outage is a gap instead of a fake drop to zero (this replaces the PHP zero-fill and its strtotime($ts.' UTC') parsing)
- timestamps are emitted by the database as ISO-8601 UTC
- the global total takes the highest sample per game per minute so overlapping collectors cannot double it
- '1y' now actually covers a year (it was identical to 'all')
- idents are lower-cased to match the collector, and a game with no data gets an empty series rather than a flat zero line
- summary is one query and averages over the runs that happened
37892b2 -
Fix
2:35 PM
Fill slots with no samples as zero players
Details
The collector only stores a row when a game has players, and the chart spaces points evenly by index, not by time. A game with a handful of scattered single-player samples therefore drew as a steady line at 1 for the whole range. The history endpoint now returns one point per slot (10 minutes for 24h and 7d, hourly for 30d and 90d, daily for 1y and all) with 0 where nothing was recorded.
e4adc4e -
Fix
2:28 PM
Answer the refresh-cache request with JSON and limit it to the owner or an admin
Details
The refresh button fetches /u/{name}/?refresh=1&ajax=1 and expects JSON, but player profiles cleared the caches and then rendered the whole page, so the button always reported "Failed to refresh cache". The player branch now returns JSON once the package cache is rebuilt.
The same request also let any visitor, logged in or not, clear a profile's vanity, package and presence caches. That now needs the profile's owner or an admin.
588b150 -
Fix
2:18 PM
Stop the forum lookup flood, make desktop nav keyboard-reachable, fix contrast and heading order
Details
- negative-cache missing package forums for an hour and stop logging their 404s: the production error log was 99% forum/{package}/threads 404s, one upstream request per package view
- navigation.js set tabindex=-1 on every nav link at load, even on desktop, so Home/News/Charts/Forums/Discover could not be reached with Tab
- lift the muted rank/count text on charts from ~3:1 to 5:1 contrast
- give the homepage trending and tag cloud headings a valid level
34ac8a8 -
Fix
2:06 PM
Harden icon font fallback, add skip link and form labels, honest outage states
Details
- hide Material Symbols ligature text until the icon font has loaded, so a blocked or slow font no longer shows raw icon names over labels
- add a skip-to-content link and focusable scrollable code blocks
- label code-search and skins filters, group the leaderboard podium
- show an em dash instead of misleading zeros on global stats when live data is unavailable
- return 503 with Retry-After and a clear message when the forum index is unreachable instead of a 404 Not found
f4ebdf7 -
Fix
1:50 PM
Drop user leaderboard endpoint that upstream answers with an empty body
Details
package/leaderboard/1/.../u/{userid}/{scope}/ returns 200 with no content for every request, real or not, and 401 for friends, so the route could only ever 500. Jam entry stays; docs record why the leaderboard route is not wired.
cb9c805
Generated from the project's commit history whenever the site is deployed. Times are server time.