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
88 changes in October 2026 Clear filters
October 2026
9
-
Feature
9:49 AM
One default profile layout for every user
Details
Drop per-user layout editing (profile-layout API, drag handles, reset); keep bio, social links and featured badges as widgets. Two-column band grid, balanced columns, hide empty Packages, cap Achievements to 6 games with masonry cards and skip full lists for games over 200 achievements.
2fe1896 -
Fix
8:36 AM
Save the Steam presence toggle where the profile reads it
Details
The presence settings endpoint wrote user_presence_settings, which nothing reads, while the page and profile read user_settings.steam_presence_enabled through SteamIntegrationManager. The toggle appeared to save but reverted on reload. Both routes now use SteamIntegrationManager, and the save is read back before reporting success.
99f048e -
Feature
8:20 AM
Rebuild the Settings page as a sidebar workspace
Details
Replace the tabbed three-column form with a sticky section nav and full-width content. Themes get CSS-rendered previews from palettes the themes endpoint now reads from each theme file, with search and dark/light filters. Language gets a searchable grid. MCP gets stats, a copyable endpoint and compact key management. New strings live in locales/en/settings.json.
Fixes found while auditing:
- delete_account now verifies the typed account name server-side
- account deletion also removes claimed_orgs, org_claim_requests and mcp_api_keys
- saving a theme merged nothing and reset other settings (including Steam presence) to defaults; it now merges with stored settings
- mcp-keys-api rotated the CSRF token on every call, breaking a second create/revoke; it now accepts the X-CSRF-Token header
- MCP key and rate limits are constants shared by the page and server
- setTheme accepts reload:false to apply without a page reload
763aeb8 -
Fix
7:56 AM
Stop the settings page rotating the CSRF token after the header prints it
Details
mcp.php called generateCsrfToken(), which issues a new token on every call. The header had already emitted the previous one in the csrf-token meta tag, so every settings save from that page (theme included) sent a stale token and got a 403. getCsrfToken() reuses the session token, so the meta tag and the MCP forms agree.
515288d -
Feature
7:47 AM
Redesign header and footer, rebuild themes around tokens, add eleven game themes
Details
Footer: three flat tiers (link groups, stats strip shown only when the status API is OK, legal bar). Social buttons and stat tiles are flat fills, and the language and support buttons no longer inherit hard theme shadows. Project and Community headings are translated in all locales.
Header: rebuilt from nav-config.php with a Discover dropdown, search trigger, user menu and mobile toggle, driven by navigation.js. Header loop variables are prefixed nav so they no longer clobber page variables such as the $link closure in /changelog. Header menus close and return focus when a button inside them is activated.
PWA install: the floating install button is replaced by an Install app entry in the Discover menu's Tools group. It stays hidden until beforeinstallprompt fires, prompts only on click, hides after use and on appinstalled, and never shows in standalone mode. On iOS it toggles an Add to Home Screen hint. The update toast styles now inject with the toast and use theme tokens, and the slideIn keyframes used by two other toasts move to base/animations.css.
Themes: all 32 existing themes set tokens on html.<name>-theme instead of overriding components. Game Boy, Rust and High Contrast are rebuilt, the retro themes lose broken effects, AMOLED is flat colour, and the Game Boy sub-stylesheets are folded into one file. Eleven game-inspired themes are added (Fallout 76, Starfield, Blueprint, Control, No Man's Sky, Aperture Science, Red Dead Frontier, Mirror's Edge, Subnautica, Windows XP, Black Mesa), bringing the total to 43. Blueprint uses opaque surface tokens, a quiet canvas-only drafting grid, corner ticks and a solid tooltip.
Package page: the header kept three columns until 760px, so wide action buttons squeezed the title to one word per line near 768px. It now stacks the actions below 1000px.
Cleanup: changelog commit hashes use 7 characters, the admin .user-avatar rule is scoped to .user-profile-header, Global Stats credit links use --link for light-theme contrast, and obsolete footer and header rules are removed from the shared sheets.
Known and left alone: 311 declarations use the invalid rgb(var(--color-*-rgb) / x%) form site wide.
a83efd5 -
Tests
3:32 AM
Make csrf_cors, mcp_security and js_referenced run on Windows
Details
csrf_cors and mcp_security spawned php -S with /dev/null for its output, which does not exist on Windows, so proc_open returned false and proc_terminate threw. They now use NUL there. js_referenced built its git ls-files command with single quotes, which cmd.exe does not understand, so the corpus was empty and every script in frontend/js looked unreferenced. It now uses double quotes.
All three were environment failures, not product bugs, and pass.
20d843c -
Feature
3:32 AM
Restructure Global Stats into Overview, Activity and Games views
Details
The page was ~6,000px of stacked bands, four near-identical bar charts and a full-width hero plus card grid. It is now three views sharing one shell, with each view loading only its own data.
Views
- Overview: live and last-24-hours stat rows, the live player chart, one daily chart with a metric selector (players, new, returning, returning share) and a compact most-played / rising leaderboard.
- Activity: growth stats with period deltas, longer online history, the daily chart with extra metrics (editor users, hours, peak), the retention curve (folded in from a standalone view that held a single chart) and the every-day table.
- Games: sortable, searchable, tag-filterable table with Show more, and the sbox.watch rising list.
Navigation
- Tabs are real links (?view=), enhanced to swap the panel from a ?partial=1 fragment with pushState, popstate and arrow/Home/End key support. The fragment is sent with X-Robots-Tag: noindex. Without script the links and a <noscript> rule still give the full game list.
- Each view has its own canonical, og:url and description. meta-tags.php takes an optional $pageMeta['canonical'] (a path with a single leading slash); pages that do not set it keep the existing strip-the-query behaviour.
- ?view=retention 301s to Activity's #retention section. Activity and Games are in the sitemap generator.
Data
- New players and sessions "today" do not exist upstream (history ends yesterday), so the page shows 24-hour totals and yesterday's new and returning players with the date. Returning share is returning / players per day.
- Rising is fetched once at the largest size and sliced for Overview so the views share one cache entry. The cached game list drops summary and orgTitle, which only the removed hero read.
Charts
- PlayerHistoryChart removes its resize listener once its canvas leaves the DOM, which tab switching would otherwise leak.
- The daily chart tooltip sits above the hovered bar, sizes to its content so it no longer wraps at the right edge, is frosted with a backdrop blur at the owner's request (a deliberate exception to the page's no-blur rule), and works on touch: a tap shows it and it lingers after the finger lifts.
- Stat values use the --fs-heading and --fs-figure tokens.
Tests
- Vitest covers filtering, sorting, the games table, the daily chart and touch. test_global_stats_grid and the new test_global_stats_meta cover the server side: view resolution, daily series, canonicals and descriptions.
Removes the unused platform-history view and the old page stylesheet.
748ddd4 -
Feature
2:26 AM
Use unused Facepunch and sbox.watch fields, load largest thumbnails
Details
Global Stats: platform reach row (unique players 24h/7d/30d, sessions, playtime) from stats/platform, and returning-player and editor-user charts, KPI and table columns from stats/platform/history.
Package page: Recommended Players and Play Modes in config details from Version.Extra, and the source stat on achievement cards.
Org page: all-time totals card from sbox.watch analytics/organizations.
thumbSources() now always returns the largest variant (Square 512, Wide 910) and no longer takes a target width, so org cards and other square thumbnails are no longer blurry.
7d69f14 -
Maintenance
1:54 AM
Accept api/list baseline (63 endpoints)
Details
Adds ai/query, ai/chat/create, ai/chat/query (401 without a game session), shader/1/{crc} and package/dll/1/{ident}/{rev} (engine-token binaries). None are usable by the site.
1f62ceb
2
-
Feature
12:49 PM
Api-list-diff --quiet so cron only mails on change
Details
cPanel cron mails any output, so staying silent when nothing changed turns the weekly run into a change alert.
5fe8e96 -
Feature
12:47 PM
Add api/list snapshot diff tool
Details
Fetches the Facepunch api/list and reports added, removed and parameter-changed endpoints against a saved baseline. Whitelisted in .gitignore so the deploy ships it for the server cron.
a866144
3
-
Fix
2:57 PM
Show the install button on desktop too, and use 4px corners
Details
beforeinstallprompt was deferred with preventDefault() but only mobile got a button, so desktop never called prompt() and Chrome logged the banner warning on every page. The button was also a 50px pill.
06f7605 -
Fix
2:53 PM
Serve service-worker.js with no-cache
Details
The immutable year-long rule for .js also covered the service worker, so Cloudflare kept the old script and its old CSP header. A service worker's fetches are governed by the CSP on its own response, so the widened connect-src never reached it. The docroot .htaccess is not deployed; the same block was added there by hand.
089546a -
Fix
2:47 PM
Restore review modal, guard null steam id on profile, widen CSP connect-src
Details
openReviewModal/closeReviewModal were lost in the admin shell refactor, so Approve/Reject threw. The profile favorites count fataled when the profile had no valid steam id. connect-src now covers the hosts the service worker fetches (cdnjs, fonts.googleapis, steam header CDNs, files.facepunch).
93018e3
74
-
Docs
10:30 PM
Rewrite the README against the current code
Details
Corrects PHPStan level (1, not 5), the cache TTL table (from cache-constants.php), the service list (FacepunchApiService, no PackageService) and the test commands (only tests/Unit has Pest tests; the standalone runner and Vitest are documented). Adds data sources, player history, the two kinds of MCP key, the SALT option, the daily session cleanup and the cron jobs, and replaces the fork-and-PR section with the actual closed-source status and house style.
fceaafe -
Feature
10:30 PM
Add Rising today, daily gains and server links from sbox.watch
Details
Global Stats gets a Rising today band: the ten games gaining the most favourites and upvotes in 24 hours, from the dashboard endpoint sorted by change24h. sbox.watch redirects that endpoint unless it is requested as sort then pageSize with no kind, so the request is built in exactly that order. Cached for 15 minutes like the other digests.
Game pages show Favourites today and Upvotes today in the Player activity card, and each live server links to its sbox.watch page. Only canonical https://sbox.watch/servers/... addresses are kept; anything else (other hosts, javascript:, look-alike domains, trailing newlines) is dropped.
Unit tests cover the rising normaliser (bad idents, negatives, clamping, maps excluded), momentum parsing and the server link filter.
56bc7af -
Feature
9:57 PM
Rebuild the changelog page as a readable release history
Details
The page was a raw commit log: ten commits a page (splitting days), a commits-per-hour joke, a stats wall with a one-person contributor list, a Last updated line that never rendered, and a 2,262 line stylesheet inside the global bundle.
It now has a hero with a Latest card (the newest day's best changes), a four tile summary, a Highlights default view (features, fixes, performance, security) with Features, Fixes, Performance, Maintenance and Everything filters, search, clickable month columns that show quiet months as zero, and pages of eight days so a day is never split. Commit bodies become paragraphs and lists behind a native Details toggle; no JavaScript is needed. Filtered and paged URLs are noindex; legacy ?type= links still work.
Filtering, grouping, search, input validation and body parsing live in ChangelogFeed with unit tests. The stylesheet is a small per-page file and no longer part of the bundle. Radii are 4px at most, including the credits page role label.
The generator now maps the PatrickJnr git identity to Patrick Jr., so the stats show one author instead of two.
c5f4b33 -
Fix
9:37 PM
Actually delete inactive session records after 30 days
Details
The privacy policy promises session data is deleted after 30 days of inactivity, but cleanupOldSessions() was never called. The scheduled refresh now runs it once a day around 03:00, and it matches on last_activity (falling back to login_time) so people who are still active are not removed. Covered by an integration test that seeds stale, long-login-but-active and recent rows. Also writes the audience key colour opacity as a percentage to satisfy stylelint.
af53781 -
Feature
9:37 PM
Rebuild the credits page and correct stale About and Legal text
Details
Credits is now About, Built by, Data & services, Technology, Thanks and an ownership statement, driven by backend/includes/credits_data.php. It no longer claims the project is open source, MIT licensed or open to contributions (the repository is private). Removed Redis, Fira Code, the build and lint tools and other things production does not use; added sbox.watch (with its attribution), sboxskins.gg, Ko-fi, Cloudflare, the front-end libraries and fonts that are actually loaded, and the profanity word lists with their licences (one is CC BY 4.0).
About: dropped the Redis, 'sub-200ms', '10-50x' and '10 specialized classes' claims and replaced the load-time tile with the language count. Legal: third-party list now covers advertising, Cloudflare, Ko-fi, sbox.watch and sboxskins.gg; the source code is described as private; log retention is 30 days (what the rotation script does, not 90); the IP wording says addresses are stored in full and only masked when displayed, as production has no SALT set. Last-updated date and version history brought up to date.
Translations: stale strings were removed from the 29 other locales so the corrected English shows instead, and the unused credits strings (including the old open-source ones) were deleted from all 30.
908a2af -
Feature
8:52 PM
Add sbox.watch analytics, organization stats and small-game chart bounds
Details
- Player activity card on game pages: rank, average and peak against the previous period, hour-of-day strip, update impact, session length, new and active players, 30 day approval trend and new vs returning players per day. Only a compact digest of the 200 KB analytics report is cached (15 min), and views tolerate digests cached before the new keys existed.
- Activity card on organization pages (peak 24h, members, last game update).
- Hourly averages keep two decimals so small games are not flattened; the chart uses decimal ticks under 4 players and draws sbox.watch history for those games as exact average plus hourly blocks up to the rounded-up peak, with the tooltip saying 'peak at least N'.
67bfac8 -
Fix
8:52 PM
Stop logging expected review 404s and guard missing SecondsPlayed on profiles
Details
Package reviews return 404 for packages nobody has reviewed, so HttpService no longer logs them as errors. The Recently Played card skips entries with no Package and hides the hours line when SecondsPlayed is absent, which was raising undefined-key warnings on signed-in profiles.
28ec260 -
Feature
7:56 PM
Restore long-range player history and add live servers from sbox.watch
Details
Integrates two operations of the public sbox.watch API (no key, no published licence or rate limit), server side through a new SboxWatch client.
activity-history gives platform and per-game history from before Explorer's collector existed, back to September 2025, which refills the Global Stats chart and the game Player History widget and adds 1y and All ranges to Global Stats. Our own data stays authoritative: the history API only uses sbox.watch when its series reaches meaningfully further back than ours (more than 10 percent of the range, at least an hour, or when we have nothing), one source per response, and says which in source and attribution. 90d maps to their 1y trimmed to 90 days. As the collector accumulates, sbox.watch is used for fewer ranges on its own.
packages/{game|map}/{ident} gives liveServers, which Explorer has no equivalent of, shown as a Live servers card on game and map pages. Playtime, users and sessions are not repeated; Facepunch's UsageStats already supply them. Dashboard, search, organisations, analytics and the server directory overlap existing data or would need new pages and are left alone; docs/SBOX_WATCH.md records why.
Caching is CacheManager::getOrSet per key (lock plus early expiry) with a seven day last-good copy as the stale fallback: history 5 min (24h), 10 min (7d), 30 min (30d), 6 h (1y, all); packages 5 min; a 404 is cached for the same TTL. Resilience: 3 s timeout, no retries, no redirects, 1.5 MB cap, a circuit breaker (3 failures pauses upstream for 2 min) and a budget of 30 upstream requests a minute site-wide, so arbitrary valid idents sent to the history API cannot be used to hammer them. The client builds sbox.watch's canonical URL (the default range=24h is omitted and range precedes game); anything else gets a 301 that the client deliberately does not follow.
Data is validated before use: idents (anchored with \z), allow-listed ranges and kinds, a fixed host, numeric points capped at 2000, server names stripped of control characters and clipped, escaped at output; addresses and upstream URLs are never stored or shown.
'Data provided by sbox.watch' links to sbox.watch under the Global Stats chart and the history widget when the series came from them (with a note when partialCoverage means older totals may omit games), in the Live servers card, and on the Credits page.
Tests: test_sbox_watch.php covers the normalisers and the source rule; test_sbox_watch_cache.php runs the client against a mock upstream that also enforces the canonical URL (hit, miss, 404, stale fallback, breaker, timeout, redirect, size cap, budget, and five concurrent processes causing one refresh); two chart tests cover the source being passed through. Checked locally against the live API and with sbox.watch unreachable.
c247d50 -
Fix
7:36 PM
Let Google see noindex instead of blocking the pages it indexes anyway
Details
Search Console reported pages indexed though blocked by robots.txt. They were URLs that public pages link to without nofollow, which robots.txt blocked, so Google indexed the bare URL and could never read a noindex: /auth/login.php and its ?return_to= variants (the sign-in link, on every page), /mcp/ (the MCP landing page) and /backend/api/docs.php?format=openapi (the API docs download).
robots.txt no longer blocks /auth/, and allows /mcp/ (exact) and /backend/api/docs.php, which are more specific than the existing Disallows. /auth/* and docs.php now send X-Robots-Tag: noindex, nofollow from sendNoindexHeader() in bootstrap, so the redirect and the JSON drop out of the index. Every sign-in link and the OpenAPI download get rel=nofollow. /mcp/ is a real page, so it stays indexable, gets a meta description and goes into the sitemap.
test_robots.php parses the real robots.txt with a most-specific-rule matcher (crawlable and blocked paths), checks the noindex paths, and that no sign-in anchor lacks nofollow.
da0bf73 -
Fix
7:33 PM
End the Steam launch link with a slash
Details
The link is now steam://run/590830//-rungame org.package/, matching the one sbox.game's own Play button uses, instead of ending at the package id.
af796dd -
Feature
7:25 PM
Play button that launches the game through Steam
Details
Game package pages get a Play button next to View on sbox.game. It links to steam://run/590830//-rungame org.package, the same launch sbox.gg uses, and lets the browser hand the steam:// URL to Steam, with no dialog or custom launcher. It is drawn only for packages of type game and only when the identifier is valid, and it is hidden on touch-only devices (hover: none and pointer: coarse), where Steam cannot launch it.
steamLaunchUrl() in helpers.php builds the link and validates through PackageValidator, which is also what the API uses. That validator's $ anchor accepted a trailing newline, so 'a.b\n' passed; it now anchors with \z. The s&box app id lives in one SBOX_STEAM_APP_ID constant, and the three places that carried 590830 as a literal (auth callback, steam_functions twice, the inventory URL) use it. The English label is common.play; other locales fall back to English.
test_steam_launch.php checks the exact URI for pcjr.emojislots and facepunch.sandbox, more valid ids, 22 malformed or hostile ones, the view wiring, the touch-device rule, and that the app id literal stays in constants.php.
38a5e14 -
Fix
7:11 PM
Stop the header and news meta reading array keys that are not there
Details
Production's config/config.php defines its own getCurrentUser(), which wins over the one in auth-helpers.php and returns is_stinky, not is_blocked. header.php read $user['is_blocked'] unguarded, so every signed-in page view logged an undefined-key warning (error logging became visible when production stopped using error_reporting(0)), and the Blocked badge never showed for blocked users. It now shows for either key.
meta-tags.php read $newsPost['title'] unguarded for the image alt text and keywords on a post with no title. Both now default to an empty string.
test_meta_news.php renders the news branch with and without a title and checks the header guard.
9e947ff -
Feature
6:46 PM
Index plus per-section sitemaps for games, orgs, news and jams
Details
sitemap.xml is now a sitemap index over sitemap-pages, -games, -orgs, -news and -jams. Games list every public game as /org/ident/ with its Updated time as lastmod (2007 of them), orgs are the ones that have a listed game (1174) and take their newest game's time, news is the platform posts and jams the current jam idents. Pages gain /forum and /ugc/charts/game|map, which were routable but missing.
Each section is built independently. If an upstream fetch fails the earlier file for that section is kept and still listed, and a section with no data and no earlier file is left out of the index, so an API outage at deploy time no longer ships a sitemap with holes. Files are written atomically, split at 50000 URLs into numbered files, and stale chunks are removed. Idents are validated, private games skipped, and the type list keeps its existing filter.
--verify checks every static page and a sample of each dynamic section against a running server. --out is now a directory. Generated sitemap-*.xml files are gitignored like sitemap.xml.
test_sitemap.php runs the generator against a mock API: sections, filtering, lastmod, and the outage fallback.
3a68fc8 -
Feature
6:33 PM
Add sbox_docs refresh, and fix clearCache missing the cached pages
Details
sbox_docs gains action=refresh with target all|docs|api. It clears the cached doc pages and index, re-reads the index, and re-downloads the API schema when asked. It needs an admin per-user key or the master key, is limited to one run per five minutes across all callers, and writes a refresh_docs audit entry (admin_id 'master' for the master key).
DocsService::clearCache built its keys from the full link path (/dev/doc/x.md) while fetch() caches under the slug (x.md), so the existing admin clear_doc_cache never removed a cached page, only the index. It now strips the prefix. McpAuth::isMaster() records that the request used the master key, since that key has no Steam identity and mcpIsAdmin() is false for it.
test_docs_refresh.php covers the key fix and the cooldown.
9c9bcd5 -
Fix
6:11 PM
Default the retention and history card headings when the caller sets none
Details
retention-curve.php and platform-history.php read $retentionHeading / $historyHeading in the ternary's true branch, so a caller that never sets one (the package page's retention section) got an undefined variable, an empty tag name and a broken heading. They now default to h2. test_heading_default.php renders the partial with no heading, with h3 and with an invalid one.
b4f9aa2 -
Tests
6:08 PM
Run under the node environment
Details
Under vitest's jsdom environment on Node 24 the worker's response.blob() returns a Blob that jsdom's Response stringifies, so the offline-cache test failed with a JSON parse error on '[object Blob]'. The worker has no DOM dependency, so the file runs in the node environment.
2da9a7e -
Docs
5:50 PM
Correct the PHPStan note now that there is no CI
65a777e -
Maintenance
5:50 PM
Remove the GitHub Actions workflows and add npm run check
Details
The repository is private and Actions minutes are billed. The checks the workflows ran are unchanged and run locally with npm run check; AUDIT.md records the decision.
46b6053 -
Docs
5:43 PM
Rewrite AUDIT.md with final states, credential evidence, the CSP path and the advisory table
Details
Every finding now ends in Fixed, Partially Fixed, Deferred or Not Actionable. The summary counts are generated from the finding tables by tests/test_audit_counts.php, which runs with the hermetic suite so they cannot drift. Adds the Post-Audit Hardening section.
cac9227 -
Fix
5:40 PM
Name the package tabs on phones, where the label is hidden for inactive tabs
39220ce -
Fix
5:38 PM
Finish moving inline handlers to delegated actions, label the settings tabs, and tune the CSP report path
Details
- remaining JS-generated onclick/onerror handlers (reviews retry, PWA update banner, claimed-orgs retry, search retry, version compare, achievements expand and icon fallback) use data-action or listeners; tests for the review and achievement renderers cover hostile names and titles, fallbacks and error states
- settings tabs get ids (the panels' aria-labelledby pointed at nothing) and accessible names, which the phone layout hid with the visible text
- CSP: the service worker's fetch() of fonts and styles was missing from connect-src, found from the collector's own reports; reports use a separate 30/min bucket so they cannot use up the API allowance; the Report-Only header is sampled at 10% (CSP_REPORT_SAMPLE_PERCENT)
- README: the schema is not auto-created; run create_consolidated_schema.php on an empty database first
1caa6dc -
Fix
5:29 PM
Remove a duplicate obscureIpAddress() and add an authenticated smoke test
Details
The PHPStan commit's claim that obscureIpAddress() did not exist was wrong: it is defined in config/session_tracking.php, which analysis was not scanning. The duplicate added to helpers.php was a fatal redeclaration for every signed-in user, found by the post-remediation sweep. tests/test_authenticated_smoke.php drives signed-in pages and the recently-viewed API against a running site and fails on any 5xx or PHP error; it fails with the duplicate present.
85270ba -
Maintenance
5:27 PM
Track tools/.htaccess and tools/run-phpstan.sh
Details
/tools/* is ignored apart from a whitelist, so the deny file protecting the operator scripts and the PHPStan runner had not been committed; found by running the CI commands from a fresh clone.
307983d -
Maintenance
5:26 PM
Separate tests into hermetic, integration and live, and harden CI
Details
- tests/run-php.sh [hermetic|integration|live|all]: assertions forced on, APCu on; a test that cannot run says why and exits 2, which fails the run; sections skipped inside a passing test are counted and shown
- test_player_history exited 0 when no database was available and test_tool_auth exited 0 with the example CRON_KEY: both silent passes, now exit 2 with a reason
- tests/_support.php: requireDatabase / requireLiveApi / requireAssertions; tests/setup-integration.sh writes a config.php for integration runs
- ci.yml: frontend (lint, tests, builds), php (syntax, hermetic tests, pinned PHPStan) and integration (PostgreSQL service, real site) jobs; no secrets, no external APIs, concurrency and timeouts set
- audit.yml (npm advisories) and live.yml (Facepunch API) are scheduled/manual so third parties cannot fail a pull request
d9d67e4 -
Maintenance
5:23 PM
Run PHPStan at level 1 with no baseline, and fix what it found
Details
phpstan.neon carried ~90 ignore patterns that hid every class of error; it now runs level 1 (unknown functions, classes, methods, constants; possibly-undefined variables) over backend, auth and mcp with seven narrow, commented ignores. tools/run-phpstan.sh (npm run analyse:php) uses the version pinned in composer.lock.
Real bugs found:
- obscureIpAddress() did not exist, so the personal-data export fataled for anyone with session history
- cache-management-api called CacheManager::getInstance(), which does not exist (the class is static), so invalidating by tag or pattern fataled; pattern LIKE wildcards in the input are now escaped
- search-api sent Cache-Control: public for the signed-in user's search history and the admin analytics
- two CompressionMiddleware classes (pages and API) shared a name; the API one is ApiCompressionMiddleware
- backend/controllers/ duplicated BaseController and PackageController and nothing loaded it; backend/services/PackageService.php called functions that do not exist and had no callers
- an invalid MCP tool example repeated the 'action' key
6533174 -
Refactor
5:18 PM
Ensure claimed_orgs.is_public from deployed code, not per request
Details
The deploy has no migration step and the base schema is created by initializeDatabase() in config/config.php, which is never deployed, so existing installs can only be upgraded from code that is. ensureClaimedOrgsSchema() runs once per process and only when the catalogue lacks the column, replacing the unconditional ALTER; it is also called by getUserClaimedOrgs(), which selects the column and silently returned nothing on a fresh install until the claims API had been hit. config.example.php creates the column for new installs. tests/test_org_claims_schema.php covers existing and fresh installs.
2197b97 -
Maintenance
5:17 PM
Remove five frontend scripts nothing loads, and guard against new ones
Details
cache-management.js, dependency-graph.js, notification-bell.js, homepage-categories.js (an ES module the non-bundling build could never have run) and its only importer homepage-utils.js. Checked against templates, the core script list, imports and dynamic loads, the service worker, build config and tests. tests/test_js_referenced.php fails if a script in frontend/js is not referenced by another tracked file.
fbe3f30 -
Fix
5:16 PM
Consolidate recently viewed on RecentlyViewedManager
Details
- one store: the recently_viewed table behind RecentlyViewedManager, keyed by $_SESSION['steam_id']; package pages, recommendations and the profile page already used it
- the manager now creates its table on PostgreSQL (the only migration that did was SQLite-only), validates package ids, and takes an injectable clock
- /user-experience/recently-viewed POST wrote user_recently_viewed, which nothing read; it now tracks through the manager. DELETE had no route, so the profile page's Clear history button got a 404; it exists now
- user-experience-api.php no longer calls track() with a null user for anonymous POST/DELETE
- account deletion also removes recently_viewed rows
- remove backend/api/recently-viewed.php: wrong session key and a column that does not exist, no caller
- tests/_support.php: integration tests fail with a reason instead of silently passing; tests/test_recently_viewed.php
2ebdfa3 -
Maintenance
5:14 PM
Take the in-range fixes for all 15 npm advisories
Details
npm update within the existing semver ranges (package.json unchanged): postcss, esbuild, vitest/vite, browserslist, nanoid, js-yaml, svgo, undici, fast-uri, brace-expansion, colord and their transitive dependencies. npm audit now reports 0 with and without dev dependencies; the CSS bundle is byte-identical and lint, tests and builds pass. npm 10.9.4's resolver crashes on this tree, so the update was run with npm 11 via npx; the lockfile format is unchanged and npm ci accepts it.
c391e49 -
Security
5:13 PM
Nonce every inline script, add a Report-Only CSP with a report collector, and start migrating inline handlers
Details
- cspNonce() on all 51 inline <script> tags; backend/includes/csp.php sends a strict Report-Only policy (no script 'unsafe-inline') for HTML pages and /backend/api/csp-report.php logs violations
- actions.js: delegated data-action clicks and data-fallback-src image fallbacks replace inline handlers; migrated mcp-tools (52), image onerror (24), the async font preloads and the 404 back button
- tests/test_csp.php fails if a template gains an inline script without a nonce
fea57c3 -
Security
5:07 PM
Reject example secrets everywhere, deny HTTP access to internal directories, remove the web installer
Details
- isPlaceholderSecret() is the one check for MCP, cron and Ko-fi keys; the README's sample MCP key was 20 characters and would have been accepted
- the root .htaccess is not deployed (cPanel owns it), so include-only and operator directories (tools, backend/includes, mcp/lib, ...) get their own deny files; tools/ holds 33 scripts with no web guard and was reachable
- remove tools/setup.php: unauthenticated, locked only by a file's absence, wrote .env with unsanitised input, and its include paths no longer resolve
91bd0ed -
Docs
5:03 PM
List the live-API tests
3c96eab -
Docs
5:02 PM
Add AUDIT.md
c5fbe62 -
Performance
5:01 PM
Add claimed_orgs.is_public only when it is missing, not on every request
1e30659 -
Maintenance
5:01 PM
Add a hermetic PHP test runner and CI, drop unused npm packages
Details
- tests/run-php.sh (npm run test:php) runs the 32 standalone tests that need no config, database or network, with assertions forced on: test_global_stats_grid relies on assert() and passed vacuously under the default zend.assertions=-1
- .github/workflows/ci.yml runs ESLint, Stylelint, Vitest, the CSS build, a PHP syntax check and the PHP runner
- remove tabbable and fast-check, which nothing imports
e30bdc1 -
Fix
4:59 PM
Answer array-valued query parameters with 400 instead of a TypeError 500
Details
Nine legacy JSON endpoints (package-info, similar-packages, search-api, steam-friends, organization-details, package-changelists, package-achievements, package-manifest, announcement) passed ?x[]= straight into string functions. Bootstrap and the legacy rate-limit include now refuse them up front.
1248420 -
Fix
4:59 PM
Read text query parameters through queryString() so array-valued parameters stop causing 500s
Details
?tags[]=x on /t/game, ?type[]=x on /award-winners, ?packages[]=x on /compare and similar reached trim()/explode() as arrays and ended in a TypeError. queryString() treats anything that is not a string as absent.
439f80a -
Fix
4:58 PM
Let config.php set the admin and blocked lists, and log PHP errors in production
Details
- constants.php defined ADMIN_STEAM_IDS/BLOCKED_STEAM_IDS before config.php loaded, so the lists a deployment set in config.php were rejected as redefinitions and the built-in defaults were used. The defaults now apply after config.php.
- production ran error_reporting(0), which also stopped errors reaching the log; it now logs everything except notices and deprecations and still displays nothing
- say so in the log when config.example.php is being used in place of config.php
e67a0fb -
Fix
4:56 PM
Refuse placeholder and short MCP API keys; stop serving frontend/index.php with display_errors on
edbc8d9 -
Fix
4:56 PM
Cap saved profile blobs, validate recently-viewed ids, repair recently-viewed session lookup
Details
- profile widgets and layout are limited to 64 KB of JSON (413 beyond that)
- /user-experience/recently-viewed accepted any string and grew without bound; it takes org.package ids and keeps the latest 50
- backend/api/recently-viewed.php read $_SESSION['user'], which is never set, so it always answered 401
da4d33b -
Fix
4:55 PM
Validate profile event types, store only known fields, bound the table
Details
POST /profile-analytics accepted any type (an array raised an uncaught TypeError) and stored the whole request body. It now accepts the three event types the client sends, keeps validated fields only, prunes events older than 90 days, and notifications get a (steam_id, created_at) index for the list query.
7953bf0 -
Fix
4:54 PM
Only cache API responses the server allows to be cached
Details
The service worker stored every API GET and answered from it for up to five minutes, ignoring Cache-Control. /stats/live and the chart refresh returned stale numbers, and signed-in responses stayed in the browser after sign-out. no-store and private responses are no longer written, and the cache version is bumped so existing entries are purged.
8c3ec78 -
Fix
4:54 PM
Command palette accessibility and typing, contrast, and indexing of personal pages
Details
- command palette: '/' no longer swallows typing in inputs and textareas, the dialog/combobox/listbox roles and aria-activedescendant are set, Tab stays inside, focus returns to the opener, searches are debounced and a slower earlier response can no longer replace a newer one, recents store a slim record
- remove the 'g h' style shortcuts that could never match
- award-winners filter and release-notes button failed WCAG AA contrast
- noindex search, favorites, settings and notifications; robots.txt keeps crawlers off /backend/ and /mcp/
0a05404 -
Fix
4:48 PM
Bound client-supplied limits and rate-limit the public legacy endpoints
Details
- take/limit on player-extended, code-search, reputation history, announcements, notifications, package-types, similar-packages and leaderboard are clamped
- package-types built a cache key from the raw take, so every value made a new entry and a new upstream request
- 27 legacy endpoints that proxy Facepunch or read the database had no rate limit; they now use the existing legacy limiter
e3075f2 -
Fix
4:47 PM
Escape package, achievement, review and version-diff text rendered with innerHTML
Details
- reviewer name/avatar/steam id in package reviews went into markup and attributes raw
- achievement and package titles/descriptions on the profile dashboard were unescaped
- version comparison interpolated field names and values, and the version string into an onclick
- clear the three remaining ESLint errors
6183415 -
Fix
4:45 PM
Verify TLS on Facepunch calls, block cross-site writes, pin the canonical host, lock down init-user-reputation
Details
- four callers forced CURLOPT_SSL_VERIFYPEER off over HttpService's verified default
- bootstrap rejects POST/PUT/PATCH/DELETE that Sec-Fetch-Site or Origin mark as cross-site
- requestHost() allow-lists the Host header used for canonical, og:url and OpenAPI URLs
- init-user-reputation needed no login and accepted any string as a steam id
- escape REQUEST_URI in the UGC charts refresh link, encode the vanity name in the login redirect
- stop returning exception messages from the public themes and docs endpoints
99e63c1 -
Fix
4:24 PM
Semantics, a working phone layout, and live numbers that stop when the tab does
Details
Structure and accessibility
- Card headings were h2 inside h2 sections, and the range selector sat inside the chart's <h2> (a <div> in a heading, so its name read "Total players over time 24h 7d 30d"). Bands are h2, cards h3, and the selector is a button group with aria-pressed, matching the per-game widget.
- The headline numbers are a <dl>. They had aria-label on bare <span>s, which overrides the text for some screen readers so the number was never read, and aria-live on two values that changed every thirty seconds.
- The search box is type=search with a label; tag chips are type=button; card icons are hidden from assistive tech and the counts say "playing" and "favourites". "Day by day" KPI labels pass contrast (axe: 1 violation -> 0).
- The page set no description of its own; it now has one (via $pageMeta).
- Bands and jump links exist only for sections that have content, so a Facepunch outage no longer leaves headings over nothing, and the filter bar is not shown with no games to filter.
Layout
- Tag chips were crushed to ~30px on a phone ("ltipla", "hoote"). They scroll, with a fade, and carry a count.
- A phone showed one 16:9 poster per game, a ten-thousand-pixel page. Cards are rows there (7,600px); a desktop row is four cards and a page is 24, which divides evenly into 2, 3 and 4 columns.
- The sticky filter bar stuck at top:0, under the 60px nav. It sits below it and follows the nav as it hides on scroll; on phones it is not sticky.
- On a phone the 90-day card drew 30 columns under a heading naming 90 days. The date range now follows what is drawn.
- Cards were opacity:0 until script added .visible, so the section was blank without JS. They are visible by default; a <noscript> rule shows the ones past the first page.
- A footnote says why the chart (public games) and "Playing now" (the platform total) differ by a few percent.
Behaviour (frontend/js/global-stats.js, extracted from an inline script)
- Polling used to run in hidden tabs forever, fetched /stats (200 games summed) for three numbers, ignored a legitimate 0, and never refreshed the chart or the 24h peak. It now polls /stats/live only while the tab is visible, backs off on failure, says "reconnecting" after three misses, and refreshes the chart without a spinner.
- Search and tag filters show a result count, announce it, keep the filter in the URL (?tag=&q=) so it survives a reload or a shared link, include the #1 hero in the filter, and move focus when "Show more" runs out. Muted autoplay video is paused for prefers-reduced-motion.
Tests: 23 JS tests for the filter, count text, back-off and the DOM behaviour (hidden tab, outage, bad payload); the grid test now pins no-JS visibility.
4df8747 -
Feature
4:21 PM
Let a page set its own title, description and keywords
Details
Pages with no package, profile or post to look up all shared the homepage's description in search results and link previews. A page can now set $pageMeta = ['title' => ..., 'description' => ..., 'keywords' => ...] before including meta-tags.php.
50fcedf -
Feature
4:21 PM
Add /stats/live for the platform counts the page polls
Details
Global Stats polled /stats every thirty seconds for three numbers. That endpoint also fetches and sums two hundred games, and reports a smaller "estimated" total when stats/platform fails, which the page then wrote over its own. /stats/live returns just the platform's players, editor users and 24-hour peak, cached for thirty seconds, and answers 503 rather than an estimate when the platform call fails.
57deea9 -
Feature
4:21 PM
Round axes, calendar ticks, touch tooltips, silent refresh
Details
The y-axis split the data's own maximum into five, so a 556-player peak labelled its gridlines 111, 222, 334, 445. It now steps on 1-2-5 (0, 200, 400, 600), whole numbers only.
The x-axis placed evenly spaced labels from the first sample to the last, so the 24h chart read "04:06 PM ... 04:06 PM" at its two ends. Ticks now sit on calendar boundaries in the viewer's time zone (6 PM, 8 PM, midnight labelled with the day), stepping through hours, days and months to the largest that fits the width.
The tooltip listened for mouse events only, so a phone had no tooltip at all. It now uses pointer events; a touch keeps its tooltip for a few seconds after the finger lifts, since the pointer leaves the instant it does.
load(null, { silent: true }) is the periodic refresh: no spinner, no draw-in animation, and a failed refresh leaves the chart already on screen alone.
Both consumers (the per-game widget and /global-stats) get all of this.
cb57ee1 -
Fix
4:09 PM
Cache a slim game list, never cache a failed scan, drop duplicate KPIs
Details
The page cached the raw package/find responses - up to eight pages of five hundred full packages - to render about fifty cards, and a scan where every request failed was stored as "no games" for a minute, blanking the page for everyone. The scan, ranking and tag counting now live in backend/includes/global_stats.php: only the fields the page draws are cached, an empty scan is not cached and falls back to the last good one, and ties in the ranking resolve by votes then name so the order is stable between loads.
The catalogue totals came from a hand-rolled fetch with TLS verification switched off and a cache file written beside the code; they now go through fetchCachedEndpoint like every other Facepunch call.
The headline strip repeated the "Players per day" and "New players" figures (and their week-on-week deltas) that the Growth card shows a screen later, and "Games with players" / "Games tracked" were two spellings of one number. The strip is now live figures only, and "Games live" reads "of N games" using the size the API reports. "24h peak" said "today"; it is the rolling 24 hours.
Cards were gold, silver, bronze from #2, but #1 is the hero, so the podium was shifted a place; it now runs hero, silver, bronze.
557f31f -
Fix
3:53 PM
Span the x-axis over the selected range
Details
The axis was fitted to the first and last returned point, so with only a few hours of history every range drew the same chart. 24h/7d/30d/90d/1y now plot over the full window ending now, with the data where it falls; all still fits the data.
5e5edcb -
Fix
3:43 PM
Use whole-number y-axis ticks on small ranges
Details
The axis always drew six ticks across maxVal and rounded each label, so a game peaking at 1 player showed 1 1 1 0 0 0. maxVal is now a whole number and the tick count is capped at it, so a peak of 1 draws 0 and 1.
1c0a5af -
Fix
3:40 PM
Load the chart script through jsAsset so a stale edge copy cannot be served
Details
header.php loaded player-history-chart.js with no version query. Cloudflare caches static files as immutable, so after the chart rewrite the edge kept serving the old file, which has no PlayerHistoryChart.create, and Global Stats threw on load. jsAsset adds the content hash like the other scripts.
bebb8d1 -
Fix
3:33 PM
Close the chart fill along the baseline
Details
The fill path began with a moveTo from the baseline, then trace() issued its own moveTo, so closePath drew a diagonal from the last baseline point to the first data point. It was invisible when a series started at 0 and showed as a wedge under the line on Global Stats, where the total never reaches 0.
31ffd54 -
Fix
3:14 PM
Make the admin actions and docs match what the system does
Details
- the admin page called a bare 'php' for the collector and aggregator; use getCliPhpBinary() like the rest of the project, and force 'collect now'
- 'Prune Old Raw Data' deleted raw rows without rolling them up first, silently losing up to a week of hourly history; it now aggregates, then prunes
- ensure the tables exist so the page loads before the first collection
- document that only games with players are stored and that gaps are not zeros, and how the two schedules interact
- list the two tools in the README
992e7ec -
Fix
3:14 PM
One shared chart, honest states, no stale-response race
Details
- player-history-chart.js is now a shared core used by both the per-game widget and the global stats page; global-stats.php carried its own ~150-line copy of the same drawing and fetching code
- the latest range click wins: a slow earlier response can no longer overwrite a newer one while the range buttons show the newer one
- x is placed by timestamp, not by index, and the line breaks where the collector was not running instead of drawing a slope across the outage
- a failed request shows an error with a retry instead of 'no history yet'
- aggregated ranges show avg and peak in the tooltip and a low/peak band
- axis labels follow the span shown (no more 'Sep 29, Sep 29, Sep 29')
- the canvas is role=img with a summary label; status regions are announced
- drop the dead 'Reset Zoom' button, which was hidden by the script
1dd0d87 -
Fix
3:02 PM
One API, honest gaps, no double counting
Details
- delete backend/api/player-history-api.php: a second, broken copy of the controller (no dispatcher, undefined sendResponse, clashing class name) that /api/player-history was rewritten to
- move the queries into PlayerHistory and make the controller thin
- a game missing from a run that happened is a zero; no run means no point, so a collector outage is a gap instead of a fake drop to zero (this replaces the PHP zero-fill and its strtotime($ts.' UTC') parsing)
- timestamps are emitted by the database as ISO-8601 UTC
- the global total takes the highest sample per game per minute so overlapping collectors cannot double it
- '1y' now actually covers a year (it was identical to 'all')
- idents are lower-cased to match the collector, and a game with no data gets an empty series rather than a flat zero line
- summary is one query and averages over the runs that happened
37892b2 -
Feature
3:01 PM
Add the collector, aggregator and schema that were never in the repo
Details
The admin page and scheduled-refresh both spawn tools/collect-player-history.php and aggregate-player-history.php, but /tools/* is gitignored and neither file (nor any table definition) was ever committed, so nothing in the repo could create or fill the data the API and charts read.
- PlayerHistory owns the schema (with unique indexes), the sweep, the writes and the roll-up
- a run shares one minute, so a game missing from a run that happened is zero and a missing run is a gap
- an incomplete sweep records nothing instead of dropping every game on a failed page to zero; one collector at a time; skipped inside the minimum interval so cron and scheduled-refresh cannot double-sample
- hourly/daily averages divide by the runs that happened; pruning is aligned to whole hours/days so a half-pruned hour is never rolled up wrongly
e71f447 -
Fix
2:35 PM
Fill slots with no samples as zero players
Details
The collector only stores a row when a game has players, and the chart spaces points evenly by index, not by time. A game with a handful of scattered single-player samples therefore drew as a steady line at 1 for the whole range. The history endpoint now returns one point per slot (10 minutes for 24h and 7d, hourly for 30d and 90d, daily for 1y and all) with 0 where nothing was recorded.
e4adc4e -
Fix
2:28 PM
Answer the refresh-cache request with JSON and limit it to the owner or an admin
Details
The refresh button fetches /u/{name}/?refresh=1&ajax=1 and expects JSON, but player profiles cleared the caches and then rendered the whole page, so the button always reported "Failed to refresh cache". The player branch now returns JSON once the package cache is rebuilt.
The same request also let any visitor, logged in or not, clear a profile's vanity, package and presence caches. That now needs the profile's owner or an admin.
588b150 -
Fix
2:18 PM
Stop the forum lookup flood, make desktop nav keyboard-reachable, fix contrast and heading order
Details
- negative-cache missing package forums for an hour and stop logging their 404s: the production error log was 99% forum/{package}/threads 404s, one upstream request per package view
- navigation.js set tabindex=-1 on every nav link at load, even on desktop, so Home/News/Charts/Forums/Discover could not be reached with Tab
- lift the muted rank/count text on charts from ~3:1 to 5:1 contrast
- give the homepage trending and tag cloud headings a valid level
34ac8a8 -
Fix
2:06 PM
Harden icon font fallback, add skip link and form labels, honest outage states
Details
- hide Material Symbols ligature text until the icon font has loaded, so a blocked or slow font no longer shows raw icon names over labels
- add a skip-to-content link and focusable scrollable code blocks
- label code-search and skins filters, group the leaderboard podium
- show an em dash instead of misleading zeros on global stats when live data is unavailable
- return 503 with Retry-After and a clear message when the forum index is unreachable instead of a 404 Not found
f4ebdf7 -
Fix
1:50 PM
Drop user leaderboard endpoint that upstream answers with an empty body
Details
package/leaderboard/1/.../u/{userid}/{scope}/ returns 200 with no content for every request, real or not, and 401 for friends, so the route could only ever 500. Jam entry stays; docs record why the leaderboard route is not wired.
cb9c805 -
Feature
1:41 PM
Expose user leaderboard scopes and jam entry lookups
Details
Add package/leaderboard/{v}/{ident}/{board}/u/{userid}/{scope} and jam/{ident}/entry/{package} behind validated path builders, with REST routes and docs. The authenticated Facepunch endpoints stay unwired.
ae1cec6 -
Style
12:58 PM
Use single-quoted escape map keys
42bedf2 -
Security
12:58 PM
Review frontend XSS sinks
Details
Escape quotes in every shared escapeHtml helper (they were used for attribute values), escape profile widget and package data before it reaches innerHTML, and only emit http(s) or site-relative widget links on profile pages.
ea7ca6b -
Security
12:56 PM
Harden MCP auth/SSRF and remove committed credentials
Details
Refuse the placeholder MCP master key, stop following redirects blindly in the KB fetcher (revalidate each hop, pin the resolved IP, reject userinfo/backslash URLs), and read the Steam API key from configuration instead of a hardcoded constant. Drop a seeded Discord webhook URL from the schema file.
8fbcbdb -
Security
12:54 PM
Harden Ko-fi and Discord webhook handling
Details
Verify the Ko-fi token before logging, refuse the placeholder token, keep the token out of logs and validate field types. Restrict outbound webhook targets to Discord endpoints and require CSRF on webhook.php.
6e472ae -
Security
12:53 PM
Drop dead shell-out from the changelog page
Details
The git log fallback was guarded by a .gits directory that never exists, so it never ran; the page renders from the generated changelog JSON.
70bf047 -
Security
12:52 PM
Add CSRF and tighten CORS
Details
Require the X-CSRF-Token header on profile-layout and settings writes (the API client already sends it), and only emit credentialed CORS headers for the configured trusted origin.
a712eec -
Security
12:51 PM
Harden environment and session configuration
Details
Select the environment from APP_ENV only, never from REMOTE_ADDR or the Host header, and stop trusting X-Forwarded-For and localhost peers for TLS, rate-limit and admin decisions.
9c26c45 -
Security
12:51 PM
Remove sensitive database backup artifacts
Details
Untrack the archived SQLite backups (user data) and ignore *.backup/*.bak. The files remain in history on origin/master.
eca5aee -
Security
12:51 PM
Harden Steam OpenID authentication
Details
Validate op_endpoint, return_to, claimed_id/identity against the signed field list and configuration, and reject replayed or stale response nonces via an atomic nonce store before establishing a session.
56bca24
Generated from the project's commit history whenever the site is deployed. Times are server time.