Commit Graph
100 Commits
Author SHA1 Message Date
Darkraise c12255c3c6 docs(auth): document guest play and launch permission
Guests now hold games.play when guest access is enabled, and
GET /api/games/:id/launch requires games.play.
2026-09-28 12:33:58 +07:00
Darkraise 2e9e9490b5 fix(games): require games.play to launch a game
GET /api/games/:id/launch only checked games.read via
validateSharedGameAccess, so an authenticated user whose role lacked
games.play could still mount and download games through the API.
2026-09-28 12:09:09 +07:00
Darkraise 2724a40325 feat(auth): let guests play games
When guest access is enabled, guests now receive games.play alongside
games.read and playlists.read, on both the backend guest fallback
(optionalAuth, sharedAccessAuth) and the frontend guest session. Play
statistics stay authenticated-only.
2026-09-28 12:09:09 +07:00
Darkraise f5c167b470 chore (ruffle): pump built-in ruffle to version 0.5.0 2026-08-22 01:09:24 +07:00
Darkraise 4ffa92a3a5 feat(ruffle): add stable and nightly release channels
Ruffle publishes tagged releases and daily nightlies into a single
GitHub feed. The updater took whichever release came first carrying a
web-selfhosted.zip, so a stable release landing at the top of the feed
was installed on hosts tracking nightlies -- and because version
comparison normalised everything to a build date, the resulting semver
never matched, leaving the host reporting an update forever.

Selection now filters on GitHub's prerelease flag, and comparison
splits by channel: build date for nightlies, numeric parts for stable.
The channel lives in the ruffle.channel system setting (default
stable) and governs both manual installs and the scheduled job.
Switching channels records the choice and reports the target build;
nothing is downloaded until the install is requested.

Stable releases are months apart and can fall outside the recent page
the changelog is built from, so that channel falls back to the
latest-release endpoint.
2026-08-22 01:06:03 +07:00
DarkraiseandGitHub 837415a3dd chor: add demo link and registration details
Added demo link and registration information to README.
2026-08-13 22:45:20 +07:00
Darkraise ba62cc91ea docs(ruffle): mention persisted Ruffle in operator docs 2026-08-13 17:49:42 +07:00
Darkraise 6649757c5d fix(ruffle): bust cached loader URL for the header fix 2026-08-13 17:49:34 +07:00
Darkraise 0ce39da805 docs(ruffle): document the persisted install path 2026-08-13 17:31:04 +07:00
Darkraise a4b717e9be fix(ruffle): serve the persisted install ahead of the bundled one 2026-08-13 17:22:24 +07:00
Darkraise f746bb2f81 feat(ruffle): seed a fresh data volume from the bundled copy 2026-08-13 17:15:50 +07:00
Darkraise 9b28abc8b9 fix(ruffle): point cleanup assertions at the persisted data dir
The updateRuffle describe block still checked distDir for leftover
ruffle-backup/ruffle-temp directories after Task 1 moved the install
target to dataDir. Those checks were passing vacuously, and the
EACCES injection in "completes when the staging directory cannot be
removed after copying" never matched the real removal path.
2026-08-13 17:09:33 +07:00
Darkraise 138e023b47 fix(ruffle): install onto the persisted data volume 2026-08-13 16:58:53 +07:00
Darkraise 27ee8b5829 docs(ruffle): plan persistent install implementation 2026-08-13 16:47:15 +07:00
Darkraise f57615f4bb docs(ruffle): spec persistent install location
Ruffle updates land in the container writable layer and are lost on
recreate. Specifies installing into the persisted /app/data volume,
seeding from the bundled copy, and serving from there.
2026-08-13 16:41:54 +07:00
Darkraise 049779a78a fix(playlists): bundle community playlist index
The index was scraped from the Flashpoint Datahub wiki on every request.
That page now sits behind a Cloudflare managed challenge that no plain
HTTP client can pass, so the endpoint answered 500 for every caller.

Ship the list instead, extracted from a saved copy of the page into
data/community-playlists.json: 104 playlists across 9 categories. Only
entries whose download URL is on the allowlist are kept, so everything
the modal offers can also be imported. That drops the Old Default
Playlists section, hosted off the allowlist and still in the
pre-Flashpoint 10 format.

The modal now fetches only while open rather than on every render of the
playlists page, and shows when the snapshot was captured. Drops cheerio,
whose only consumer was the scrape.
2026-08-13 13:57:52 +07:00
Darkraise e9613362c2 fix(settings): tighten update card and pin auth
- Show checkedAt on the up-to-date panel and only claim currency
  when versions actually match, with a matching neutral toast path
  for unreleased builds
- Add aria-expanded to the changelog toggle
- Reword a review-referencing comment/test title and pin the /app
  route's settings.update permission gate in tests
2026-08-13 00:44:02 +07:00
Darkraise 1b9b1ce933 fix(settings): keep held data on a failed app update refetch
isError stays true for any errored query state, including a
background refetch that failed after a prior successful fetch -
TanStack Query keeps the last-good data in that case. Using isError
in checkFailedEntirely hid an already-rendered update/changelog/
upgrade-command panel behind the generic failure panel on any
refetch error, most likely right after an admin follows the card's
own upgrade instructions and restarts the backend it polls.

isLoadingError is true only when there is no cached data to fall
back on, which is the actual "nothing to show" case the failure
panel is for.
2026-08-13 00:24:05 +07:00
Darkraise 680f4624c3 fix(settings): show failure panel on app update query error
The useQuery for /updates/app only had its always-200 "check failed"
field handled, not a genuine request failure (backend unreachable,
transport error, expired token). In that case info stayed undefined
with isLoading false, rendering an empty card below "Current Version:
Unknown". Fold isError into the existing checkFailedEntirely panel so
a query error gets the same treatment as a failed remote check.
2026-08-13 00:17:23 +07:00
Darkraise 7760698980 feat(settings): add the web app update card 2026-08-13 00:10:04 +07:00
Darkraise 7b0291d8b2 feat(updates): serve the app update check 2026-08-13 00:03:13 +07:00
Darkraise 1bd1f2afb2 test(updates): cover app update caching and failures
Also close a gap found in review of Task 3: a failed check recorded no
timestamp, so every call during a GitHub outage or rate limit fired a
fresh request that blocked up to the 10s timeout, defeating the 60s
forced-refresh floor exactly when it mattered. Failures now record
lastFailedAt and are subject to the same floor as forced refreshes,
regardless of the force flag, and a success clears it.
2026-08-12 23:56:01 +07:00
Darkraise 00c290892a feat(updates): compare the app version against the latest release 2026-08-12 23:47:11 +07:00
Darkraise 1e974d3664 feat(config): report the running app version 2026-08-12 23:40:46 +07:00
Darkraise c4e80398f6 feat(utils): add release version comparison 2026-08-12 23:35:28 +07:00
Darkraise 745d48128d docs(plan): implementation plan for the update check
Six TDD tasks: version comparison, the server-side version, the service,
its caching and failure behaviour, the endpoint and client, the card.
2026-08-12 23:32:28 +07:00
Darkraise b363fb294f docs(spec): revise the update check after review
A Fable review checked the spec against the code and found three things
that would have shipped wrong: the response envelope was copied from the
wrong router, the forced-refresh path could exhaust the shared GitHub
quota, and a 502 on total failure would double up with the axios
interceptor's global 5xx toast.

Answer 200 always with a lastCheckFailed flag, hold in-flight fetches so
a cold cache costs one call, floor forced refreshes at 60s, strip a
leading v from both sides, and reconcile the new server-side version with
the build-time one the General tab already shows.
2026-08-12 22:36:18 +07:00
Darkraise 9c532f8b6c docs(spec): design the web app update check
Notify-only card on the Update page: compare APP_VERSION against the
latest GitHub release, show the changelog and the upgrade command, and
stay quiet on development builds.
2026-08-12 22:18:17 +07:00
Darkraise cfb6c7c194 fix(preferences): keep the settings of a sparse preferences.json
Ultimate ships preferences.json without gameDataSources, and validation
treated a missing key as fatal. The whole file was discarded and replaced
with Infinity-shaped defaults, so that edition ran on hardcoded paths,
kept lastLoadFailed set - which drops the cache TTL from 60s to 5s - and
logged an error on every reload.

Normalize instead of validate: fill in the two fields the rest of the app
relies on, pass everything else through as written, and reject only a
file that is not an object. A malformed data source entry is dropped with
a warning rather than taking the file down with it.
2026-08-12 21:37:40 +07:00
Darkraise 9c85284798 fix(settings): show the real web app version
The card always displayed 1.0.0. VITE_APP_VERSION was never set: CI
passes the release tag as a VERSION build arg, but the Dockerfile never
declared it or forwarded it to the frontend build, so the hardcoded
fallback always won.

Declare the build arg and export it as VITE_APP_VERSION, and resolve the
version in vite.config.ts for every other build: the env var, then the
release tag via git describe, then package.json. The image version label
was pinned at 1.0.0 for the same reason and now follows the arg too.
2026-08-12 21:32:49 +07:00
Darkraise aed4e4914d feat(settings): show package date and last update
The Version Information card could not tell one Flashpoint install from
another: Ultimate names only a major version in version.txt, and nothing
showed when the metadata was last synced.

Serve two more values with the public settings. The package date is the
mtime of version.txt, falling back to .preferences.defaults.json. Both
are written when the package is built and never rewritten afterwards,
unlike preferences.json and flashpoint.sqlite, which the Launcher and
this app write to.

The last update comes from preferences.json. A missing source or the
1970-01-01 placeholder is normalized to null, which the UI renders as
"Never" - what Ultimate always shows, since it ships no metadata source.
2026-08-12 21:32:05 +07:00
Darkraise 74aa0cf067 feat(startup): serve the app while the server warms up
On a network share the database copy and cache pre-warm run for minutes,
and the listener only opened after all of it, so anyone who loaded the
page during that window got a connection error.

Open the port immediately after registering the static frontend, and
hold every route that needs the database behind a gate that answers 503
with a machine-readable marker and the current startup phase. The SPA
shell needs nothing the gate blocks, so the frontend renders a "server
is starting" screen on the login page's own background, polls /health,
and reloads once the server reports ready.

Move the database copy out of the entrypoint: it does the same freshness
check the application does, and doing it before node starts kept the port
closed for the longest part of startup. Distinguish a dead server from a
slow one by requiring the backend's own marker, since a proxy in front of
a stopped backend still answers.
2026-08-12 14:05:14 +07:00
Darkraise 3672022ea0 style(downloads): use ?? for data pack path defaults 2026-08-12 14:05:06 +07:00
Darkraise 54701de66e fix(logs): survive an unwritable client-error log dir
The mkdir ran unguarded at module load, so an uncreatable log directory
threw during import and took down server startup. The logger already
handles the same situation by falling back and continuing.
2026-08-12 14:05:05 +07:00
Darkraise 0cb6edf8d1 perf(db): copy the database off the event loop
syncAndReload runs on every Launcher write to flashpoint.sqlite, and a
synchronous copy of a multi-hundred-megabyte file froze every in-flight
request for its full duration.
2026-08-12 14:05:05 +07:00
Darkraise 78f6b7f33f fix(downloads): stage temp files on the data volume
TEMP_DIR was built from process.cwd() plus 'backend', but the container
already runs from /app/backend, so it resolved to a doubled path on the
container's writable layer. Downloads of up to 500MB staged there
instead of a mounted volume, and the mkdir fails outright with EACCES
when PUID moves the app user off the image build UID.

Anchor it to a configured path under /app/data and sweep files a crash
left behind at startup, with an age guard so a second container sharing
the volume cannot delete a download that is still running.
2026-08-12 14:05:04 +07:00
Darkraise 3b0a5a4b6d fix(storage): write launcher-shared files atomically
preferences.json, playlist JSON and the cached images all live on the
mount the Flashpoint Launcher reads and writes, and all were rewritten
in place. An interruption leaves a torn file: for preferences.json that
corrupts the desktop app's own config, and a truncated cached image is
served forever because both the proxy and the asset downloader treat any
existing file as valid.

Add a shared helper that fills a sibling temp file and renames it into
place, with a unique per-call suffix so two writers cannot collide, and
a short retry for the Windows and SMB case where another process holds
the target open. It never falls back to writing in place, which would
silently reintroduce the torn-write window.
2026-08-12 14:03:04 +07:00
Darkraise 2facf4a9f6 fix(games): stop truncated packs from breaking launches
FileImporter copied straight to the final path, so a size mismatch or a
crash mid-copy left a truncated ZIP that nothing removed. The mount path
finds any {gameId}-*.zip by prefix and mounts it without verifying the
hash, and because a file exists the re-download branch never runs, so
the game becomes unlaunchable until someone deletes it by hand.

Copy to a .part file in the destination directory, verify the size, then
rename into place, and unlink the partial on failure. Import under the
launcher-convention filename the caller already computed instead of
stamping Date.now(), which hid packs from the direct-path lookup and
left one copy per download attempt on disk.

When a mount fails, hash the ZIP against its recorded sha256 and move it
aside only on a genuine mismatch, then retry once. A hash match means
something else failed and the file is left alone: the Launcher writes
this directory too. Recovery is opt-in so the bulk startup mount cannot
turn a transient storage fault into a library-wide re-download.
2026-08-12 14:02:55 +07:00
Darkraise 529fe49571 fix(ruffle): install without renaming directories
Updating Ruffle failed in Docker with "EXDEV: cross-device link not
permitted" because overlay2 cannot rename a directory that still lives
in a read-only image layer, even within one parent directory.

Replace the directory renames with a rename-then-copy fallback, and
empty rather than unlink a path that turns out to be a mount point, so
a volume mounted over the install directory also works. Verify the
copied tree against the extracted one before dropping the backup: an
interrupted copy can leave ruffle.js in place while the rest is
missing, which a bare existence check cannot detect.

Also chown the served frontend directory when PUID moves the app user
off the image build UID, or every write there fails with EACCES before
any of this is reached.
2026-08-12 14:02:39 +07:00
Darkraise 3816c4e0b5 fix(library): keep game card content inside the rounded corners
The card owns the radius but never clipped its children, and CardContent
clips to a square box flush with the card's top edge. The skeleton and the
gradient overlay both fill that box, and a hovered image scales into it, so
square corners rendered over the card's rounded ones.

Clip on the card itself, matching PlaylistCard. CardContent keeps its own
overflow-hidden to bound the scaled image to the media area.
2026-08-12 11:21:05 +07:00
Darkraise a984801f57 fix(images): abort thumbnail downloads on navigation
LazyImage carried the abort logic but nothing rendered it -- every image in
the app was a plain <img>, so navigating away from a grid left dozens of
thumbnails downloading, holding the six connections and the server I/O the
next page needed. Removing an <img> from the DOM does not cancel its
request; removing the src attribute does.

Rewrite LazyImage around the real element: native lazy loading, internal
loaded and error state, a skeleton prop taking true, false or a node, and a
fallback prop for the error case. Migrate the seven game image sites to it,
each dropping its own loaded/error state. Drop LazyBackgroundImage and
useLazyLoad, which had no consumers either.

Skip the abort while the node is still connected: StrictMode runs effect
cleanups on a live element, and clearing src there blanks every image in
development with nothing to restore it.
2026-08-12 11:09:34 +07:00
Darkraise e680a088dd fix(security): stop sending headers browsers reject over HTTP
A LAN deployment served over plain HTTP logged two console warnings on
every page load. COOP is ignored on origins that are not potentially
trustworthy, and Origin-Agent-Cluster conflicted because /game-proxy and
/game-zip are mounted before helmet -- they must bypass frameguard and the
SPA CSP to be framed -- so game documents carried no header while the rest
of the origin asked for origin-keying.

Send COOP only on connections that used TLS, reusing the X-Forwarded-Proto
detection behind COOKIE_SECURE, and drop Origin-Agent-Cluster so the origin
is uniformly site-keyed.
2026-08-12 11:09:25 +07:00
Darkraise 3b11e3898d docs(readme): surface the 1.0.32 breaking change up front
The upgrade notice sat below the install walkthrough, where an existing
user pulling a new image would never look.

Add an alert directly under the header and move the Upgrading section
above Getting Started, listing what a pre-1.0.32 deployment must change.
2026-08-12 10:11:15 +07:00
Darkraise 1973adabca docs: add upgrade guide for the single-image deployment
Deployments on 1.0.31 or earlier cannot take an image update without
changing their compose file and .env, and nothing in the repo said so.

Document the migration from the two-container setup to the single
flashpoint-web image, the environment variables added, removed, and
changed since 1.0.31, and per-version release notes for 1.0.32-1.0.36.
Link it from the README and both documentation indexes.
2026-08-12 10:02:15 +07:00
Darkraise 9a32471f61 fix(docker): mount Flashpoint read-write by default
The shipped compose mounted the Flashpoint directory read-only, so every write
the app makes to it failed: recording downloaded state in flashpoint.sqlite,
saving downloaded game data under Data/Games, and metadata sync. The Downloaded
page marked nothing and the reconciler warned on every boot.

Drop :ro from both compose files and the README's snippet, and correct the docs
that described the mount as read-only.
2026-08-12 03:12:57 +07:00
Darkraise 9c27e5840c fix: address review findings from the two-day changes
Critical: DbQueryPool stranded the caller of any query whose worker died —
pending promises were only rejected when the last worker went, so a single
crash left an HTTP request hanging until the timeout. Track the in-flight
query per slot, reject exactly that caller, and settle queued work when the
pool empties or shuts down.

Security: same-origin acceptance compared host but ignored scheme, so a
plaintext page could claim same-origin against an HTTPS deployment; the
metadata source allowlist accepted http://, where a MITM could rewrite the
game database; and asset paths were built from remote game IDs without
validation, allowing traversal outside the images directory.

Also: the reconciler now derives filenames via GameDataDownloader.getFilename
instead of re-parsing dateAdded (formats that helper normalizes would never
have matched), and warns once instead of failing when the database is
read-only — the state of the shipped compose mount. getYearRange runs through
the worker pool like its siblings rather than blocking the main thread.
2026-08-12 03:10:16 +07:00
Darkraise 18c0c3d329 feat(metadata): optionally download images after a metadata sync
Images normally arrive lazily: the proxy fetches one from the CDN when a
browser asks for a game the local mount lacks. That is wrong for an offline
host, where a sync can add thousands of games whose images would only ever
appear for whoever browses to them while online.

Add an opt-in setting (off by default) that, after a sync, fetches logos and
screenshots for exactly the games that sync touched. Runs as its own job with
progress and cancellation, and is never awaited by the sync, so a slow or
failing CDN cannot block or fail a metadata update.
2026-08-12 02:51:24 +07:00
Darkraise f9437c0617 docs: remove known-issues list now that the items are fixed 2026-08-12 02:37:52 +07:00
Darkraise a93cb69bbb test(frontend): give the test environment working storage
This Vitest version's jsdom environment installed localStorage as a bare object
with no methods, so the auth store's persist middleware threw and took 69 tests
across 6 files down with it. A suite that red hides real regressions.

Install an in-memory Storage for localStorage and sessionStorage, cleared
between tests. That surfaced two failures it had been masking: PlaylistCard
asserted a stale string id in its href, and the MSW mocks lacked a handler for
POST /api/games, which is how search has worked since filter combinations
outgrew the URL length limit.

Frontend suite: 115/115.
2026-08-12 02:36:04 +07:00
Darkraise 13c4ccb19e perf(db): run heavy reads in worker threads, raise I/O parallelism
better-sqlite3 is synchronous, so a slow read froze the entire server — a
filter-options request over a slow mount was measured blocking it for 167
seconds, during which even /health timed out.

Add DbQueryPool: read-only SQLite connections in worker threads, with
DatabaseService.allAsync routing the full-table scans through them and falling
back in process when workers are unavailable. Filter-options queries now run
concurrently rather than serially. Workers reopen on database reload and stop
on shutdown.

Also set UV_THREADPOOL_SIZE=16: libuv's default of 4 throttled every image and
ZIP read in the process, which dominates on a network mount.
2026-08-12 02:18:44 +07:00
Darkraise fb8fcd390a fix(images): cancel in-flight preloads and skip abandoned requests
LazyImage and LazyBackgroundImage preload through a detached Image and their
cleanup only set a cancelled flag, so the request kept downloading after
unmount. Navigating away from a grid left dozens of downloads occupying the
browser's six connections and the server's I/O, starving the page the user
actually opened.

Clear the handlers and reset src on cleanup, which is what aborts the request,
and have the image proxy return early when the client has already gone rather
than paying a mount round trip — or a 10s CDN fetch — for nobody.
2026-08-12 01:56:11 +07:00
Darkraise 246e58f4ac docs: mark ruffle, image cache, and test script issues fixed 2026-08-12 01:14:26 +07:00
Darkraise 73d6c3f01c fix(startup): install Ruffle in the background, into the served directory
The server awaited the Ruffle download before listening, so the port stayed
closed for minutes on slow storage and would stay closed indefinitely if the
download stalled. RuffleService also checked frontend/public/ruffle while a
built deployment serves frontend/dist/ruffle, so a fresh container re-downloaded
Ruffle it already shipped.

Install in the background with its own error handling, and target the directory
that is actually served.
2026-08-12 01:14:13 +07:00
Darkraise a470d0a9ef perf(images): cache locally served images and stop blocking on stat
Images served from the Flashpoint mount carried no Cache-Control, while the
CDN fallback sent a day of caching, so browsers revalidated every image on
every page view. Over SMB each of those cost four metadata round trips, one of
them a synchronous existsSync that blocked the event loop.

Send the same cache header on both branches, replace existsSync with an async
stat, and resolve the base directory's realpath once instead of per request.
2026-08-12 01:14:12 +07:00
Darkraise bce6ebdd48 fix(test): run backend vitest once instead of watch mode 2026-08-12 01:14:12 +07:00
Darkraise ad8d7720c9 feat(metadata): allow admins to set a metadata source
The Ultimate edition ships preferences.json without gameMetadataSources, so
metadata sync was permanently unavailable there with no way to opt in.

Add an admin-only override (metadata.custom_source_enabled / _url) resolved
ahead of preferences, restricted to the two trusted FPFSS hosts and validated
on save. While it is off, the Metadata Update card explains that Ultimate does
not support metadata updates and that updating means a newer full package,
pointing advanced admins at the override. Enabling it requires confirming a
dialog that spells out what a sync rewrites.
2026-08-12 01:09:25 +07:00
Darkraise b8ed9af06d docs: track open defects found during deployment testing 2026-08-12 01:00:51 +07:00
Darkraise f6fd7f9bac fix(auth): mark auth cookies Secure per connection, not per NODE_ENV
Cookies were Secure whenever NODE_ENV=production, so browsers discarded them
on any deployment served over plain HTTP and every login bounced back to the
login page. A fixed flag cannot serve a LAN over HTTP and the internet over
HTTPS from the same instance.

Derive the flag from req.secure, which honours X-Forwarded-Proto from a
trusted proxy, and add COOKIE_SECURE=auto|true|false to override it.
2026-08-11 23:49:28 +07:00
Darkraise d1d885d302 docs(readme): add minimal working env example, refresh stale sections 2026-08-11 22:14:16 +07:00
Darkraise 352e2a07b2 fix(docker): pass FLASHPOINT_PATH explicitly in dev compose 2026-08-11 22:09:59 +07:00
Darkraise c09f0c8a83 fix(cors): normalize origins and allow same-origin requests
Browsers omit a scheme's default port when serializing Origin, so a page at
http://host:80 sends "http://host". Comparing that to the raw DOMAIN string
rejected every POST from a deployment served on port 80 or 443 — including
same-origin requests under SERVE_FRONTEND, which surfaced as a CORS error on
login with no way to guess the cause.

Compare normalized origins instead, always allow a request whose Origin
matches its own Host, and accept a comma-separated DOMAIN so one deployment
can be reached over both a LAN address and a public domain. Apply the same
normalization to the domains table, which previously built origins without
ports, and reflect the caller's origin on the game-zip admin routes.
2026-08-11 22:09:59 +07:00
Darkraise 57f099b944 perf(games): cache downloaded filter options indefinitely
Treat `downloaded` as a base cache dimension like platform and library
instead of a dynamic filter. It is two-valued, and every path that can
change presentOnDisk (GameDatabaseUpdater, DownloadedReconciler) plus a
database reload already clear this cache, so the 30-second TTL only forced
the Downloaded page to recompute nine option queries twice a minute.

Pre-warm the downloaded combination at startup so the page is instant on
first visit.
2026-08-11 21:57:39 +07:00
Darkraise 889133cf3b feat(games): add downloaded games page, filter, and settings
Add a Downloaded page listing games whose data is on disk, a downloaded
filter on the browse pages, and three admin settings controlling the page,
guest access to it, and whether the filter starts on.

Make presentOnDisk trustworthy: the play-path download now records it, a
reconciler rebuilds it from ZIP filenames at startup and after each Launcher
sync (which also backfills earlier play-path downloads), and self-writes no
longer trigger a database reload and cache flush.

Closes #12
2026-08-11 19:47:52 +07:00
Darkraise d9af9af1fd test(library): cover explicit-off write on downloaded chip removal
Adds the test promised in the design spec's testing section: chip
removal must write an explicit '0' when the admin default is on,
otherwise clearing the URL param would silently re-apply the
downloaded filter. Covers all three removal paths in
GameBrowseLayout (handleRemoveChip, handleRemoveWithChildren,
handleClearAllFilters) against both default states.

Renders GameBrowseLayout with its data hooks (useGames,
useFilterOptions, useFavoriteGameIds, useBatchRatingAggregates,
useFeatureFlags) stubbed out and FilterPanel replaced with a minimal
stand-in exposing the exact callbacks it receives, so the real
handler logic runs without pulling in FilterPanel's own filter-
dropdown machinery or live network calls.
2026-08-11 19:11:46 +07:00
Darkraise 9dbaf0713e docs: fix HTTP method and presentOnDisk sample in games docs
Search Games is a POST with a JSON body (backend/src/routes/games.ts),
not a GET with query params - correct the framing around the new
`downloaded` field. Also fix the presentOnDisk SQL sample in
game-browsing-filtering.md, which defined it as "has any game_data
row" and contradicted both the Downloaded Games section further down
and the real backend logic (EXISTS ... presentOnDisk = 1).
2026-08-11 19:11:40 +07:00
Darkraise a0e8835be5 fix(downloads): clear caches after reconciler marks games downloaded
reconcile() called noteSelfWrite() but never cleared GameSearchCache
or the filter options cache. In the post-sync path caches are cleared
at reload and reconcile then runs asynchronously, leaving a window
where a downloaded:true search gets cached without the flags the sync
just wiped. With GameSearchCache's 5-minute TTL and updateAgeOnGet,
a frequently-refreshed Downloaded-page query could stay stale
indefinitely.
2026-08-11 19:11:36 +07:00
Darkraise 714cef96ee fix(downloads): move self-write suppression into GameDatabaseUpdater
markAsDownloaded and markAsNotDownloaded now call
DatabaseService.noteSelfWrite() plus clear the search and filter
options caches themselves, matching the design spec. Previously this
only happened at the gamezipserver.ts call site, so DownloadManager's
Download-button path (non-copy mode) still triggered the file watcher
on every download, causing an unnecessary full reload and cache flush.
2026-08-11 19:11:31 +07:00
Darkraise bbb0c169f3 docs: document downloaded games page and filter 2026-08-11 18:50:58 +07:00
Darkraise a1b2bafcb4 feat(games): add downloaded games page 2026-08-11 18:39:37 +07:00
Darkraise cb1c9f5ef5 fix(filters): forward downloaded to filter-options request
Task 2 already narrows filter-option lists on the backend when downloaded
is true, but useFilterOptions never forwarded the field into the query
params or query key, so the backend support was unreachable and the
dropdown/year-range options never narrowed to downloaded games.
2026-08-11 18:34:28 +07:00
Darkraise 11bce2af41 feat(filters): add downloaded filter to browse pages 2026-08-11 18:30:53 +07:00
Darkraise e7bbd36aff feat(filters): encode downloaded filter in the URL 2026-08-11 18:19:20 +07:00
Darkraise 3cef0feb7c feat(settings): add downloaded page and filter settings 2026-08-11 18:15:09 +07:00
Darkraise 170956197a fix(games): record downloaded state after play-path download 2026-08-11 18:10:23 +07:00
Darkraise 3be575bbd7 feat(games): reconcile downloaded state from disk 2026-08-11 18:04:32 +07:00
Darkraise aecada204f fix(games): remove unreachable dead code in filter options cache key 2026-08-11 17:59:21 +07:00
Darkraise 18cb658eca feat(games): narrow filter options to downloaded games 2026-08-11 17:38:33 +07:00
Darkraise 52cf237f5e feat(games): filter search results by downloaded state 2026-08-11 17:33:29 +07:00
Darkraise a8f9b1db11 docs(plan): add downloaded games implementation plan 2026-08-11 17:30:11 +07:00
Darkraise be3fb6de60 docs(spec): design downloaded games page, filter, settings 2026-08-11 17:18:33 +07:00
Darkraise df298165a3 feat(settings): add toggle for I'm Feeling Lucky button
Add the public feature flag features.enable_lucky_button (default on)
with an admin switch in Settings > Features. AppShell renders the
floating random-game button only when the flag is enabled.

Unlike the other feature flags, this one is not bypassed for admins:
it controls cosmetic chrome, so an admin hiding it must also stop
seeing it in their own session.

Closes #13
2026-08-11 16:35:23 +07:00
Darkraise f385eeb27b fix(security): harden served-frontend CSP, drop unsafe-inline/eval
Tighten the Content-Security-Policy the backend sends when it serves the
SPA so injected inline scripts and eval are no longer allowed:

- script-src: 'self' + 'wasm-unsafe-eval' (for Ruffle's WASM) + a SHA-256
  hash of index.html's inline theme bootstrap, computed at startup from the
  served file. Drops 'unsafe-inline' and 'unsafe-eval'.
- style-src: keep 'unsafe-inline' — Radix/shadcn set inline style attributes
  at runtime, which hashes/nonces (style-src-attr) cannot cover.

The inline-script hash normalizes CRLF/CR to LF before hashing because the
HTML parser does the same before matching, so the hash holds on Windows
(CRLF) checkouts too.

Verified: 0 eval/new Function in the bundles; Ruffle loads as an external
same-origin script and only needs wasm-unsafe-eval; the hashed theme script
executes under the strict policy in a real browser with no CSP violation.
2026-08-11 16:02:06 +07:00
Darkraise 3ba60aedf9 refactor(docker): build and ship a single API + UI image
Collapse the separate backend (Node) and frontend (nginx) images into
one image where the Express backend serves the built React app in
addition to the API and game content, so deployment is a single
container and one origin.

- Add a root Dockerfile with frontend-builder + backend-builder stages
  producing one runtime image; remove backend/ and frontend/ Dockerfiles,
  the nginx config, and the frontend entrypoint
- Serve the frontend dist and SPA fallback from Express (config.serveFrontend
  / FRONTEND_DIST_PATH), before auth/maintenance so the shell always loads and
  before the API 404 so client routes resolve to index.html
- Port the SPA-oriented CSP from nginx into Helmet, gated on serveFrontend;
  keep the strict API-only policy otherwise
- Reduce both compose files to a single flashpoint-web service on WEB_PORT
  -> 3100; drop API_PORT, BACKEND_HOST, BACKEND_PORT
- Build one image in the release workflow instead of a two-service matrix
- Update .env.example, README, workflow README, and env var docs

Also add .gitattributes (*.sh eol=lf) and normalize the entrypoint line
endings — without it, Windows checkouts bake CRLF into the image and the
shebang fails with "no such file or directory".
2026-08-11 16:02:06 +07:00
Darkraise a41eac0101 chore: remove ui-ux-pro-max skill and UI revamp plan 2026-08-11 16:02:06 +07:00
Darkraise e80328cd24 refactor(playlist): improve AddToPlaylistModal accessibility
- Use label/fieldset/legend and Checkbox ids so playlist rows are
  keyboard- and screen-reader-navigable
- Add aria-live status regions for loading and empty states and
  aria-hidden on decorative icons
- Move action buttons into DialogFooter
- Drop the duplicate 'Playlist created' toast already fired by the
  create mutation
2026-08-11 16:02:06 +07:00
Darkraise bd2514f1f7 chore(config): centralize and document environment variables
- Read game paths and NODE_ENV through the config module in
  gamezipserver instead of process.env directly
- Harden SQLite cache/mmap parsing with isNaN checks so invalid
  values fall back to defaults instead of NaN
- Pass auth, rate-limit, application, and OpenTelemetry variables
  through both docker-compose files with documented defaults
- Expand env var documentation and .env examples to match
2026-08-11 16:02:06 +07:00
Darkraise b57f2701c0 feat: add 5-star game rating system
Add community game ratings with a 5-star system:

- Backend: GameRatingService, /api/ratings routes, and migration
  002_game_ratings.sql for the ratings table and aggregates
- Frontend: StarRating and GameRatingSection components, useRatings
  hooks, ratings API client, and rating types
- Surface aggregate ratings on game cards, list items, detail view,
  and a Highest Rated section on the home page
- Gate the feature behind an enableRatings feature flag with a
  settings toggle
- Add --star theme tokens and Tailwind star color
2026-08-11 16:02:06 +07:00
DarkraiseandGitHub c79e18d0a5 Merge pull request #15 from MaxfieldKassel/fix/guest-browsing-401-redirect-loop
fix(auth): keep guest browsing alive on auth-only 401s
2026-08-11 16:01:42 +07:00
DarkraiseandGitHub ac8364103a Merge pull request #11 from darkraise/feature/filter-enhancement
Feature/filter enhancement
2026-02-26 10:08:15 +07:00
Darkraise 2757ac0a12 feat: support multi-value filters with POST search endpoint
Switch games search from GET to POST with JSON body to avoid URL
length limits when combining multiple filters. Filter options now
return context-aware results based on active selections.

Key changes:
- Backend: POST /api/games with array-based filter params
- Backend: Filter options cached per page combination + 30s TTL
  for dynamic filter states
- Backend: Fix semicolon-delimited field matching (developers,
  publishers, playModes, languages) using INSTR
- Frontend: Add URL compression (lz-string) for shareable filter URLs
- Frontend: Multi-select support in filter dropdowns and chips
- Frontend: Context-aware filter options hook
2026-02-25 22:26:27 +07:00
Darkraise e0656e81cb feat: add year range filter and improve filter panel
- Add YearRangeFilter component with from/to inputs and popover UI
- Check both game_data and game table for Flash SWF detection
- Add context-aware sort labels (Oldest/Newest vs A-Z/Z-A)
- Validate page param with isNaN() and Math.max(1, ...)
- Memoize filter handlers, updateFilter, and visibleConfigs
- Add error state with retry button for filter options
- Add LIMIT 100 to play modes query for consistency
- Stabilize useFilterDropdowns with useRef pattern
2026-02-15 17:52:42 +07:00
Darkraise 9984b70a70 docs: expand API reference with comprehensive endpoint docs
Add detailed documentation for multiple API endpoints and features:
- User playlists API with sharing and cloning endpoints
- Shared playlists API with access token generation
- Community playlists API for browsing/downloading
- Downloads API with SSE progress tracking
- Jobs API for scheduled task management
- Maintenance mode feature documentation

Update existing docs:
- Clarify Flashpoint vs User playlists distinction
- Add setup-status, most-played, and theme endpoints
- Expand settings cache and permission cache docs
- Reorganize API reference navigation structure
2026-02-15 13:45:14 +07:00
Darkraise c270e42d87 chore: update documents 2026-02-15 11:50:39 +07:00
DarkraiseandGitHub 4594a66721 Merge pull request #10 from darkraise/feature/pagination-enhancement
feat: add server-side pagination to all list endpoints
2026-02-15 01:16:10 +07:00
Darkraise b2456efe40 fix: fetch all local playlists for duplicate detection 2026-02-15 01:15:57 +07:00
Darkraise d0681ef3ab feat: add server-side pagination to all list endpoints
Add page-based pagination with proper total counts to:
- Favorites games endpoint (backend + frontend)
- Community playlists endpoint (backend + frontend)
- User playlists endpoint (backend + frontend)
- Roles endpoint (backend + frontend)

Changes include:
- Backend routes now accept page/limit params and return
  { data, pagination } response format
- Services return { data, total } for paginated queries
- Frontend hooks updated with page/limit parameters
- Views display PaginationWithInfo component when needed
- DataTable uses shared Pagination component
- Mutation hooks use invalidateQueries instead of manual
  cache updates for consistency with paginated data
- Increase default siblingCount to 2 in Pagination
2026-02-15 01:04:51 +07:00
Darkraise ea2d913766 fix: preserve search params during auth redirect
When a protected page includes query parameters (e.g., filters,
pagination), they were lost after login because only pathname was
extracted from location state. Now both pathname and search params
are preserved when redirecting back after authentication.
2026-02-14 20:45:11 +07:00
DarkraiseandGitHub 1dea285285 Merge pull request #9 from darkraise/feature/html5-game-enhancement
fix: improve HTML5 game compatibility and auth redirects
2026-02-14 20:08:47 +07:00
Darkraise 37d6ee239c fix: prevent open redirect in login return URL handling
- Add isValidReturnUrl() helper in LoginForm.tsx
- Add sanitizeRedirectPath() helper in AuthContext.tsx
- Apply sanitization to both regular login and guest login flows
2026-02-14 20:07:30 +07:00
Darkraise a990db1ce2 fix: improve HTML5 game compatibility and auth redirects
- Add COEP headers for iframe embedding when parent has COEP enabled
- Remove URL encoding in game proxy path (handler expects raw URLs)
- Preserve returnUrl as query param across cross-browser navigation
- Pass redirect path to loginAsGuest for proper return after login
- Filter play history to only return completed sessions
- Add documentation for CORS header responsibilities
2026-02-14 16:47:57 +07:00