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
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
September 2026
3
-
Fix
1:41 AM
Mark staff with the logo in the badge instead of a red square
Details
The Facepunch logo PNG is fully opaque - every pixel including the corners is #EC1C24 - so using it as a faint backdrop painted a red square across the author column and underneath the badges, whatever the size or opacity. The logo now sits inside the Facepunch badge, where it is meant to be solid, and the staff tint carries the rest of the signal.
Post one no longer carries a Started this badge: the post number already says it, and dropping the second badge row takes the card from 216px to 169px.
e435c95 -
Fix
1:31 AM
Drop the broken score glyph and pull the staff watermark inside the card
Details
The score prefix was written as a star but stored mis-encoded, so it rendered as stray characters in front of every score. The Facepunch watermark was an 8.5rem layer offset -1.6rem past the corner, which the card had to clip and which sat underneath the badges and the score. It is now a 3.25rem mark inset inside the column.
c5af5ff -
Fix
12:42 AM
Link the right profiles, and render what Trix was hiding
Details
Every author link was built from the display name - /u/xraybeesx - which 404s, because a name is not an address. The API hands us the address in Author.Url, either a vanity (/u/weregonlose) or the steam id for the many people who never set one, and forumAuthorUrl() now uses it: vanity when there is one, steam id otherwise, and nothing at all when the id is not a steam id, which is how the Facepunch "Bot" account stopped pointing at /u/1. A private profile has no page here - player/{id}/overview 404s upstream for those - so those names link out to sbox.game rather than to a dead end on this site. 56 author links across the forum now resolve; before this, any author without a vanity url was a broken link.
Quotes were being deleted. Facepunch stores them as a Trix attachment whose visible content is an empty <span>, with the author, the text and the quoted post id in a JSON attribute - and the sanitiser strips attributes, so a reply quoting someone rendered with no quote at all, just the response. The sanitiser now expands that attachment into a real blockquote before the allowlist runs, links the attribution to the quoted post (an in-page anchor when it is on the same page, sbox.game when it is not), and keeps escaping everything it reads out of the JSON, so markup in a quoted post is text.
Link attachments got the same treatment: a YouTube url becomes a click-to- play facade - the thumbnail plus a play button, no iframe until someone asks for one, so a thread full of videos costs a few images rather than a few players and nothing is requested from Google on page load. Other urls stay as plain links. Only the four class names this code writes survive the sanitiser, so none of this lets an author style their own post.
The thread page itself is rebuilt around the two-column shape a forum wants: a bar carrying the date and the post number, an author column with avatar, name, badges and score, and the body beside it. Posts by Facepunch staff - read from organization/facepunch, 38 members, cached six hours - carry a Facepunch badge, a blue frame and the logo as a watermark; when you are signed in, your own posts are framed in green with a "You" badge, the way sbox.game marks them. The thread header now reads "Started by X · 15 days ago · 14 posts · 815 views", which meant caching those fields alongside the title when a listing is fetched.
Also: forum.css had the same token drift the jam sheet had - var(--border-color) and var(--accent-color) are not tokens here, so every rule naming them fell back to a hard-coded colour. All of it names real tokens now. The badge modifier rules had landed after the base rule at equal specificity and were losing to it, so the staff and "You" badges were rendering identical to a plain one; measured in the browser, fixed, re-measured.
Verified: 18 routes clean, 0 broken author links, every piece of forum text at or above 4.5:1, no horizontal overflow, 57 sanitiser checks covering the quote, embed and profile-link paths.
254f27e
1
-
Fix
11:47 PM
Escape every meta tag exactly once
Details
Every branch in meta-tags.php pre-escaped its dynamic parts with htmlspecialchars and then concatenated raw literals onto them - the " - s&box Explorer" suffix, the "s&box, sbox, packages" keyword list. The output then disagreed with itself about what that left: og:title, og:url, description and keywords echoed the value raw, so a bare & reached the attribute, while twitter:title, twitter:description and both image:alt tags ran htmlspecialchars over it again and double-escaped whatever the branch had already escaped. A package titled "Tom & Jerry" shipped one tag saying "Tom & Jerry" and another saying "Tom &amp; Jerry".
Rather than chase the escaping through ten branches, the values are normalised once after they are all built: html_entity_decode then htmlspecialchars, which lands on the canonical single-escaped form no matter how the branch assembled it, and is idempotent, so a value that was already correct passes through unchanged. Re-encoding means nothing can come out less escaped than it went in. The four duplicate htmlspecialchars calls at the output are gone.
og:url picks up the fix too - a query string's & was reaching the attribute bare on every paginated and sorted page on the site.
Checked across 23 routes: no bare &, no &amp;, no PHP notices, and og:title now matches twitter:title on all of them.
2671346
5
-
Fix
10:55 AM
Version theme stylesheet URLs so edge caches release them
Details
.htaccess stamps every .css with `public, max-age=31536000, immutable`, by extension and without checking for a version query. head.php's inline preloader already appends ?v=<bundle filemtime>, but loadThemeCSS and preloadAllThemeCSS built bare `/frontend/css/ee/NAME.css` URLs. Those are therefore pinned in browser and Cloudflare caches for a year with no revalidation.
The theme repair deployed correctly and is right at origin - a cache-busted fetch returns the new NAME-theme selectors, and the five removed themes 404 - but the edge kept serving copies from before the deploy, including 200s for files that no longer exist. Every future theme change would have been swallowed the same way.
Both builders now route through themeHref(), which reads the asset version off the data-asset-version attribute the inline script in head.php already carries, so the URL changes whenever the bundle does and no purge is required. Versioned URLs are what makes the immutable header correct, so the header stays as is.
fafdf79 -
Fix
10:36 AM
Repair the 14 themes that never rendered, then curate the rest
Details
Fourteen of the thirty-seven themes targeted `body.theme-NAME` while all four class-derivation sites (themes.php, UtilityController, SettingsValidator, generate-critical-css) produce `NAME-theme`. Nothing server-side ever supplied the prefix form, so picking any of them applied a class matching zero selectors and silently did nothing. theme-template.css taught the broken convention, so anything copied from it was born dead. That is why the retro/console set looked unfinished - it was never rendering. Renaming their selectors also fixes the critical CSS, which had been emitting rules for 14 classes that did not exist.
Removed five themes that are perceptually redundant, measured in OKLab rather than judged by eye: brutalist (distance 0.0 from amoled-dark - identical ground and accent), moonlight (identical accent to palenight), catppuccin-frappe and catppuccin-macchiato (keeping mocha, the darkest, and latte, the only light theme), and midnight-blue (invented rather than an upstream palette, and crowded by kanagawa and tokyo-night).
Raised muted-text contrast to at least 4.5:1 on 21 themes by moving each colour toward that theme's own body text, so palettes stay intact. Game Boy is the one exception and is left failing: an authentic four-shade DMG LCD spans too little luminance to reach 4.5, and the first attempt reached it only by making muted identical to body text, which destroyed the text hierarchy. It now uses the fourth authentic shade.
Stripped dead duplicated rule blocks from 20 files, including a second differing *:focus block in sunset and brutalist where the earlier one was already dead under the cascade. Fixed rust.css's corrupted .beta-badge selector and win95's mojibaked checkbox glyph, whose font-size also referenced --fs-title on a 13px mark.
Deleted DARK_THEMES and the dark-mode class: the class was added and removed by the theme manager and read by nothing, and the list named a phantom theme-gta6 while omitting some twenty real dark themes. Fixed isCurrentTheme, whose startsWith('theme-') test marked Default as selected while a theme was active. Added pruneUnknownTheme so a stored theme that no longer exists is cleared instead of requesting a 404 stylesheet on every page load.
All 32 surviving themes now carry a display name and a distinct icon; the picker previously showed one generic palette icon for every theme, under names like "Ps1" and "Win95".
tests/test_themes.php asserts every theme targets the class the API derives and that the unreachable form cannot come back.
9e4cc5c -
Fix
9:52 AM
Three more entry points, found by sweeping the rest
Details
Probing every read-only endpoint under /api/ turned up three more failures beyond the four already fixed, and the same root cause in a new disguise.
/api/search answered 500 with an empty body. It does require bootstrap, but on line 11, after SearchAnalytics and SearchIndex on lines 9 and 10 - and SearchIndex pulls in cache_manager, which calls CacheManager::init() and dies on DB_HOST before bootstrap has run. Requiring bootstrap is not enough; it has to come first. The body was empty because the guard around those requires catches Exception, and an undefined constant throws Error, so nothing answered at all. It now catches Throwable.
/api/package-comparison and /api/cache-management had no bootstrap. Both reach the database transitively through cache_manager rather than naming it, which is why the first version of this test did not see them. cache-management is the worse of the two: it checks $_SESSION['is_admin'] and a CSRF token before doing anything, but it fatalled before reaching either check, and bootstrap is what starts the session those checks read.
analytics-api used a float as an array offset when picking a median ($durations[floor($count / 2)]). intdiv says what was meant and clears the only two errors PHPStan reported for backend/api.
The test now checks the rule that actually holds: an entry point that requires anything reaching config - cache_manager, api_functions, PostgreSQLManager, SearchIndex and friends - must require config/bootstrap.php, and it must be the first require. That rule flags all three of these and nothing else.
Confirmed working and not touched: storage returns 400 for missing params, settings and featured-badges return 401, docs returns its listing, package-leaderboard, similar-packages, package-info, package-types, organization-details, code-search, player-extended and package-changelists all answer correctly.
27fa6e6 -
Fix
9:44 AM
Four legacy endpoints died on every request
Details
.htaccess maps /api/<name> straight at backend/api/<name>.php, so those files are entry points, but four of them were written as though something had already set the process up.
/api/themes and /api/profanity-words called apache_setenv() unguarded. That function only exists under mod_php, and this host runs PHP as FastCGI, so both died with "Call to undefined function apache_setenv()" before sending a byte. backend/api/settings.php already had the function_exists guard; the other two now match it.
/api/health and /api/player-history died with "Undefined constant DB_HOST" in PostgreSQLManager, because neither loaded config/bootstrap.php and so no config had ever been read. similar-packages.php, which works, requires bootstrap first; these now do the same. A health check that fatals when called is worse than no health check, since an uptime monitor sees HTTP 200 with a stack trace in the body.
Reproduced locally before and after: the CLI has no apache_setenv either, so running the files directly shows the same fatal, and now shows JSON. health.php reports status healthy with a live database check.
tests/test_legacy_api_entrypoints.php holds the rule rather than the fix: apache_setenv only behind function_exists, and every database-using entry point in backend/api loads bootstrap.
Not changed: /api/leaderboard, /api/counters and /api/activity-stream answer 503 "Redis not available" by design - RedisHelper::isAvailable() returns false whenever CACHE_DRIVER is not redis, and production deliberately runs the SQLite cache. Their /api.php/ equivalents work.
f096548 -
Fix
8:51 AM
Stop repeating the jam win Facepunch already flairs, link it instead
Details
Every one of the twenty category winners across the four jams already carries a contest-winner flair reading "Won Best Least in Tech Jam, January 2025", so the badge added in the previous commit was a second copy of it on every winner page. The flair says it better than a chip can, but it goes nowhere.
The flair is now the link: jamForFlair matches a flair's tooltip to a jam by name, longest title first so "Game Jam III" is never read as "Game Jam II", and the badge becomes an anchor to that jam's page. A jam already named by a flair is dropped from the chip row, so nothing is said twice.
The winner chip is kept for the case the flair does not cover it. All twenty winners are flaired today, but the entry search is missing seven of them, so the two sources still disagree and the chip is what shows a winner the flair has not caught up with.
Checked against the live API: gbonn.surreal renders one badge linking to Tech Jam, apetavern.micromayhem one linking to Game Jam II, limestudio.roundbound its unlinked "new" flair plus a Game Jam III entry chip.
0e8436a
7
-
Fix
10:31 PM
Apply the cache-key and shell-escape fixes to the cron that actually runs
Details
2e945aa fixed backend/api/auto-refresh.php, but the caller in production is tools/scheduled-refresh.php, run from cron every 30 minutes. Minutes after the deploy it started a refresh for a "user" named player_all_packages_76561198162354937 - a cache key the player pages write, read back as a username by the same str_replace bug. It now requires the expected prefix and skips anything else, and the name reaches the shell through escapeshellarg rather than bare double quotes.
The same pattern is in tools/cron-refresh.php, tools/refresh-endpoint.php, tools/cleanup-invalid-cache.php and admin/refresh-status.php. None of them run: they are not in the crontab, nothing references them, and the admin page only displays. Left alone.
f600468 -
Fix
10:19 PM
Read the prune list from a file, since the host has no /dev/fd
Details
The first deploy to run tools/deploy-prune.sh printed
tools/deploy-prune.sh: line 29: /dev/fd/62: No such file or directory prune: moved 0 file(s) deleted since 09cea795
The host's bash has no /dev/fd, so the process substitution feeding the loop failed and the loop read nothing. "moved 0 file(s)" was true only because this deploy deleted nothing; a deploy that removed files would have reported the same and left them live. The list now goes through a temp file. Redirecting a pipeline also exposes its exit status to set -e, and git check-ignore exits 1 when no path is ignored, so the pipeline is guarded.
Verified on the server: process substitution fails there, the temp-file pipeline lists all 14 files deleted in 09cea79. The test now also rejects process substitution, which no local shell would have caught.
5d19018 -
Fix
9:21 PM
End the refresh worker's retry loop, and make deploys remove deleted files
Details
The background refresh failed 204 of its last 218 runs and retried the same handful of orgs every few seconds, attempt 309 and counting. Three bugs:
- BackoffStrategy cast 30 * 2^(n-1) to int before capping it. Past ~58 failures the float no longer fits and PHP casts it to 0, so the wait collapsed to nothing. It now caps first; a long streak waits 30 minutes.
- The worker treated an org with no packages as a failed fetch. codira, shadowstudio and lore are real orgs with zero packages, so they could never succeed. Only a failed request is a failure now; an empty list is cached.
- auto-refresh read every TYPE_USER_PACKAGES cache key as a username, including the player_all_packages_{steamid} keys the player pages write, and started workers for a user of that name. Keys without the expected prefix are skipped, and the name now reaches the shell through escapeshellarg.
The worker also re-queried eleven types for every org, one of them "texture", which Facepunch ignores and answers with the whole catalogue. The per-type sweep now runs only when the first pass hit its 2,000 cap, over the types that exist, including clothing, sound, vsnd and tmat, which it never asked for.
Deploys never removed anything: rsync has no --delete, so all 14 files removed in 09cea79 stayed live, and the dropped 2027 theme stayed in the theme picker. tools/deploy-prune.sh moves files deleted in git since the recorded build into ~/sbox-backups, skipping whatever .deployignore excludes, so server-owned paths are never touched. It does not use .gitignore, which ignores /tools/* and would have left deleted cron scripts executable. A failure only reports; the deploy carries on.
Also: browse chips for Addons, Collections, Entities and Terrain Materials (each larger than the Prefab or Shader lists), German "Prefabs" and "Sounds" in place of "Fertighäuser" and "Klingt", a successful webhook no longer written to error_log, and the comments removed from code deployed earlier.
2e945aa -
Fix
6:19 PM
What the pages told visitors that had stopped being true
Details
- Org links: org pages and the classic package sidebar read website, X and Discord off the Org object inside package results, which Facepunch now fills with Ident, Title and Thumb only. No org page showed its links or summary. They now come from the organization record (fetchOrganization, already cached 5 minutes), through orgSocialLinks(), which also stops owner-typed non-http(s) values becoming links and points handles at x.com rather than twitter.com.
- Sound Files filter: it asked for type:soundfile, which does not exist, and Facepunch ignores an unknown type - so it listed all 33,000 packages. It now asks for vsnd (8,876 sound files); /t/soundfile/ redirects there, and the label key is renamed in all 30 languages.
- Empty search: the advice to "try different boolean operators" was wrong - Facepunch search matches AND, OR and NOT as words ("car AND boat" finds more than "car"). A search with no results now says so, instead of suggesting the server might be down.
- Error state: the heading rendered as the raw key "common.errorLoadingPackages", and "contact support" pointed nowhere.
- Translations: a key missing from a language rendered raw, because the English fallback applied only when a whole file was missing. Every one of the 29 languages lacked the same 66 keys. Languages are now layered over English key by key.
chore(views): drop the comments added with the stale-copy fixes
f5c992b -
Fix
5:23 PM
Clear what the production logs were full of, and a few real bugs
Details
Found by reading error_log, the app log and ~/error_log on the server:
- Client errors: ~90% of the app log was unactionable. One crawler's bare "Script error." (154 in ten days), Applebot and Mediapartners-Google running tippy before Popper had loaded (107), and ResizeObserver loop notices. client-errors.php now drops those three before logging.
- form-validation.js called matches() on window/document, which Firefox hands a captured blur listener: 51 TypeErrors in ten days.
- The Facepunch circuit breaker counted only in Redis, so it did nothing in exactly the outage it exists for. It now falls back to APCu, verified on the production host with Redis absent.
- Bots asking for ?page=913198702 became skip=22829967525 and a 400 from Facepunch. getCurrentPage() caps at 10,000 (Facepunch lists ~33,000 packages, ~1,300 pages), and hidden gems uses it.
- 404s on org news posts join the suppression list; their links outlive the posts.
- fetchPackagesBatch remembers a failed ident for five minutes instead of refetching and relogging it on every view, and a 404 is not an error.
- Webhook "cooldown active" skips were ~2,000 error_log lines a month of the cooldown working; they go to debugLog.
- newspost.css used --text-color, which nothing defines. It resolved to the inherited --text-secondary, so that is now named explicitly and the CSS integrity check passes again.
- CSP (report-only): AdSense's googlesyndication, doubleclick and adtrafficquality hosts, and a frame-src, whose absence sent every ad iframe to default-src 'self'. The live .htaccess is patched by hand as before.
4ab05fa -
Fix
2:22 PM
Stop the gc_divisor startup warning and the Steam key in error_log
Details
The "PHP Startup: session.gc_divisor must be greater than 0" lines were not the site. getCliPhpBinary() started background jobs with the host's /usr/local/bin/ea-php84, whose php.ini sets the divisor to 0; the site itself serves from alt-php84, which is fine. Each spawn inherited the docroot as its working directory, so the warning landed in our error_log roughly every half hour. The CLI next to the serving PHP (PHP_BINDIR/php) now comes first: same version, same ini, no warning.
Steam outages also logged full request URLs, and every Steam Web API URL carries the account key. HttpService and SteamHttpClient now replace the key= value before a URL reaches a log line or exception message.
a31eb90 -
Fix
9:08 AM
Serve the last good catalogue, and keep the address that works
Details
"Unable to load skins data right now" was showing most of the time, and it was mostly this site rather than sboxskins.gg, which answers every page in about 0.2s. Two faults, each enough on its own.
The fallback never worked. Production caches in SQLite, and there a plain CacheManager::get() of an expired entry deletes it. fetchSkinsCatalogue() did that read first, then - once the fetch had failed - asked for a stale copy of the entry it had just destroyed. So any failure upstream reached visitors as an empty market for the ten minutes the failure was remembered. The last catalogue that loaded is now kept as its own entry for a week and served whenever a fetch fails; tests/test_skins_fallback.php reproduces the old behaviour against those SQLite semantics and fails on master.
The address pinning discarded the one address that works. sboxskins.gg's DNS rotates through a pool, and from this host part of it is unreachable: 216.150.16.1 and .16.129 time out every time, .16.65 and .1.129 answer in 0.2s. The remembered good address was only used when the current DNS answer happened to contain it, and it expired after ten minutes - shorter than the fifteen between catalogue refreshes - so each refresh gambled on the rotation again, and an answer of two dead addresses failed outright. It is now tried first regardless and kept for a day; it is re-proven on every use, and TLS still checks the certificate against the hostname.
fetchCachedEndpoint(), added for the jam and platform-stats pages, had the same stale-read pattern and so the same hole. It keeps a last-good copy too, so an outage at Facepunch shows slightly old jam data rather than none.
9f8a9b1
8
-
Fix
8:03 PM
Cover cdnjs, the loading-screen videos and GA's region host
Details
Second pass over the report-only log, from a package detail page rather than the listings.
cdnjs is a real dependency, not stray markup: Font Awesome loads on the profile and package pages and highlight.js on /mcp-knowledge, so it needs script, style and font rather than removing.
media-src was never declared at all, so the loading-screen videos fell back to default-src 'self' and every package page with one logged a violation.
The analytics host is the subtle one. connect-src already listed region1.google-analytics.com, but GA4 collects on region1.analytics.google.com
- a different domain - and picks the region by visitor location, so the
wildcard covers the other numbered hosts rather than waiting to learn each one from a fresh console.
1842d79 -
Fix
7:54 PM
Allow the hosts the service worker and Cloudflare actually use
Details
The report-only policy has been logging two violations on every page load.
Both are false alarms in the sense that nothing is blocked, but they bury anything real. service-worker.js caches thumbnails through fetch(), and a service worker fetch is governed by connect-src even when the response is an image - img-src already permits these hosts, which is why the pictures render while the console fills up. Cloudflare injects its own beacon script, so static.cloudflareinsights.com was never going to be in a policy we wrote.
The docroot .htaccess is excluded from the rsync, so this file documents the policy rather than shipping it; the live header needs the same edit by hand.
e5f605e -
Fix
5:34 PM
Shim requestIdleCallback, which Safari was throwing on
Details
The app log is 285 reports a day of "Can't find variable: requestIdleCallback", by an order of magnitude the largest entry in it. That is Safari's wording: Safari only shipped the API in 16.4 and older iOS has none, and three call sites used it bare.
The costly one is in theme-manager's UI init, which throws partway through, so date pickers, sliders, tags, the lightbox, charts, drag and drop, gridstack and masonry never initialise for those visitors. Nothing is visible in Chrome, which is why a full front-end audit went straight past it - it only showed up in the errors real browsers were reporting back.
Shimmed once at the top of main.js: it loads first in the shared runtime and everything after it is deferred, so it is defined before any caller, and a native implementation is left alone.
Reproduced against production with the API deleted before page scripts run - "ReferenceError: requestIdleCallback is not defined" - and confirmed gone against the same page with the shim.
bf85998 -
Fix
5:30 PM
Send expected skins-host timeouts to the app log
Details
sboxskins.gg is a small community server that is often slow, and its connect timeouts were the bulk of what was left in error_log - crowding out the faults that actually need someone to look.
They are not faults. Everything downstream already handles them: the failure is remembered for ten minutes and callers fall back to stale data, which is the whole point of the cache added earlier. So httpCode 0 from that host now goes through Logger::warning into logs/app-<date>.log, where it keeps more context than error_log ever had - endpoint, request id, IP, the cURL error - and stops sitting next to real problems.
A real status code still goes to error_log, because a 403 or a 500 means something changed upstream. So does a malformed body.
With the per-attempt suppression already in HttpService, an unreachable skins host now writes nothing to error_log at all.
fab45db -
Fix
5:17 PM
Stop a Redis outage writing a log line per cache lookup
Details
RedisCache::init() guarded on $redis being null, but a failed connect sets $redis back to null in the catch - so the guard never tripped. Every isAvailable() call reconnected from scratch and logged again, which is why a 70-second outage on 4 September put roughly 140 identical "Connection refused" lines in error_log: one per cache lookup rather than one per request. Each of those also paid a connect attempt before failing.
Tracked with an explicit attempted flag now, set before anything that can throw, so one attempt is made per request whatever the outcome.
Also suppresses 404s for organization/ lookups, which the list beside it already does for player/, package/get/, package/versions/, news/package/ and package/stats/. A miss there means the ident does not exist upstream - a stale link or a bot walking the URL space - and because any unknown top-level path is resolved as an org ident, every probe for /env, /weapon or /crashes wrote a line. Non-404s from the same endpoints still log.
tests/test_log_noise.php pins both, including the exact state the storm ran in: no handle, but already attempted.
f57d3a2 -
Fix
6:34 AM
/claim-org 404s in production
Details
The route was registered only in router.php, which serves the dev server. Production goes through .htaccess into index.php and reaches routing_logic's list instead, so the page worked locally and returned 404 on the live site - found by sweeping production after the deploy rather than localhost.
Every other route in router.php's $frontendRoutes is already in routing_logic. claim-org was the one gap.
Verified by serving the site through a router that mimics .htaccess - straight to index.php, no dev-only route table - where it now redirects to login like its siblings instead of 404ing.
3b5c1a6 -
Fix
6:05 AM
Keep the cPanel .htaccess out of the rsync
Details
The docroot .htaccess is tracked here but is cPanel's file, not ours. It carries the MultiPHP handler block, which cPanel regenerates and marks do-not-edit, and which the repo copy has never contained - so a deploy overwrote it, AddHandler went away, .php stopped executing and the site served an Apache directory listing until the original was restored from backup.
That is recorded in the changelog but nothing stopped it happening again: the file was still tracked and still absent from .deployignore, so the next deploy would have repeated it exactly.
The rewrite rules the repo copy adds were checked after that incident and are redundant against the server copy, and the server copy's protections are confirmed live - config.php, logs/, data/, .git/ and .env all answer 403 while index.php renders. So the server keeps ownership of this one file.
config/.htaccess and mcp/.htaccess are ours and still deploy.
e0bad46 -
Fix
5:52 AM
Build a sitemap for this site, and let the deploy carry it
Details
robots.txt advertises https://sbox.grimtech.co.uk/sitemap.xml. The file on the server listed thirteen URLs and every <loc> pointed at https://sbox.game/ - the upstream platform, not this site - so every URL handed to a crawler was the wrong domain. It also named /stats and /org/<name>, which are not routes here.
Nothing in the repo generated it, and .deployignore listed /sitemap.xml alongside .env and the server-owned config, so no deploy could ever correct it. tools/generate-sitemap.php builds it from this site's real routes and runs as part of npm run build, the same hook the changelog uses.
Package types come from the platform, filtered. Anyone can coin a type by publishing with it, so the raw list is mostly single-package noise - "exe", "jpg", and one obscenity that would have gone straight into a file submitted to Google. ShowOnIndex plus a count threshold leaves seventeen real ones. --verify checks every URL against a running server before writing, so a sitemap of 404s cannot ship; all 38 currently answer 200.
The same exclusion was hiding a second problem. meta/ holds dev tooling next to meta/changelog.json, which frontend/changelog.php reads at runtime, and the whole directory was excluded - so the build regenerated the file and rsync then dropped it. The live changelog stops at April 2, 2026 and shows ten commits. The tooling is excluded by name now and the data ships.
SITE_URL is defined rather than assumed: admin/warmup-redis.php already referenced it with a hardcoded fallback that was always the one being used.
756c326
Generated from the project's commit history whenever the site is deployed. Times are server time.