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
85 changes in September 2026 Clear filters
September 2026
2
-
Feature
10:08 AM
Exact verdict counts and a verdict filter
Details
The summary was worked out from the first 50 reviews. It now reads the exact Count for score 1, 2 and 3 from package/reviews, and the list can be filtered by verdict server-side with an optional score parameter.
score is validated to 1-3 and dropped otherwise. The count requests use take=2 because upstream rejects take=1 and omits Count.
04e57e8 -
Maintenance
9:39 AM
Drop unused getVersionedLeaderboard
Details
Nothing called it; the versioned per-user leaderboard endpoints need a signed-in Steam user ID, which Explorer has no flow for.
c8a18a8
4
-
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 -
Style
12:53 AM
Give the lists a hierarchy and cut the post card down
Details
Every level of text on these pages was dimmed with opacity rather than given a colour, so a thread title, its author line and its view count all landed on roughly the same washed grey and nothing led the eye. Sixteen of those are named colours now - titles at 16.4:1, body at 9.7:1, meta at 5.8:1, three distinct levels instead of one - and the only opacity left is on things that are meant to be faded: the staff watermark, a disabled pager step, the pager ellipsis.
Forum and thread rows had no hover and no padding of their own, so they read as text stacked in a card rather than as rows you can click. They have inner padding, a rounded hover, a focus-visible state that matches, and the forum icons sit in a tinted chip instead of floating at 70% opacity.
The post card was 247px tall for a single line of text. Measured against the screenshot: the header bar was 47px for one 12px line, and the author column stacked avatar, name, badges and score down the side at 152px, which set the height of the whole row no matter how short the post was. The badges and score share one line now, the avatar is 44px, and the bar and body padding came down. Same post: 146px, bar 30px, column 85px. The point was never to make it dense, it was that a one-line reply should not look like a wall.
Also: the permalink and pager steps were 19px and 22px, under the 24px touch target; both clear it now.
Verified at 1400, 820 and 390 across all three forum views: no horizontal overflow, no heading-level skips, every piece of text at or above 4.5:1.
be89488 -
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
8
-
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 -
Feature
11:31 PM
Share the winning game, and square up the labels
Details
A jam page shared on Discord or Twitter used the site's default og-image, so Game Jam 1 and Tech Jam were indistinguishable in a link preview. meta-tags.php already honours an image for a news post; the jam branch now does the same, and game-jams.php hands it the wide art of the jam's first winner with an alt of "My Summer Cottage: Tech Demo - Game Jam 1 winner". A jam with nothing awarded yet, like the running one, keeps the site image.
The hub cards said "Prizes" where the feature card and every detail page said "Prize pool". They all say "Prize pool" now.
The category badge and prize chip on a winner card sit over whatever artwork the entry shipped. At 65% black the white text could fall under 4.5:1 on a bright thumbnail - Terrain Pro's is a rainbow - so the scrims are 82% now, which holds the text at roughly 13:1 whatever is behind it.
Measured the rest rather than assuming: every piece of text these pages draw clears WCAG AA against its composited background, the muted 11px labels included, at 5.2:1 and up.
2ea3f69 -
Feature
11:07 PM
Give the jam pages a podium, and the hub real cards
Details
The four jam detail pages all rendered their winners as one flat row list, so Game Jam 1's £10,000 first place looked exactly like its £1,000 fifth, and Tech Jam's ten equal categories looked like a ranking they are not. jamIsRanked() tells the two apart - a jam is ranked only when every awarded category is titled "1st", "2nd" and so on - and the winners now render through views/partials/jam-winner-card.php: a podium of three for a ranked jam with the runners-up smaller beneath it, or an even grid of category cards for a jam like Tech Jam. Each card carries the entry's wide art, its category and prize, its org and its player and favourite counts, so a finished jam's page finally answers "who won, and was it any good" without a click. The grids use auto-fill rather than auto-fit, or a two-entry runners-up row would stretch each card wider than the first place above it.
The hub listed its four jams as text rows that said less than the detail pages did. They are cards now - status, theme, dates, prize pool, entries, how many categories were won, and for a ranked jam the game that won it. The running jam keeps its feature card and drops out of the list below, which is headed "Other jams" while one is live. The hub header was a hand-rolled .jam-hub-head; it is the site's .page-hero now, like every other page and like the jam pages it links to.
The detail pages never showed when a jam ran. The facts bar leads with "Ran 16 Feb - 17 Mar 2024" (or "Runs", live), and the hero icon follows the state instead of showing an hourglass over a jam that finished two years ago.
Removed: the dimmed winner-art backdrop behind the hero. At 12% opacity under the announcement banner it read as a rendering fault rather than a flourish, and the winner cards already carry that art at full strength. On the live jam, the prizes-and-votes grid was two equal columns, which left a 665px hole under the short prizes card; the prizes column is capped at 22rem now and the vote tally takes the rest.
Also: the newer half of the stylesheet had drifted off the design system - var(--border-color) is not a token anywhere in this codebase, so every rule naming it was silently falling back to a hard-coded rgb(); text was dimmed with opacity instead of --text-muted; font sizes were rem values between the --fs- steps. All of it now names tokens. A winner card with no package URL renders as a div rather than an <a href="#">, and .jam-winner__jam meets the 24px touch target it missed at 18px.
Verified at 1400, 820 and 390: no horizontal overflow, no heading-level skips, no console errors, every image has alt text. Load-more still pages 24 at a time and the countdown still ticks.
305143b -
Feature
10:41 PM
Rebuild the jam hub and cut the live jam page from 954KB to 202KB
Details
Game Jam III rendered all 179 entries at once: 480 package cards, 589 images, 954KB and three and a half seconds. The community vote card added another 80KB by rendering all 118 entries with votes so that a details element could hide most of them.
Entries now render 24 at a time. The Show more link carries ?show= and works on its own, and a small script fetches the same URL, swaps the grid in and updates the button and the address bar so the page never reloads. Sorting by most played, most favourited, newest or A to Z is a link rather than a script, so it survives with JavaScript off and each order has its own URL. The vote card now lists forty entries at most and says how many more are on sbox.game.
The hub was a live jam card and a four-row list, which said nothing about the jams as a whole. It now opens with what the four jams add up to - £135,000 in prizes, 478 entries, 20 categories won - and a gallery of the twenty winners across every jam, each with its thumbnail, category and prize, newest jam first. Winners come from Categories[] .Winner rather than the entry lists, which is the only source that has all of them.
Jam pages link to the jam before and after them, so the four are browsable without going back to the hub each time.
New styles follow the 4px radius the rest of the site uses and the card bodies are padded, both measured rather than eyeballed: the winners gallery, entry controls and jam navigation all sit 17px off the card edge at 1400px.
15943a4 -
Performance
10:32 PM
Serve forum pages from cache and stop every expiry costing a second
Details
A thread listing took 937ms on a cache miss and the entries lasted five minutes, so roughly every fifth minute somebody paid a full second for a page that had not changed. Profiling first ruled out the obvious suspect: sanitising fourteen posts costs 1ms, so the HTML pass is not worth caching and is left alone.
The forum fetchers now go through CacheManager::getOrSet rather than a plain read-then-fetch. That was already in the codebase and does two things this needs: it takes a lock so only one request regenerates while everyone else is handed the existing entry immediately, and its XFetch beta regenerates probabilistically before expiry so the changeover is spread out instead of landing on one unlucky visitor.
Lifetimes now follow how mutable the data actually is. The forum list itself changes about never, so an hour. Page one of a listing keeps its five minutes because new threads land there; later pages get half an hour because they only change when threads shuffle off page one. A thread page that is not the last page cannot gain posts, so it is held for six hours after it is read.
Forum activity keeps its own short mutex so a cold batch of ten upstream calls is not started by several visitors at once; the rest are handed the stale copy. It is best-effort rather than a real lock, since the worst case is two batches running and the cost is one wasted fetch.
Every fetcher keeps the last good answer for a week and falls back to it when Facepunch fails, which the tests cover along with the refetch on expiry.
Warm page loads are now 30-40ms across the forum index, listings, deep pages and threads.
bd13b1b -
Feature
10:25 PM
Rebuild the forum browsing, cross-link it, and restructure Global Stats
Details
Forums. The index listed thread and post counts and nothing else, so a forum with 489 threads looked the same as one with 29. Each forum now shows its newest thread with who replied last, the page opens with the six most recent threads across every forum, and the header states the 2,635 threads and 12,417 posts the whole board holds.
Thread lists gained sort controls covering the four orders the API takes - latest reply, newest, most replies, most viewed - along with the author's avatar, when the thread started, and a last-reply column that links straight to the final page rather than the first.
Thread pages gained numbered posts with per-post anchors, a highlight and a badge on whoever opened the thread, an edited marker where the edit came more than a minute after the post, a back link to the parent forum, and a windowed pager at the top and bottom instead of bare previous and next links.
The forums are now reachable from the rest of the site: a top-level nav entry, the five newest threads on the homepage, and a card on player profiles showing threads that person started or replied to. There is no endpoint for a user's posting history, so that card reads the active thread listings and says so rather than implying it is complete.
One batch of ten calls feeds all three of those, cached for fifteen minutes. Only /forum itself is allowed to pay for a cold fetch; the homepage and profiles render from cache or skip their block, because Facepunch serialises concurrent thread listings and fourteen of them took fifteen seconds. The index and the homepage previously warmed two different cache keys, so the homepage block could never have populated.
Global Stats. Eight equal-weight metric cards opened the page with no sense of which mattered, and four unrelated sections followed with no headings between them. The page now leads with five headline numbers - players now, 24h peak, in the editor, players per day and new players, the last two carrying their week-on-week change - with catalogue size demoted to a single quiet strip. Everything below is grouped under Right now, Growth, Retention and Top games, with jump links in the header. Every id and class the page's scripts bind to is unchanged; the metric card styles they replaced are deleted.
Padding and radii. The new cards inherited no body padding, so icons and text sat flush against the card border, and the new sheets had invented pill shapes and 7 to 14px corners. Radii now come from --radius-sm like the rest of the site, which caps at 4px; the only exception is the 50% on avatars, matching the site's existing use.
b1d75e6 -
Feature
10:06 PM
Pool seven cohorts and draw the platform baseline behind each package
Details
A single day's cohort was too small to read: facepunch.sandbox's was 39 players, which put a 0.0% on both the day-7 and day-30 tiles and left gaps in the bars that looked like missing data rather than churn.
Retention now pools the seven daily cohorts ending 35 days back, which is the oldest window where every cohort still has a full day-30 point. Pooling is weighted properly - day N is the sum of returning players over the sum of cohort sizes, not an average of ratios - so sandbox reads 305 players instead of 39 and the curve stops flickering between zero and one player.
Each package curve now draws the platform curve behind it as a dashed line and each tile says how far above or below average it sits, which is the only way a number like 14.8% means anything. The chart gained a percentage axis, a flat marker for days nobody came back, and day labels every five days instead of thirty crowded ones.
The seven upstream calls go out in one parallel batch and cache for six hours, including the empty answers, so a package with no cohort pays the cost once.
Stat names are humanised from their raw idents, a prefix every stat in a package shares is dropped, and the card lists the four largest with the rest behind a disclosure. The old 'N more stats' link pointed at the package page, which shows the viewer their own stats - on someone else's profile it led nowhere useful.
332e6bf -
Feature
9:51 PM
Wire the five unused Facepunch endpoints into pages
Details
The api/list sweep turned up five public endpoints nothing on the site read. Four now render; the fifth is confirmed dead upstream and is not worth another attempt.
Forums. forum, forum/{slug}/threads and forum/thread/{id}/posts are fully public and had no presence here at all. /forum lists the 18 real forums grouped by category, a forum lists its threads, and a thread renders its posts with each author linked to their existing profile page. The index also carries 4,722 auto-created per-package forums (Parent "p"); those are filtered out of the index because almost all are empty, and surface instead as a Discussion tab on the package page, which only appears when that package actually has threads.
Post bodies are Trix HTML written by other people, so they go through a new sanitiseForumHtml(): a DOMDocument pass with a tag allowlist, an attribute allowlist of href/src only, http(s)-or-relative URL checks, and rel/target forced on links. Everything else, including the data-trix-attachment payloads and any class or event handler, is dropped. The posts endpoint returns no thread title and no forum, so listings cache each thread's title and forum as they are rendered and the thread page reads that back, falling back to an excerpt of the first post when nobody has browsed the listing yet.
Per-user package stats. package/stats/2/{ident}/u/{steamid} is public and returns a full per-stat breakdown. It fills a new In-Game Stats card on player profiles for that profile's three most-played packages, and a "Your stats in this game" table on the package page for the signed-in viewer. Values outside a readable range are shown as an em dash with a count of how many were suppressed: facepunch.sandbox reports damage_taken as 6.8e38, which would otherwise wreck the column.
Review by steam id. package/reviews/{ident}/{steamid} gives one user's review directly, so the package page can show the viewer their own review without paging the whole list.
Retention. stats/platform/retention and package/{ident}/retention share one partial. Two upstream quirks drive the shape of it: a day with no returning players omits Users and Retention entirely rather than sending zeroes, so bare {"Day":3} rows are read as 0% instead of being skipped, and the curve stops at day 30, so the default cohort is 35 days back to keep a day-30 milestone. Cohorts under 20 players are not drawn at all; facepunch.walker's cohort is one person and produced a flat 0% curve that looked like a bug.
Not wired: package/leaderboard/{v}/{ident}/{stat}/u/{id}/global and /country answer 200 with an empty body even where the user is rank 1 of 1, /friends is 401 and version 2 does not exist. player/{id}/feed is 404 for every id tried. The existing package/{ident}/leaderboard/{stat} call already returns what those promised.
formatRelativeDate() takes a prefix argument now; it hardcoded "Published", which reads wrong on a forum reply and on a stat timestamp. Existing callers are unaffected.
9b28316
12
-
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 -
Docs
9:39 AM
Point the API section at a base path that answers
Details
Every endpoint in llms.txt was documented under /api/. That prefix only resolves single-word names that happen to match a physical file in backend/api; the .htaccess rule for anything with a slash in it falls through to the HTML front controller. So /api/packages, /api/package?ident=, /api/news, /api/status, /api/search/unified and /api/player - most of the file
- returned a web page rather than JSON to anyone who followed it. The working
router is /api.php/, which is what the site's own JavaScript calls, and every route listed here was re-checked against production through it.
The legacy single-word aliases are mentioned but not recommended: /api/health and /api/themes currently die with a fatal error, and /api/leaderboard and /api/activity-stream answer 503 because Redis is off in production. activity-stream is dropped rather than documented as something that never works.
Corrections found while checking the rest:
- similar-packages takes ?package=, not ?ident=.
- player-history takes ?ident= with range 24h, 7d, 30d, 90d, 1y or all, and its /global variant accepts only the first three. Both are now listed alongside /summary.
- There is no texture or particle package type; those are vtex and vpcf. The list also omitted clothing, vsnd, tmat and sent, which outrank most of the types it did name - clothing alone has over 1,500 packages. The upstream API reports 89 types, so the file now points at /api.php/packages/types for the live list.
- The cache TTLs were inverted: player data is 30 minutes and API responses are 5, not the other way round. Stale upstream responses are served as X-Cache: STALE, which was undocumented.
Game jams shipped without ever reaching the page list; both routes are added. Sixteen live endpoints that nothing documented are added too, and the MCP section now says how many tools there are and where the reference lives.
7a3b6bd -
Docs
9:28 AM
Correct the counts that had drifted, and cut it by a third
Details
The README disagreed with itself and with the code. It claimed 27 themes in the feature list and 24 in the theme table; there are 37. It documented PHPStan at level 2, which is 5. It described mcp/tools as "5 consolidated tool definitions" while listing sixteen tools and a six-file tree; there are seventeen files backing those sixteen tools. It pointed at bundle-css.bat, which no longer exists, and at /api-doc.php rather than /api-doc. It said "50+" scripts in tools/, where there are 81, and listed one file in docs/, where there are three. Languages were given as both 30 and "30+"; it is 30.
The Deployment section described "git pull origin master" on the server. That is not how this site has deployed for some time - pushing master to the cPanel remote runs .cpanel.yml, which builds, rsyncs against .deployignore, prunes files deleted since the last release and stamps the commit into config/build.php. That is now written down, without the host details.
Game jams, award winners, hidden gems, code search, storage explorer and package comparison all shipped without ever reaching the feature list. The list is now grouped by what a reader is looking for rather than being a flat run of twenty-three bullets.
The size came from two tables that existed only to go stale: every theme by filename, and thirty-five of the tools/ scripts. Both are now a sentence and a pointer, since themes are discovered from the directory at runtime and the script list changes weekly. 574 lines to 312.
e5c7d31 -
Refactor
9:04 AM
Cut the rail card back to what is about this package
Details
Theme, entry count and prize pool are facts about the jam, not about the package being looked at, and all three are already on the jam page a click away. The card is now the placing, any category won, and when the next deadline lands - six rows down to two.
It also no longer renders for a finished jam, where it had nothing left to say that the winner flair beside the title does not already say, and that flair now links to the jam anyway.
0e3171c -
Feature
8:58 AM
Carry the whole jam record onto package pages, cards and orgs
Details
The jam list already holds the theme, prize pool, entry count, deadline and full timeline, and jam/{ident}/voting holds the live tally. All of it was being thrown away after the one bit the badge needed, so the package page now shows the rest.
packageJamStanding carries the jam's facts and its current and next timeline step, worked out from dates rather than the Phase number, which has gaps. jamPackageRanks reads the package's position out of the tally by reusing jamTally, so competition ranking - two entries tied on twelve votes are both second and the next is fourth - is the same rule the jam page already uses.
The detail page gains a rail card - placing, theme, phase, what happens next and when, entries, prize pool - and its chip now reads "Game Jam III · 1st in Best Game" while voting is open. Package cards and org cards get a badge too.
The voting call is the only new upstream request, so it is made only when a package is actually in a live jam with community voting, and only by the detail page: fetchPackageJams takes withRanks, and the grids leave it off. The jam list and entry index are memoised per request, so a page of forty cards costs one lookup rather than forty. Measured: 0.004s warm for the detail page, 0.001s for forty cards. The jam page suppresses the badge on its own grid, where every card is an entry in the jam being viewed.
Live check: limestudio.roundbound reads 1st of 48 in Best Game on 40 votes, phase Building, next Nominations, theme One More Round, pool £50,000.
44bc01c -
Performance
8:52 AM
Stop loading a 300KB png for a 72px icon
Details
The package icon beside the title renders at 72 square but was served the full-size Thumb: 268KB for roundbound, 378KB for micromayhem and sandbox. Every package publishes Square webp variants at 32 through 512, and the 128px one is 2-5KB - a 99% saving on an image that carries fetchpriority="high" and so competes with the hero for bandwidth on first paint.
This is the same conversion the browse grids and homepage got in e7738c3; the detail page's own icon was missed. thumbSources falls back to Thumb, so packages cached before the change still render.
370b617 -
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 -
Feature
8:46 AM
Show a package's game jam entry and any category it won
Details
The package payload carries nothing about jams, so membership has to come from the jam endpoints. Two sources are needed, not one: the entry search (package/find/2?q=jam:X) is incomplete - Game Jam III reports 76 entries but returns 53, and seven of Tech Jam's ten category winners are absent from it entirely, because winners were not always tagged as entries. Category winners are therefore read from the jam list itself, which is already fetched and costs nothing extra, and unioned with the search results. A package that won a category it was never listed in still gets its badge.
packageJamStanding is pure so tests/test_jams.php can pin the rules without the network; the ident->jams index is built once and cached for an hour, since entries only move while a jam is live.
Verified against the live API: limestudio.roundbound resolves to a Game Jam III entry, gbonn.surreal to a Tech Jam "Best Least" winner, and a package in no jam to nothing.
58d8d96 -
Performance
8:29 AM
Stop shipping a 5MB icon font and full-size PNGs where WebP exists
Details
Two findings from measuring what the browser actually downloads.
The Material Symbols request left every variable axis open (opsz,wght,FILL,[email protected],100..700,0..1,-50..200), so Google served the complete variable font: 5,248KB. base.css renders every icon at a single point in that space - FILL 1, wght 400, GRAD 0, opsz 24 - so the axes are now pinned to exactly that: 447KB, a 4.8MB saving on a first visit. Weight ranges are not worth it; any range costs 1,208KB, so the two rules asking for wght 500 and the profile tag icons asking for 600 are set to 400, which at icon sizes is not a visible difference. material-symbols.css also imported Material Symbols Outlined, a second 3,887KB face that nothing could use: base.css overrides the outlined class to the Rounded family with !important. That import is gone, and head.php and the stylesheet now request one identical URL so it is fetched once.
Facepunch publishes resized WebP variants of every thumbnail - Square 32-512, Wide 160x90-910x512, Tall - in the same responses the site already reads, and the site was using the full-size PNG. thumbSources() picks the smallest variant that covers the space and falls back to the old field, so packages cached before this change still render. Measured, same pages: /t/model/ 1.10MB -> 0.60MB, /hidden-gems/ 11.23MB -> 0.67MB. The homepage reads its lists from a 30-60 minute cache, so it changes over as that expires; fetchCarouselPackages was verified directly and now resolves to a 320x180.webp.
The screenshot rail on package pages loaded the full-size stage image for each thumbnail. Screenshots carry their own Thumb, usually the same file but not always - one package had a 5.6MB PNG whose thumb is 114KB - so the rail uses it where it differs.
Org logos are left alone: the API exposes only Thumb for an organisation, the CDN ignores resize parameters, and some orgs upload 600KB PNGs shown at 24px. That one needs an image proxy or a word with Facepunch.
Three files show a large diff because git normalises their CRLF line endings to LF on staging under core.autocrlf=input; `git diff -w` shows the real change is 8, 3 and 7 lines.
e7738c3
11
-
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 -
Feature
6:47 PM
Bring the search page onto the current design, and make it tell the truth
Details
The page had not changed since May, and several things on it were wrong:
- It advertised AND, OR, NOT, phrases and parentheses. Facepunch's search has none of these, so the syntax panel and the script that validated it (advanced-search.js, loaded on every browse page and used by nothing else) are gone.
- "101 results found" for car was a count of what had been grouped; the real total is 3,704. It read a total key the fetch never returned.
- It asked Facepunch for 500 packages per search, which takes ~10.7s upstream, to show 10 per type. It now asks for 200 (~1.7s).
The page now uses the browse layout: the same header, search box and type chips, the organization matches as a row (with an exact organization match first when the query is one), then one row of the standard package cards per type, each with "See all" into /t/{type}/?tags={query}. The number of cards follows the grid so every breakpoint shows full rows. ?type= goes straight to that listing.
That listing now says what it is: "Models matching "car"" instead of "Browse Models", and type names come from the translations, so /t/vsnd/ reads "Browse Sound Files" rather than "Browse Vsnds". Addons, collections, decals, entities, terrain materials and tutorials get English names.
search-results.css and advanced-search.css keep only the rules other pages still use.
09cea79 -
Refactor
6:30 PM
One version of each page, no switches
Details
Removes the old package page and org page that ?v2=0 still served, and the Explorer 2027 beta theme with its ?beta=1 override, beta homepage and cookies. Everyone gets the current package page (package-detail-v2) and org page (org-v2); anyone who had picked Explorer 2027 falls back to Default, which the theme manager already does for a theme that no longer exists.
Deleted: views/package-detail.php, package-detail-sections.php, package-detail-sidebar.php, the sections only they used (media-stage, media-gallery, loading-screen, reviews-teaser, rail-reception), frontend/org-page.php, views/beta-home.php, includes/explorer-2027.php, css/ee/sbox-explorer-2027.css and css/features/beta-home.css. The hooks in index.php, bootstrap.php, head.php, package-list.php and theme-manager.js go with them.
test_package_tabs.php now checks the current package page, keeps every check on the shared tab and stage scripts, and pins that neither switch comes back. Shared stylesheets and scripts stay: each is still in use by the current pages.
640d45e -
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 -
Feature
8:05 AM
Platform counts from Facepunch, and the last 90 days
Details
Global Stats worked out "Playing Now" by paging through every game - four requests, about 2.5 seconds - and adding up UsersNow. Checked against stats/platform that missed about 8% of players, everyone in a game with no public listing, and it had no way to see the editor at all.
Worse, the live counter that polls /api.php/stats every 30 seconds summed only the top 200 trending games, so two seconds after the page loaded its all-games figure was swapped for a smaller one. Both now read stats/platform, the platform's own count, with the old sums kept as the fallback and is_estimated saying which one a caller got.
The page gains "In the Editor" - 122 against 108 playing when checked, more people building than playing - and a 24h peak that is there on load rather than waiting for the chart script to fill it.
The new "Last 90 days" section is from stats/platform/history, which gives daily unique, new and returning players, peak concurrency and playtime. It shows the trend the old page could not: daily players down from about 5,500 in June to about 2,000, new players from ~450 a day to ~40.
- Two single-series charts rather than one stacked. New players are 2-8% of daily players and would be an invisible sliver on the same bars; a second y-axis would invent a relationship between two scales.
- Plain HTML columns, not a stretched SVG, so the rounded ends and axis text stay crisp. The whole column is the tooltip target, and every value is also in the table behind "Every day as a table".
- The range ends at midnight so the last bar is a finished day, not a partial one reading as a collapse. Week and month comparisons only ever compare complete windows of the same length, and say nothing otherwise.
- Phones get the last 30 days; 90 columns at that width are hairlines.
db1379a -
Feature
7:57 AM
Game jam pages, with the live vote and every past winner
Details
Facepunch's public API has carried jam data all along - timelines, prize ladders, winners by category and a live community vote tally - and nothing on the site read it. Game Jam III is running now (building until 25 Sep, results 3 Oct), so this is the moment the page is worth most.
/game-jams lists every jam and features the live one. /game-jams/{ident} shows the phase timeline with how far through the current phase it is, what each place pays or who won it, the community vote ranked, and every entry through the existing package cards.
Not /jam or /jams: sbox has organisations with both idents, and a route here would have shadowed their pages. /game-jams is free.
Some decisions the data forced:
- One upstream call serves both pages. The jam list is byte-for-byte what jam/{ident} returns for each jam, so the detail page reads from the list, and a real jam cannot 404 because a second call failed during an outage.
- Timeline state comes from dates, not the Phase number. The numbers have gaps (1, 2, 4, 5, 6, 7) and the older jams skip steps, but every step has a date.
- Seven of Tech Jam's ten winners were never tagged as entries, so they showed as bare idents. The missing winners, and only those, are fetched through the existing package batch.
- Entries are filtered by the jam's own Query rather than a string built here, so a change to how Facepunch tag entries is followed automatically.
- Vote ranks are competition ranks with ties ordered by ident, so the list does not reshuffle on every cache refresh.
- The public list can be shorter than EntryCount (44 of 64 for Jam III), and the page says so rather than implying the grid is everything.
fetchCachedEndpoint() is the fetch-fresh-else-serve-stale shape that fetchPackageStats already used, pulled out because every new endpoint wants it. The platform stats fetchers beside it are used by the next commit.
tests/test_jams.php covers the timeline, relative dates, ranking, ties, unlisted entries and prize shapes: 42 checks.
7cea2ea
15
-
Feature
10:52 PM
Actually load tippy, which the site has been styling for nothing
Details
tippy-init.js warned "Library not loaded" on every page. It was the one entry in theme-manager's vendor list with a real consumer, and emptying that list left the initialiser, frontend/css/vendor/tippy.css and 55 title attributes on a package page all shipping with nothing to drive them.
Loaded in the footer above tippy-init.js rather than through the vendor loader, because that one injects asynchronously and would race it; defer keeps document order. The plain UMD build, not -bundle, because -bundle only adds tippy's own stylesheet, which would land after ours and undo the skin. Popper is a separate global either way. Both pinned with integrity - they are third-party and executable.
tippy.css only ever held the skin, which nobody noticed because it was never exercised: with arrow: true the arrow needs the geometry from the core stylesheet or it does not render at all. That structure now sits above the skin in the same file, so the colours still win.
The .gitignore change is why that file was awkward to commit: `vendor/` matches at any depth, so it swallowed frontend/css/vendor along with Composer's directory - the explicit fontawesome line below it was a workaround for the same thing. Anchored to the root it actually meant.
Verified in Chrome against a package page: tippy and Popper both defined, 65 elements initialised, the native title removed so tooltips do not double up, and the box measured 97x36 with a 4px radius and a drawn arrow.
7791f18 -
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 -
Refactor
5:41 AM
One shared runtime, and the defects a full-site audit found
Details
A global script layer already existed, but most of the runtime had drifted into a partial that exactly one page includes, so 23 standalone pages ran without theming, modals, lazy loading or keyboard shortcuts - and 20 of them had pasted theme-manager.js back in to compensate. One shared partial now carries what every page type needs. Modal runtime went from 9 of 32 routes to 32, the announcement banner from 4 to 32, and vendor 404s from 21 a page load to none: 20 of those 21 libraries had no consumer at all.
Removed 15 files and 4 API endpoints that nothing referenced. Three of the endpoints had returned 500 for nine months; deleting the fourth repaired /api/display/preferences, which the published spec documents and which a dead file had been shadowing.
sboxskins.gg was flooding error_log and hanging page loads. Failures are remembered for ten minutes rather than one, single items are negative-cached like the catalogue already was, and the per-attempt connect noise is dropped while the caller's line is kept. Cold failure 4.0s, warm 0.00s, two log lines where there were nine.
A crawl of every public route then found: disabled pagination arrows were anchors carrying href="#", and the CSS that should dim them was written as :disabled, which never matches an anchor; /t/<type> pages rendered "Browse Games" as their h1 but fell through to the bare site name in the tab; org news author links opened internal profiles in a new tab.
And the changelog page was invisible. One .date-group wraps all 15,331px of it and a scroll-reveal asked for threshold 0.1 - 1,533px visible at once, which no ordinary viewport satisfies - so it set opacity 0 and never fired. Its commit bodies also double-broke every newline and inherited the accent colour.
12b9a78 -
Feature
5:41 AM
Leaderboard and profiles on the v2 surfaces, and what the sweep found
Details
The leaderboard is reworked and profiles move onto the shared surfaces, finishing the v2 pass over the public pages.
Fixes found while sweeping the result: the news list claimed the feed had ended while posts were still unreachable, and its layout is now one wide post over rows of three as the live site has it; one page width throughout; a brand blue that actually passes AA; the no-radius-over-4px rule enforced rather than just stated; media named as upstream sends it, so a 404 upstream stops being reported as ours; the award index unstuck; and every page given a real title.
ca5cdc8 -
Feature
5:41 AM
Make v2 the default, and bring charts, home and global stats onto it
Details
v2 stops being opt-in. The package rail gets one inset rather than several competing ones, and its changelog link goes somewhere real.
The chart pages, the homepage below the hero, and global stats are rebuilt on the same card language, so the four highest-traffic pages now read as one site.
65e9fe4 -
Feature
5:41 AM
The same header and the same width on every page
Details
Pages disagreed about where content starts. Every page now shares one header and one measure, and the tops line up rather than each page beginning at its own offset.
The news article pages are rebuilt on the v2 language and the news list is paged instead of loading everything at once.
Releases 2.0.0.
e02774a -
Feature
5:41 AM
One type scale, one radius scale, and a shared component kit
Details
The site had grown several competing sets of sizes, weights and corner radii, so two pages built a month apart did not look related. This introduces the kit every later page is built from: a single type scale, a single radius scale, and shared surface tokens.
The package and organisation pages are rebuilt on it as the first v2 view, and every public page is then measured and corrected against it rather than adjusted by eye.
b2f033f -
Refactor
5:41 AM
Clear the stylesheet debt and stop routing to pages that do not exist
Details
The slow, low-contrast pages were carrying stylesheet debt behind them - rules that had been added over the top of each other rather than replaced. Cleared so the design work that follows has a level surface to build on.
Routing was resolving URLs to pages that were never there: a request for a name the site does not serve rendered a shell rather than a 404.
Also corrects claims the site made about itself that were no longer true, and removes the running commentary from the sources.
7542065
33
-
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 -
Maintenance
9:36 AM
Keep the stylelint config out of the webroot
Details
.stylelintignore was already excluded; the rc file it pairs with was not, and .json is not covered by the .htaccess FilesMatch deny list, so it would have been publicly fetchable dev clutter.
dc7ca62 -
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 -
Accessibility
4:54 AM
Give every form control a name, wrap wide tables
Details
21 selects, inputs and textareas had no accessible name at all: a screen reader announced 'combo box' with nothing more, and several had no visible caption either. Added aria-label to each, matching the visible caption where there was one.
MCP API Keys' five tables and System Info's four had no scroll container, so they pushed the page sideways on a narrow viewport instead of scrolling inside their own box.
The three pages using material-symbols-outlined now use the rounded set the rest of the admin uses and head.php preloads.
43c63dd -
Tests
4:50 AM
Lock in the invariants this refactor established
Details
22 checks over the admin tree: every nav link resolves, every page is reachable from the nav, the shell opens and closes exactly once per page, no page hand-rolls a head or footer, every POST handler validates CSRF through the shared helper, no native confirm(), no bare button variant, no inline <style>, no bare setInterval, and everything parses.
Writing it caught three more: mcp-knowledge's five row actions and the manifest review modal's close button were still missing the base .btn class, and analytics.php had a second <style> block buried in the body that the first extraction pass missed.
4655cac -
Maintenance
4:48 AM
Stop ignoring tracked tests and drop the dead footer.version string
Details
tests/ was in .gitignore while twenty of its files were committed, so every new test needed git add -f. Nothing on disk there is untracked, so removing the entry changes no file, only the friction.
footer.version ('Perpetual betaa' and its 29 translations) is referenced nowhere: the badge renders APP_VERSION and the deploy's commit hash, and takes its aria-label from footer.versionLabel. Removed one line per locale; all 30 files still parse.
8c8c183 -
Maintenance
4:46 AM
Restore the stylelint config and clear 3,148 ESLint errors
Details
npm run lint:css could not run at all: .stylelintrc.json lives on the server but was gitignored, so it was never in the repo. Pulled the server copy verbatim (it is the authoritative one) and un-ignored it; the lint scripts now cover admin/assets too, and the admin CSS is clean.
eslint --fix takes frontend/js from 3,163 errors to 15, all of it quote and indent formatting. Verified by node --check on all 57 changed files plus the vitest suite. Of the rest, 57 were no-undef against vendor globals (Swal, Chart, tippy and friends load as plain script tags) which the config now declares, and admin/assets/*.js is linted for the first time.
What is left is real, not formatting: 7 empty catch blocks, 2 useless assignments and 1 rethrow without a cause. frontend/css still reports 2,601 style-guide errors; those are auto-fixable but rewriting every site stylesheet is well outside an admin audit.
0e545c9 -
Performance
4:42 AM
Stop background polling and collapse two N+1 queries
Details
Four pages polled on a bare setInterval, so a left-open background tab kept hitting the database indefinitely; Advanced Analytics did it every five seconds. adminPoll() skips the work while document.hidden and refreshes immediately when the tab comes back.
MCP API Keys resolved a username with its own query for every banned user rendered; one DISTINCT ON lookup now covers the whole list. Cleanup Invalid Users opened a transaction and issued three deletes per account; it is three set-based deletes in one transaction.
191f35b -
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 -
Feature
4:29 AM
Replace every native confirm() with a styled dialog
Details
35 destructive actions were guarded by window.confirm(), which cannot show what is about to be deleted, looks different in every browser, and can be suppressed after a few dialogs, quietly turning a guarded action into an unguarded one. 23 inline handlers become data-confirm attributes and 12 JS call sites await adminConfirm().
The dashboard tool grid now renders from the nav registry, so all 29 tools appear grouped by section instead of a hand-picked 17.
d75a8ce -
Refactor
4:27 AM
Move all 31 pages onto the shared page shell
Details
Each page rendered its own <head>, stylesheet list and footer by hand. Five of them (analytics-advanced, manifest-removals, org-claims-full, reputation-management, webhook-settings) never closed .admin-content, <main> or .admin-layout at all, and another five called renderAdminFooter() in the middle of the page with content after it.
Page CSS (1,673 lines that were inlined into PHP and re-sent on every request) now lives in admin/assets/pages/*.css and is cached and lintable. Titles are normalised to '<Page> - Admin - s&box Explorer'; the two pages that pulled theme-manager.js, and with it a dynamic load of 21 vendor scripts none of them used, no longer do.
deeea32 -
Feature
4:23 AM
Add shared page shell, nav registry, command palette and confirm dialog
Details
The admin area had 31 pages each hand-rolling their own <head>, stylesheet list and footer, which is how the CSS list drifted, how five pages ended up never closing .admin-layout, and how the sidebar (29 tools), the dashboard grid (17) and reality (30) all disagreed about what exists.
- admin/includes/admin-nav.php is now the one list of tools
- admin/includes/admin-page.php renders the page shell
- Ctrl+K palette and a sidebar filter, for jumping between 30 tools
- adminConfirm() replaces native confirm() for destructive actions
- admin pages no longer pull the 8-script front-end vendor bundle
4cbf325 -
Style
4:00 AM
Drop the last package section heading icon
Details
config-details carried a tune icon and leaderboards a military_tech one; the other nine sections have none. All eleven headings are plain text now.
29b64b5 -
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 -
Feature
3:40 AM
Show the deployed commit in the version badge
Details
The footer now reads "1.0.0 [acc26c6]", so what is live is identifiable at a glance instead of having to be inferred from a git log.
The deploy already computed the hash for its own log line. It now also writes it to config/build.php, after the rsync and into the document root only. That file is generated and never committed, so rsync has nothing to overwrite it with and no install that was not deployed by the hook gets a stale value.
A generated PHP file rather than a text file read at runtime, so opcache holds it and no request pays for the I/O.
Degrades to the bare version when the stamp is absent, which is the case locally and on any manual install. The bracket is only rendered for a non-empty APP_BUILD, so it never shows an empty [].
26f4b22 -
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 -
Refactor
3:28 AM
Move APP_VERSION into constants.php
Details
It was defined in config/config.php, which holds the database credentials and CRON_KEY, is gitignored, and is excluded from deployment. So the one string on the site that changes most often was the one string git could never touch: bumping it meant a hand edit on the live server, which is how 1.0.0 got there.
constants.php is tracked and deploys normally, so a version bump is now an ordinary commit.
Guarded with its own defined() check rather than folding it into the block below, because bootstrap loads constants.php before config.php: an install whose config.php still defines APP_VERSION would otherwise hit a redefinition warning instead of quietly keeping its own value.
The live server was patched by hand to the identical content, verified byte-for-byte against this commit, so the next deploy is a no-op for both files and the footer never lost the version.
90e8177 -
Revert
3:24 AM
Keep .htaccess in the deployed set
Details
Excluding it was based on two wrong readings of the outage.
The cPanel-generated handler block is not required. The site is serving now, PHP executing normally, with an .htaccess that does not contain it, so the AddHandler is configured at server level and that block is belt-and-braces cPanel writes in. The outage was entirely the document root dropping to mode 700 under rsync -p; restoring .htaccess from the backup was chasing the wrong cause and did not fix anything.
The five /api/* rewrites are also not redundant. I tested those routes after the repo version of .htaccess had been put back on the server, so they passed because those very rules were live, and I credited an upstream fallback that does not exist.
.htaccess is a normal tracked file and deploys like any other.
c87be7a -
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 -
Maintenance
3:20 AM
Ship 1.0.0
Details
The footer badge had read 'beta 0.0.3' for about a year. The site has a full feature set, a public API, an MCP server and 30 locales; the beta label stopped being accurate a long time ago.
APP_VERSION lives in config/config.php, which is gitignored and excluded from deployment, so the live value was changed on the server directly. This updates the template so a fresh install does not start at 0.0.1 again.
950b1cb -
Feature
3:16 AM
Add the cPanel deployment config
Details
Builds in the server-side clone, then rsyncs into the document root with .deployignore holding back everything the live site owns.
No -p on the rsync. With it, rsync stamps the source tree's permissions onto the destination including the document root itself. The repository cPanel creates is mode 700, so a -p deploy left the docroot at 700, the web server's group lost traverse, index.php stopped executing and Apache served a directory listing instead. Without -p, existing files keep their own permissions and --chmod applies only to files rsync creates. The explicit chmod 750 afterwards is belt and braces on the one permission that takes the site down.
Lives on dev. master is production: the deploy hook fires on a push to the checked-out branch there, so master is only ever reached by a deliberate merge.
524be0f -
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 -
Maintenance
2:59 AM
Add the rsync exclusion list for cPanel deployment
Details
Names every path the live site owns and git must never write over: the SQLite databases and their write-ahead logs, everything under data/ (chart_history is appended to on ordinary page views), the caches, the logs, .env, config/config.php, and the config files the site rewrites at runtime. Also drops development-only files that have no business being publicly readable.
Committed ahead of .cpanel.yml on purpose. cPanel deploys on push as soon as it sees .cpanel.yml, so the exclusion list goes up first and gets verified with a dry run before anything is armed.
b80ff07 -
Maintenance
2:35 AM
Clean up the repository root and unbreak the lint tooling
Details
Audit of the 27 root-level files. Most were fine; these were not.
Dead ignore files ----------------- .eslintignore has been unsupported since ESLint 9, and package.json pins eslint ^10 with a flat config. Its four patterns move into an ignores block in eslint.config.js, which is what actually keeps frontend/js/dist out of the lint run: lint:js globs frontend/js/**/*.js, so dist was in scope.
.prettierignore had nothing to ignore for. There is no prettier dependency, script or config anywhere in the repo; formatting is php-cs-fixer, eslint and stylelint.
composer.lock ------------- The blanket *.lock rule was excluding it, so PHP dependency versions were not reproducible. This is an application, not a library, so the lockfile belongs in git. Narrowed the rule and generated the lock.
Database dumps -------------- A 38MB pg_dump of the production database was sitting in the webroot with no reference to it anywhere, kept out of git only by .git/info/exclude, which is per-clone and not shared. Moved out of the tree, and now ignored by name so the next one cannot be committed by someone whose clone lacks that local exclude.
Apache was not denying it either: the block list covered env, ini, log, sh, sql, bak and config, but not dump. Added.
Docs ---- facepunch.md and MCP_TOOLS_AUDIT.md move into docs/, which already exists and held one file.
facepunch.md also documented three things that are not true, which is what sent global-stats looking for live games in the wrong place. Verified against the live API and corrected: sort only works as a token inside q, not as a query parameter; downloads and rating come back in exactly the same order as an invented token, so they are not honoured; an unrecognised token silently falls back to popular rather than erroring; and take caps at 500, with 501 returning HTTP 400.
Also removed: SCMM.json, a 420KB OpenAPI spec for a service that shut down. Its consumer went with the skins rebuild and skins-retired.php now says as much on the page. It was never tracked.
d063b01 -
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 -
Feature
2:19 AM
Add the s&box Explorer 2027 opt-in
Details
Borrows the shape of the in-game menu (icon rail, filter pills, content rows with a lead tile, activity sidebar) but keeps this site's own visual language: our tokens, our type scale, our spacing. Deliberately not a reproduction of it.
The colours are a normal entry in frontend/css/ee/, so Settings > Appearance picks the theme up automatically like any other. The layout is server-rendered though, so the server has to know the choice before it renders; it learns it from a cookie theme-manager.js mirrors alongside localStorage. Resolution runs in bootstrap, before any output, because setcookie() silently fails once headers are sent.
The picker writes both cookies, which makes it authoritative. Without that, anyone who had ever opted out with ?beta=0 would pick the theme in Settings and keep getting the classic layout forever, with no way to fix it from the UI. ?beta=1 and ?beta=0 still work as shareable links and still win for the request they are on; they just no longer outlive the next theme change.
The homepage rows de-duplicate across each other, since the same package otherwise turns up in several of them and the page looks like it is repeating itself.
677a4bf -
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 -
Feature
2:18 AM
Rebuild the skins market on sboxskins.gg
Details
sbox.scmm.app stopped resolving (NXDOMAIN, not a transient outage), which left every /skins route rendering an empty page. sboxskins.gg exposes an equivalent JSON API, so the section is rebuilt on that instead of dropped.
Their robots.txt carries "Disallow: /api". The endpoints are public and unauthenticated, but that is a stated preference against automated use, so the cache TTLs are long, the User-Agent is identifiable, and the catalogue is paged through /api/items rather than making 221 detail calls.
Notes on their data, learned the hard way:
- priceHistory comes back newest-first, so it is reversed once on fetch rather than in each consumer. Left alone, the charts ran backwards and a "last N days" window returned the oldest N.
- sharePercent does not match the holdings it describes (92 units reported as 41.3%, 500 units as 0.9%), so shares are computed from quantity/totalSupply instead.
- ratingGrade is only returned by the list endpoint, never the detail one, so it is borrowed from the already-cached catalogue.
- priceChange24h is a percentage while priceChange6h is an absolute dollar delta, which is easy to render as the wrong unit.
SKINS_ENABLED gates the whole section for the next time a source dies. With it off, /skins used to fall through to the org handler and fuzzy-match an unrelated org page with a 200; it now returns a real 404 page.
Also fixes the homepage carousels, which all showed near-identical packages. The API ignores a &sort= parameter entirely: sorting is a token inside the query ("type:game sort:trending"). Sending it as a parameter meant every carousel got the same default ordering.
807922a
Generated from the project's commit history whenever the site is deployed. Times are server time.