Merge pull request 'Release prep: legal-safe setup, hosting requirements, in-app stream proxy, Gitea CI, cleanup' (#2) from release-prep into rebrand-and-docs

Reviewed-on: #2
Reviewed-by: branderson <brad@branderson.io>
This commit was merged in pull request #2.
This commit is contained in:
2026-10-13 22:14:30 -07:00
49 changed files with 562 additions and 4949 deletions
+6 -3
View File
@@ -10,8 +10,13 @@ JWT_SECRET=replace-with-a-secure-random-string
# ---------------------------------------------------------------------------
# Container Images
# ---------------------------------------------------------------------------
# Tag for the locally built images
# Built from source by default. To pull published images instead, set the
# registry path they live under (ending in /), then:
# docker compose pull && docker compose up -d
# IMAGE_PREFIX=registry.example.com/owner/
# IMAGE_TAG=latest
# "Latest release" API URL to check for updates (GitHub or Gitea); off when unset
# APP_UPDATE_URL=https://git.example.com/api/v1/repos/owner/dotcom-arcade/releases/latest
# ---------------------------------------------------------------------------
# Port (host mapping)
@@ -100,14 +105,12 @@ ENABLE_CACHE_PREWARM=true
# ---------------------------------------------------------------------------
# Application
# ---------------------------------------------------------------------------
# HOME_RECENT_HOURS=24
# ENABLE_CGI=false
# Streamed play for games that can't run in a browser (Java, Shockwave, the
# original Flash Player, Windows games). Set a token (e.g. `openssl rand -hex 32`)
# and enable the profile. See docs/10-features/13-streaming.md.
# COMPOSE_PROFILES=streaming
# STREAMING_API_TOKEN=
# STREAM_PORT=8081
# MAX_SESSIONS=4
# Sign in users an authenticating reverse proxy (e.g. Authentik forward auth)
# names in a header. Off unless TRUSTED_HEADER_USERNAME and TRUSTED_PROXIES are
+65
View File
@@ -0,0 +1,65 @@
# Builds the app, session manager and streamer images and pushes them to this
# Gitea instance's container registry, under the repository's owner.
#
# Needs repository secrets REGISTRY_USERNAME and REGISTRY_TOKEN: a user, and an
# access token of theirs with package write access.
name: Images
on:
push:
branches: [main]
tags: ['v*.*.*']
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- image: dotcom-arcade
context: .
- image: dotcom-arcade-session-manager
context: streaming/session-manager
- image: dotcom-arcade-streamer
context: streaming/streamer
steps:
- uses: actions/checkout@v4
# The registry lives on the same host as this Gitea instance
- name: Locate the registry
id: registry
run: |
host=${GITHUB_SERVER_URL#*://}
echo "host=${host%/}" >> "$GITHUB_OUTPUT"
echo "owner=${GITHUB_REPOSITORY_OWNER,,}" >> "$GITHUB_OUTPUT"
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ${{ steps.registry.outputs.host }}
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_TOKEN }}
- id: meta
uses: docker/metadata-action@v5
with:
images: ${{ steps.registry.outputs.host }}/${{ steps.registry.outputs.owner }}/${{ matrix.image }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
type=ref,event=branch
- uses: docker/build-push-action@v6
with:
context: ${{ matrix.context }}
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64
build-args: |
VERSION=${{ steps.meta.outputs.version }}
provenance: false
-3
View File
@@ -1,8 +1,5 @@
blank_issues_enabled: true
contact_links:
- name: Ask a Question
url: https://github.com/darkraise/flashpoint-web/discussions
about: Use Discussions for questions, help, and general conversation
- name: Flashpoint Archive
url: https://github.com/FlashpointProject
about: For issues with Flashpoint itself (not this web interface)
-204
View File
@@ -1,204 +0,0 @@
# GitHub Actions Workflows
This directory contains automated workflows for the Flashpoint Web project.
## Workflows
### 1. Docker Build and Push (`docker-build-push.yml`)
Builds and pushes the single Docker image to the container registry.
#### Triggers
- **Version tags** (`v*.*.*`): Builds and pushes the image with version tags and `latest` tag
- **Manual dispatch**: Can be triggered manually from GitHub Actions tab
#### Image Built
- `flashpoint-web` - Single image serving both the REST API (including game
content) and the React web UI
#### Container Registry
The workflow pushes images to **Docker Hub**.
#### Setup Instructions
##### Docker Hub (Required)
1. **Create Docker Hub account** at https://hub.docker.com
2. **Create access token**:
- Go to Account Settings → Security → Access Tokens
- Click "New Access Token"
- Name: "GitHub Actions"
- Permissions: Read & Write
- Copy the token (you won't see it again!)
3. **Add GitHub Secrets**:
- Go to your repository → Settings → Secrets and variables → Actions
- Click "New repository secret"
- Add two secrets:
- `DOCKERHUB_USERNAME`: Your Docker Hub username
- `DOCKERHUB_TOKEN`: The access token from step 2
4. **Image will be pushed to**:
```
docker.io/darkraise/flashpoint-web:latest
```
#### Image Tags
The workflow automatically creates multiple tags when you push a version tag:
| Tag Type | Example | Description |
|----------|---------|-------------|
| `latest` | `latest` | Always created for every version tag |
| Full version | `1.2.3` | Git tag `v1.2.3` → `1.2.3` |
| Major.Minor | `1.2` | Git tag `v1.2.3` → `1.2` |
| Major | `1` | Git tag `v1.2.3` → `1` |
#### Creating a Release
To create a versioned release:
```bash
# Ensure you're on master branch
git checkout master
# Tag the commit
git tag v1.0.0
# Push the tag (this triggers the workflow)
git push origin v1.0.0
```
This will create images tagged as:
- `1.0.0`
- `1.0`
- `1`
- `latest`
#### Platform Support
Images are built for:
- `linux/amd64` (x86_64) - Standard servers and desktops
**Note:** ARM64 support has been disabled to significantly reduce build times. If you need ARM64 images, you can build them locally or enable multi-platform builds by adding `linux/arm64` to the platforms in the workflow file.
#### Build Caching
The workflow uses GitHub Actions cache to speed up builds:
- Layer cache stored between runs
- Significantly faster builds after the first run
- Cache automatically invalidated when dependencies change
#### Usage Examples
##### Pull and Run the Image
```bash
# Pull the latest image
docker pull darkraise/flashpoint-web:latest
# Or use a specific version
docker pull darkraise/flashpoint-web:1.0.0
```
##### Use in Docker Compose
Update your `docker-compose.yml` to use the pre-built image:
```yaml
services:
flashpoint-web:
image: darkraise/flashpoint-web:latest
# Remove build section
ports:
- "80:3100"
# ... rest of config
```
Then simply run:
```bash
docker compose pull # Pull the latest image
docker compose up -d # Start the service
```
#### Making Images Public
By default, Docker Hub images are private. To make them public:
1. Go to https://hub.docker.com/repositories
2. Click on the repository (e.g., `flashpoint-web`)
3. Click "Settings"
4. Change visibility to "Public"
#### Troubleshooting
##### Build Fails
Check the Actions tab for detailed error logs:
```
https://github.com/<username>/<repo>/actions
```
##### Docker Hub Not Pushing
Verify secrets are set correctly:
- Repository → Settings → Secrets and variables → Actions
- Check `DOCKERHUB_USERNAME` and `DOCKERHUB_TOKEN` exist
##### Image Not Found
For private images, authenticate first:
```bash
docker login docker.io
```
#### Performance
Typical build times (optimized for amd64 only):
- First build: 3-5 minutes
- Subsequent builds (with cache): 1-2 minutes
- Frontend and backend build stages run within a single image build
**Optimizations applied:**
- Single platform (amd64) instead of multi-platform
- npm ci with `--prefer-offline` and `--no-audit` flags
- Disabled provenance and SBOM generation
- Latest BuildKit image
- GitHub Actions cache for Docker layers
#### Security
- Secrets are never exposed in logs
- Images are scanned for vulnerabilities (optional - can add)
- Multi-stage builds minimize attack surface
- Non-root users in all containers
- Read-only root filesystems where possible
---
### 2. Performance Budget (`performance-budget.yml`)
Frontend performance monitoring workflow.
See the workflow file for details.
---
## Adding New Workflows
To add a new workflow:
1. Create a new YAML file in `.github/workflows/`
2. Use the GitHub Actions syntax
3. Test locally with [act](https://github.com/nektos/act) (optional)
4. Push to trigger the workflow
## References
- [GitHub Actions Documentation](https://docs.github.com/en/actions)
- [Docker Build Push Action](https://github.com/docker/build-push-action)
- [GitHub Container Registry](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry)
-144
View File
@@ -1,144 +0,0 @@
name: Build and Push Docker Images
on:
push:
tags:
- 'v*.*.*'
workflow_dispatch:
env:
REGISTRY_DOCKERHUB: docker.io
jobs:
build-and-push:
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
outputs:
version: ${{ steps.meta.outputs.version }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
driver-opts: |
image=moby/buildkit:latest
network=host
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY_DOCKERHUB }}
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract metadata (tags, labels)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY_DOCKERHUB }}/${{ secrets.DOCKERHUB_USERNAME }}/flashpoint-web
tags: |
# Tag with full version (v1.0.0 -> 1.0.0)
type=semver,pattern={{version}}
# Tag with major.minor (v1.0.0 -> 1.0)
type=semver,pattern={{major}}.{{minor}}
# Tag with major version (v1.0.0 -> 1)
type=semver,pattern={{major}}
# Latest tag
type=raw,value=latest
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha,scope=flashpoint-web
cache-to: type=gha,scope=flashpoint-web,mode=max
platforms: linux/amd64
build-args: |
BUILD_DATE=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.created'] }}
VCS_REF=${{ github.sha }}
VERSION=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.version'] }}
provenance: false
sbom: false
summary:
needs: build-and-push
runs-on: ubuntu-latest
if: always()
steps:
- name: Build Summary
run: |
echo "## Docker Build Summary" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Image Built:" >> $GITHUB_STEP_SUMMARY
echo "- \`flashpoint-web\` (API + UI)" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Registry:" >> $GITHUB_STEP_SUMMARY
echo "- Docker Hub: \`${{ secrets.DOCKERHUB_USERNAME }}/flashpoint-web\`" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Pull Command:" >> $GITHUB_STEP_SUMMARY
echo "\`\`\`bash" >> $GITHUB_STEP_SUMMARY
echo "docker pull ${{ secrets.DOCKERHUB_USERNAME }}/flashpoint-web:latest" >> $GITHUB_STEP_SUMMARY
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
create-release:
needs: build-and-push
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/v')
permissions:
contents: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Get version from tag
id: version
run: echo "version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
- name: Generate release notes
id: release-notes
run: |
# Get the previous tag
PREV_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")
echo "## Docker Image" >> release_notes.md
echo "" >> release_notes.md
echo "Pull the image from Docker Hub:" >> release_notes.md
echo "" >> release_notes.md
echo "\`\`\`bash" >> release_notes.md
echo "docker pull ${{ secrets.DOCKERHUB_USERNAME }}/flashpoint-web:${{ steps.version.outputs.version }}" >> release_notes.md
echo "\`\`\`" >> release_notes.md
echo "" >> release_notes.md
if [ -n "$PREV_TAG" ]; then
echo "## Changes since $PREV_TAG" >> release_notes.md
echo "" >> release_notes.md
git log --pretty=format:"- %s (%h)" $PREV_TAG..HEAD >> release_notes.md
echo "" >> release_notes.md
fi
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.version.outputs.version }}
name: Release ${{ steps.version.outputs.version }}
body_path: release_notes.md
draft: false
prerelease: ${{ contains(steps.version.outputs.version, '-') }}
generate_release_notes: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+1 -6
View File
@@ -1193,13 +1193,8 @@ Before submitting code, verify:
## Environment
```bash
FLASHPOINT_PATH=D:/Flashpoint # Required - path to Flashpoint installation
FLASHPOINT_PATH=/path/to/flashpoint # Required - path to Flashpoint installation
JWT_SECRET=your-secret # Required - random string for JWT signing
DOMAIN=http://localhost:5173 # Optional - frontend URL for CORS
LOG_LEVEL=info # Optional - debug, info, warn, error
```
## Source References
- Flashpoint Launcher: `D:\Repositories\Community\launcher`
- Flashpoint Game Server: `D:\Repositories\Community\FlashpointGameServer`
+64 -18
View File
@@ -17,6 +17,21 @@ Flash Player stream from your server.
> Flashpoint Archive. It's built on
> [flashpoint-web](https://github.com/darkraise/flashpoint-web) by darkraise.
## Before you start: hosting requirements
> [!WARNING]
>
> **Dotcom Arcade needs a lot of disk, and streaming needs a lot of CPU.** Check
> these before you download anything.
| Resource | Needed |
| ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Storage** | **More than 2.3 TB** for Flashpoint Ultimate 14, and growing with each release. 4.6 TB if you keep the downloaded archives to seed them. A spare desktop drive won't do. |
| **Time** | A day or more to download and extract, depending on your connection and disks. |
| **Server** | Linux with Docker. 2 CPU cores and 4 GB of RAM serve the site and in-browser games to a household. |
| **Streaming** | Optional, and heavy: every player gets their own container running the game and encoding its video on the CPU. Plan on roughly 1 to 2 CPU cores and 1 GB of RAM per player streaming at once, and a few Mbps of upload each. |
| **HTTPS** | Required for streaming, since browsers only allow its video decoder on secure pages. Recommended anyway. |
## Features
- **Plays almost everything.**
@@ -54,27 +69,50 @@ Flash Player stream from your server.
## Getting started
You need a Linux server with Docker, and enough disk for Flashpoint Ultimate:
about 2.3 TB for version 14, or 4.6 TB if you keep the downloaded archives to
seed. Expect the download and extraction to take a day or more.
Check the [hosting requirements](#before-you-start-hosting-requirements) first.
### 1. Get Flashpoint Ultimate
[`scripts/setup-flashpoint.sh`](scripts/setup-flashpoint.sh) downloads the
torrent with `aria2c` and then extracts each archive in turn. It checks free
space before each one and deletes it afterwards, so the disk never needs room
for both. It also pulls the database, settings and playlists out of
`Base_1.zip`, which otherwise only holds Windows software. Re-run it to resume
after an interruption.
Download two files from Flashpoint's
[downloads page](https://flashpointarchive.org/downloads):
- the **Flashpoint Ultimate** `.torrent` file;
- **Flashpoint Infinity for Linux** (`.7z`), whose software streaming needs.
[`scripts/setup-flashpoint.sh`](scripts/setup-flashpoint.sh) then does the rest:
- It checks that you have the disk space, and stops before downloading anything
if you don't.
- It downloads the torrent with `aria2c`.
- It extracts each archive in turn and deletes it afterwards, so the disk never
needs room for both.
- It pulls the database, settings and playlists out of `Base_1.zip`, which
otherwise only holds Windows software.
Re-run it to resume after an interruption.
```sh
sudo apt install aria2 7zip tmux
tmux new -s flashpoint
scripts/setup-flashpoint.sh --with-infinity /srv/flashpoint
scripts/setup-flashpoint.sh --torrent FlashpointUltimate14.torrent \
--infinity fp14.0.3_lin_20251201.7z --seed-ratio 1.0 /srv/flashpoint
```
`--with-infinity` adds Flashpoint Infinity for Linux's software, which streaming
needs. `--keep-archives` keeps the archives so you can keep seeding.
#### Please seed
Flashpoint Ultimate is shared over BitTorrent, and stays available only as long
as people seed it. If you can, give back at least what you downloaded:
- **Seed before extracting.** `--seed-ratio 1.0` keeps seeding after the
download until you've uploaded as much as you downloaded. `--seed-hours 24`
seeds for a day. With both, seeding stops at whichever comes first. This needs
no extra disk space, but extraction waits until seeding stops.
- **Seed for the long term.** `--keep-archives` keeps the archives after
extracting (about 4.6 TB in total), so a torrent client such as qBittorrent or
Transmission can go on seeding the downloads directory.
- **Let peers reach you.** The download listens on ports 6881-6999, TCP and UDP.
Forwarding them on your router and allowing them through your firewall lets
more people download from you.
### 2. Configure
@@ -105,7 +143,7 @@ Under **Settings → App** you can set the site name and tagline.
Streaming plays the games browsers can't. To turn it on:
1. Run the setup script with `--with-infinity`.
1. Run the setup script with `--infinity`.
2. Add to `.env`:
```sh
@@ -114,13 +152,12 @@ Streaming plays the games browsers can't. To turn it on:
```
3. Rebuild with `docker compose up -d --build`.
4. Route `/stream/` on your site to the session manager (port 8081). Streams
need HTTPS. See the [streaming guide](docs/10-features/13-streaming.md) for
an nginx example.
4. Serve the site over HTTPS. The app proxies streams itself, so your reverse
proxy only needs to pass WebSocket upgrades and allow long-lived connections.
See the [streaming guide](docs/10-features/13-streaming.md).
The session manager starts containers through the Docker socket, which amounts
to root on the host. Keep it off the public internet. Only `/stream/` needs to
be reachable, and only through your proxy.
to root on the host. It publishes no ports: only the app talks to it.
### Signing in through a reverse proxy
@@ -156,6 +193,15 @@ and development. Useful starting points:
- [Selkies](https://github.com/selkies-project/selkies), which streams the
games.
## Legal
Flashpoint Archive is a preservation project whose legal standing has never been
settled. It removes games when their rights holders ask. Dotcom Arcade contains
no games and links to no game downloads. You get Flashpoint from Flashpoint's
own site, and you are responsible for complying with copyright law where you
live. Rights holders who want a game removed should contact
[Flashpoint Archive](https://flashpointarchive.org/).
## License
GPL-3.0, like flashpoint-web. See [LICENSE](LICENSE).
-1
View File
@@ -22,7 +22,6 @@ BCRYPT_SALT_ROUNDS=10
# ENABLE_CGI=false
# Misc
HOME_RECENT_HOURS=24
# ---------------------------------------------------------------------------
# Database Performance Optimization (for network storage)
+4 -2
View File
@@ -167,12 +167,12 @@ export const config = {
bcryptSaltRounds: parseInt(process.env.BCRYPT_SALT_ROUNDS || '', 10) || 10,
homeRecentHours: parseInt(process.env.HOME_RECENT_HOURS || '', 10) || 24,
// Session manager that streams games which can't run in a browser, such as
// Java applets (flashpoint-ultimate-server). Streaming is off unless both are set.
streamingApiUrl: process.env.STREAMING_API_URL || null,
streamingApiToken: process.env.STREAMING_API_TOKEN || null,
// Where /stream/ is proxied to: the session manager's stream port
streamingStreamUrl: process.env.STREAMING_STREAM_URL || null,
// Sign users in from a header set by an authenticating reverse proxy, such as
// Authentik forward auth. Off unless the header name and the proxy addresses
@@ -208,6 +208,8 @@ export const config = {
// marks the build as unreleased. `||` is deliberate: a whitespace-only value
// must collapse to null, which `??` would not do.
appVersion: process.env.APP_VERSION?.trim() || null,
// A GitHub- or Gitea-style "latest release" API URL; update checks are off without it
appUpdateUrl: process.env.APP_UPDATE_URL?.trim() || null,
} as const;
/** Resolves external image CDN URLs from Flashpoint preferences, with hardcoded fallbacks. */
-56
View File
@@ -1,56 +0,0 @@
import { Router, Request, Response } from 'express';
import { logger } from '../utils/logger';
import { asyncHandler } from '../middleware/asyncHandler';
const router = Router();
// In-memory cache for GitHub star count (avoid hitting 60/hr unauthenticated rate limit)
let cachedStars: { count: number; fetchedAt: number } | null = null;
const CACHE_TTL_MS = 15 * 60 * 1000;
// Proxy to avoid browser CORS issues with GitHub API
router.get(
'/stars',
asyncHandler(async (_req: Request, res: Response) => {
if (cachedStars && Date.now() - cachedStars.fetchedAt < CACHE_TTL_MS) {
return res.json({
success: true,
data: { stars: cachedStars.count },
});
}
const repoUrl = 'https://api.github.com/repos/darkraise/flashpoint-web';
const response = await fetch(repoUrl, {
headers: {
Accept: 'application/vnd.github.v3+json',
'User-Agent': 'Flashpoint-Web-App',
},
});
if (!response.ok) {
// If rate-limited and we have a stale cache, return it
if (cachedStars) {
logger.debug('[GitHub] Rate limited, returning stale cache');
return res.json({
success: true,
data: { stars: cachedStars.count },
});
}
throw new Error(`GitHub API error: ${response.status}`);
}
const data = (await response.json()) as { stargazers_count: number };
cachedStars = { count: data.stargazers_count, fetchedAt: Date.now() };
res.json({
success: true,
data: {
stars: data.stargazers_count,
},
});
})
);
export default router;
-2
View File
@@ -25,7 +25,6 @@ import ratingsRouter from './ratings';
import gameSavesRouter from './game-saves';
import discoveryRouter from './discovery';
import ruffleRouter from './ruffle';
import githubRouter from './github';
import domainsRouter from './domains';
import healthRouter from './health';
import cacheRouter from './cache';
@@ -40,7 +39,6 @@ export function setupRoutes(app: Express): void {
app.use('/api/settings/auth', authSettingsRouter);
app.use('/api/settings', systemSettingsRouter);
app.use('/api/ruffle', ruffleRouter);
app.use('/api/github', githubRouter);
app.use('/api/domains', domainsRouter);
app.use('/api/jobs', jobsRouter);
app.use('/api/users', usersRouter);
+87
View File
@@ -0,0 +1,87 @@
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import http, { type Server } from 'http';
import type { AddressInfo } from 'net';
import express from 'express';
import { mountStreamProxy } from './streamProxy';
vi.mock('../utils/logger', () => ({
logger: { info: vi.fn(), error: vi.fn(), debug: vi.fn(), warn: vi.fn() },
}));
let upstream: Server;
let app: Server;
let base: string;
function listen(server: Server): Promise<number> {
return new Promise((resolve) =>
server.listen(0, '127.0.0.1', () => resolve((server.address() as AddressInfo).port))
);
}
beforeAll(async () => {
// A stand-in session manager: echoes the path, and accepts WebSocket upgrades
upstream = http.createServer((req, res) => {
res.setHeader('Content-Type', 'text/plain');
res.end(`upstream saw ${req.method} ${req.url}`);
});
upstream.on('upgrade', (req, socket) => {
socket.write(
'HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\r\n'
);
socket.write(`ws ${req.url}`);
socket.end();
});
const upstreamPort = await listen(upstream);
const expressApp = express();
const attach = mountStreamProxy(expressApp, `http://127.0.0.1:${upstreamPort}`);
expressApp.get('/other', (_req, res) => {
res.send('app');
});
app = http.createServer(expressApp);
attach(app);
base = `http://127.0.0.1:${await listen(app)}`;
});
afterAll(() => {
app.close();
upstream.close();
});
describe('stream proxy', () => {
it('forwards /stream/ requests with their full path', async () => {
const res = await fetch(`${base}/stream/abc/_fp/start`, { method: 'POST' });
expect(await res.text()).toBe('upstream saw POST /stream/abc/_fp/start');
});
it('leaves other routes to the app', async () => {
expect(await (await fetch(`${base}/other`)).text()).toBe('app');
});
it('forwards WebSocket upgrades', async () => {
const received = await new Promise<string>((resolve, reject) => {
const req = http.request(`${base}/stream/abc/websocket`, {
headers: { Connection: 'Upgrade', Upgrade: 'websocket' },
});
req.on('upgrade', (res, socket, head) => {
expect(res.statusCode).toBe(101);
let data = head.toString();
socket.on('data', (chunk) => (data += chunk.toString()));
socket.on('end', () => resolve(data));
});
req.on('error', reject);
req.end();
});
expect(received).toBe('ws /stream/abc/websocket');
});
it('answers 502 when the session manager is down', async () => {
const down = express();
mountStreamProxy(down, 'http://127.0.0.1:9');
const server = http.createServer(down);
const port = await listen(server);
const res = await fetch(`http://127.0.0.1:${port}/stream/abc/`);
server.close();
expect(res.status).toBe(502);
});
});
+40
View File
@@ -0,0 +1,40 @@
import { ServerResponse, type IncomingMessage, type Server } from 'http';
import type { Socket } from 'net';
import type { Express } from 'express';
import { createProxyMiddleware } from 'http-proxy-middleware';
import { logger } from '../utils/logger';
/**
* Proxies /stream/ to the session manager, WebSockets included, so a reverse
* proxy in front of the app needs no route of its own for streams. Returns a
* function that wires WebSocket upgrades into the HTTP server once it exists.
*/
export function mountStreamProxy(app: Express, target: string): (server: Server) => void {
const proxy = createProxyMiddleware('/stream/', {
target,
ws: true,
logLevel: 'silent',
// Save sync is a long-lived event stream; don't let the server's idle timeout cut it
onProxyReq: (_proxyReq, req) => {
req.socket.setTimeout(0);
},
onError: (error, req, res) => {
logger.warn('[Streaming] Stream proxy error', { url: req.url, error: error.message });
// A failed WebSocket upgrade hands over the raw socket instead of a response
if (!(res instanceof ServerResponse)) {
(res as Socket).destroy();
} else if (!res.headersSent) {
res.writeHead(502).end();
}
},
});
app.use(proxy);
return (server) => {
server.on('upgrade', (req: IncomingMessage, socket: Socket, head: Buffer) => {
if (!req.url?.startsWith('/stream/')) return;
socket.setTimeout(0);
proxy.upgrade?.(req as Parameters<NonNullable<typeof proxy.upgrade>>[0], socket, head);
});
};
}
-5
View File
@@ -80,11 +80,6 @@ router.get(
Record<string, unknown>
> = systemSettings.getPublicSettings();
if (!publicSettings.app) {
publicSettings.app = {};
}
publicSettings.app.homeRecentHours = config.homeRecentHours;
// Inject edition/version from config (auto-detected from version.txt, not stored in DB)
if (!publicSettings.metadata) {
publicSettings.metadata = {};
+1 -1
View File
@@ -51,7 +51,7 @@ const INFO = {
updateAvailable: true,
isUnreleasedBuild: false,
publishedAt: '2026-08-12T07:11:45Z',
releaseUrl: 'https://github.com/darkraise/flashpoint-web/releases/tag/v1.0.42',
releaseUrl: 'https://git.example.com/owner/dotcom-arcade/releases/tag/v1.0.42',
changelog: '## Changes',
checkedAt: '2026-08-12T08:00:00.000Z',
lastCheckFailed: false,
+10
View File
@@ -44,6 +44,8 @@ import { zipManager } from './game/zip-manager';
import { gameZipServer } from './game/gamezipserver';
import gameProxyRouter from './routes/game-proxy';
import gameZipRouter from './routes/game-zip';
import { mountStreamProxy } from './routes/streamProxy';
import { StreamingService } from './services/StreamingService';
/**
* Compute SHA-256 CSP hashes for inline <script> blocks (those without a src
@@ -112,6 +114,13 @@ async function startServer() {
app.use('/game-proxy', gameCors, startupGate, gameProxyRouter);
app.use('/game-zip', gameCors, startupGate, gameZipRouter);
// Streams are proxied before helmet and compression, which would break the
// streamer's page and its event stream
const attachStreamProxy =
StreamingService.isEnabled() && config.streamingStreamUrl
? mountStreamProxy(app, config.streamingStreamUrl)
: null;
// When the backend also serves the SPA, allow the app's own resources by
// hash/keyword rather than the blanket 'unsafe-inline'/'unsafe-eval':
// - scriptSrc: 'self' for hashed bundles, sha256 hashes for the inline theme
@@ -230,6 +239,7 @@ async function startServer() {
logger.info(`🎮 Game content: /game-proxy/* and /game-zip/* (integrated)`);
logger.info(`🌍 Environment: ${config.nodeEnv}`);
});
attachStreamProxy?.(server);
// Prevent connection exhaustion - critical for handling game file requests
server.keepAliveTimeout = 65000; // 65s - slightly above common load balancer timeout
+22 -3
View File
@@ -4,7 +4,15 @@ vi.mock('../utils/logger', () => ({
logger: { info: vi.fn(), error: vi.fn(), debug: vi.fn(), warn: vi.fn() },
}));
const configMock = vi.hoisted(() => ({ config: { appVersion: '1.0.38' as string | null } }));
const RELEASE_URL = 'https://git.example.com/api/v1/repos/owner/dotcom-arcade/releases/latest';
const configMock = vi.hoisted(() => ({
config: {
appVersion: '1.0.38' as string | null,
appUpdateUrl: 'https://git.example.com/api/v1/repos/owner/dotcom-arcade/releases/latest' as
| string
| null,
},
}));
vi.mock('../config', () => configMock);
vi.mock('axios', () => ({
@@ -20,7 +28,7 @@ function release(tagName: string) {
data: {
tag_name: tagName,
published_at: '2026-08-12T07:11:45Z',
html_url: `https://github.com/darkraise/flashpoint-web/releases/tag/${tagName}`,
html_url: `https://git.example.com/owner/dotcom-arcade/releases/tag/${tagName}`,
body: '## Changes since v1.0.37\n\n- feat: something',
},
};
@@ -30,6 +38,7 @@ beforeEach(() => {
vi.clearAllMocks();
AppUpdateService.clearCache();
configMock.config.appVersion = '1.0.38';
configMock.config.appUpdateUrl = RELEASE_URL;
});
describe('AppUpdateService.getUpdateInfo', () => {
@@ -105,10 +114,20 @@ describe('AppUpdateService.getUpdateInfo', () => {
await AppUpdateService.getUpdateInfo();
expect(axios.get).toHaveBeenCalledWith(
'https://api.github.com/repos/darkraise/flashpoint-web/releases/latest',
RELEASE_URL,
expect.objectContaining({ timeout: 10000 })
);
});
it('checks nothing when no release source is configured', async () => {
configMock.config.appUpdateUrl = null;
const info = await AppUpdateService.getUpdateInfo(true);
expect(axios.get).not.toHaveBeenCalled();
expect(info.enabled).toBe(false);
expect(info.updateAvailable).toBe(false);
});
});
describe('AppUpdateService caching', () => {
+17 -11
View File
@@ -3,12 +3,13 @@ import { config } from '../config';
import { logger } from '../utils/logger';
import { compareVersions, isReleaseVersion, normalizeVersion } from '../utils/version';
const LATEST_RELEASE_URL = 'https://api.github.com/repos/darkraise/flashpoint-web/releases/latest';
const CACHE_TTL_MS = 60 * 60 * 1000;
const FORCED_REFRESH_FLOOR_MS = 60 * 1000;
const REQUEST_TIMEOUT_MS = 10000;
export interface AppUpdateInfo {
/** False when no release source is configured (APP_UPDATE_URL) */
enabled: boolean;
currentVersion: string | null;
latestVersion: string | null;
updateAvailable: boolean;
@@ -28,7 +29,8 @@ interface CachedRelease {
fetchedAt: number;
}
interface GitHubRelease {
/** The fields GitHub's and Gitea's release APIs have in common */
interface Release {
tag_name?: unknown;
published_at?: unknown;
html_url?: unknown;
@@ -50,13 +52,16 @@ export class AppUpdateService {
private static lastFailedAt: number | null = null;
static async getUpdateInfo(force = false): Promise<AppUpdateInfo> {
if (config.appUpdateUrl === null) {
return this.buildInfo();
}
// A forced check still respects a 60s floor. Express rate limiting allows
// 100 requests a minute, which is no protection for a GitHub quota of 60 an
// hour that this shares with the star count and the Ruffle update check.
// 100 requests a minute, which is no protection for a release API's quota
// (GitHub allows 60 an hour, shared with the Ruffle update check).
const maxAge = force ? FORCED_REFRESH_FLOOR_MS : CACHE_TTL_MS;
const age = this.cached === null ? Infinity : Date.now() - this.cached.fetchedAt;
// A failure also respects the 60s floor, even when force is true, so a
// GitHub outage or rate limit can't be hammered every request while it
// source's outage or rate limit can't be hammered every request while it
// still shares the API's blocking timeout with each caller.
const failureAge = this.lastFailedAt === null ? Infinity : Date.now() - this.lastFailedAt;
const withinFailureFloor = failureAge < FORCED_REFRESH_FLOOR_MS;
@@ -69,7 +74,7 @@ export class AppUpdateService {
} catch (error: unknown) {
this.lastCheckFailed = true;
this.lastFailedAt = Date.now();
logger.warn('[AppUpdate] Could not reach GitHub for the latest release', {
logger.warn('[AppUpdate] Could not fetch the latest release', {
error: error instanceof Error ? error.message : 'Unknown error',
});
}
@@ -96,19 +101,19 @@ export class AppUpdateService {
}
private static async requestLatestRelease(): Promise<CachedRelease> {
const response = await axios.get(LATEST_RELEASE_URL, {
const response = await axios.get(config.appUpdateUrl ?? '', {
timeout: REQUEST_TIMEOUT_MS,
headers: {
Accept: 'application/vnd.github.v3+json',
'User-Agent': 'Flashpoint-Web',
Accept: 'application/json',
'User-Agent': 'Dotcom-Arcade',
},
});
const release = (response.data ?? {}) as GitHubRelease;
const release = (response.data ?? {}) as Release;
const tagName = asString(release.tag_name);
if (tagName === null) {
throw new Error('GitHub release response carried no tag_name');
throw new Error('Release response carried no tag_name');
}
return {
@@ -131,6 +136,7 @@ export class AppUpdateService {
: null;
return {
enabled: config.appUpdateUrl !== null,
currentVersion,
latestVersion: latest?.version ?? null,
updateAvailable: comparison !== null && comparison < 0,
+11 -9
View File
@@ -8,6 +8,8 @@
# Usage:
# 1. Copy .env.example to .env and set the required variables
# 2. docker compose up -d --build
# (or, with IMAGE_PREFIX set to a registry's images:
# docker compose pull && docker compose up -d)
# 3. For streaming, set STREAMING_API_TOKEN and COMPOSE_PROFILES=streaming in
# .env, then run `docker compose up -d --build` again
#
@@ -18,7 +20,7 @@
services:
app:
build: .
image: dotcom-arcade:${IMAGE_TAG:-latest}
image: ${IMAGE_PREFIX:-}dotcom-arcade:${IMAGE_TAG:-latest}
container_name: dotcom-arcade
restart: unless-stopped
ports:
@@ -54,10 +56,12 @@ services:
- RATE_LIMIT_WINDOW_MS=${RATE_LIMIT_WINDOW_MS:-60000}
- RATE_LIMIT_MAX_REQUESTS=${RATE_LIMIT_MAX_REQUESTS:-100}
# Application
- HOME_RECENT_HOURS=${HOME_RECENT_HOURS:-24}
# "Latest release" API URL (GitHub or Gitea) for update checks; off when unset
- APP_UPDATE_URL=${APP_UPDATE_URL:-}
- ENABLE_CGI=${ENABLE_CGI:-false}
# Streamed play for games that can't run in a browser (off unless the token is set)
- STREAMING_API_URL=${STREAMING_API_URL:-http://session-manager:8080}
- STREAMING_STREAM_URL=${STREAMING_STREAM_URL:-http://session-manager:8081}
- STREAMING_API_TOKEN=${STREAMING_API_TOKEN:-}
# Sign in users named by an authenticating reverse proxy (off unless both are set)
- TRUSTED_HEADER_USERNAME=${TRUSTED_HEADER_USERNAME:-}
@@ -96,16 +100,14 @@ services:
networks:
- arcade
# Starts a streamer container per player and proxies its stream at /stream/
# Starts a streamer container per player. The app proxies /stream/ to it, so
# it publishes no ports.
session-manager:
profiles: [streaming]
build: streaming/session-manager
image: dotcom-arcade-session-manager:${IMAGE_TAG:-latest}
image: ${IMAGE_PREFIX:-}dotcom-arcade-session-manager:${IMAGE_TAG:-latest}
container_name: dotcom-arcade-session-manager
restart: unless-stopped
ports:
# Route /stream/ on the site to this port (see streaming/README.md)
- "${STREAM_PORT:-8081}:8081"
volumes:
# Starts and removes streamer containers. Docker socket access is
# effectively root on the host.
@@ -115,7 +117,7 @@ services:
- FLASHPOINT_HOST_PATH
# The session manager refuses to start without it
- API_TOKEN=${STREAMING_API_TOKEN:-}
- STREAMER_IMAGE=dotcom-arcade-streamer:${IMAGE_TAG:-latest}
- STREAMER_IMAGE=${IMAGE_PREFIX:-}dotcom-arcade-streamer:${IMAGE_TAG:-latest}
- STREAMER_NETWORK=dotcom-arcade-streams
- MAX_SESSIONS=${MAX_SESSIONS:-4}
- STREAM_PLATFORMS=${STREAM_PLATFORMS:-Java,Shockwave,Flash}
@@ -129,7 +131,7 @@ services:
streamer:
profiles: [streaming]
build: streaming/streamer
image: dotcom-arcade-streamer:${IMAGE_TAG:-latest}
image: ${IMAGE_PREFIX:-}dotcom-arcade-streamer:${IMAGE_TAG:-latest}
scale: 0
networks:
+2 -2
View File
@@ -205,9 +205,9 @@ Routes are defined in `App.tsx` with nested routing structure:
<AppShell>
<Routes>
{/* Public routes */}
<Route path="/flash-games" element={
<Route path="/browse" element={
<ProtectedRoute requireAuth={false}>
<FlashGamesView />
<BrowseView />
</ProtectedRoute>
} />
@@ -286,16 +286,7 @@ interface SidebarProps {
```typescript
const gameNavItems: NavItem[] = [
{
path: '/flash-games',
iconImage: '/images/Flash.png',
label: 'Flash Games',
},
{
path: '/html5-games',
iconImage: '/images/HTML5.png',
label: 'HTML5 Games',
},
{ path: '/', icon: Home, label: 'Home' },
{ path: '/animations', icon: Film, label: 'Animations' },
{ path: '/browse', icon: Gamepad2, label: 'Browse' },
];
@@ -360,11 +351,6 @@ const isNavItemActive = (item: NavItem) => {
);
}
// Special case: Home (/) should highlight Flash Games
if (location.pathname === '/' && itemPath === '/flash-games') {
return true;
}
return location.pathname === itemPath;
};
```
+9 -20
View File
@@ -6,24 +6,24 @@ Dotcom Arcade frontend.
## Route Structure
```
/ → Redirects to /flash-games
/ → HomeView (discovery homepage)
/login → LoginView (no auth required)
/register → RegisterView (no auth required)
/unauthorized → UnauthorizedView
# Game Views (Public/Guest Accessible)
/flash-games → FlashGamesView
/html5-games → HTML5GamesView
/browse → BrowseView
/animations → AnimationsView
/games/:id → GameDetailView
/browse/:id → GamePage (also /animations/:id, /games/:id)
/flash/*, /html5/* → Redirect to the same path under /browse
# Protected Routes (Auth Required)
/games/:id/play → GamePlayerView (requires "games.play" permission)
/browse/:id/play → GamePage, game started (requires "games.play")
/dashboard → DashboardView
/favorites → FavoritesView
/playlists → PlaylistsView
/playlists/:id → PlaylistDetailView
/playlists → UserPlaylistsView
/playlists/:id → UserPlaylistDetailView
/flashpoint-playlists → PlaylistsView
/settings → SettingsView
# Admin Routes (Permission-Based)
@@ -91,17 +91,6 @@ interface ProtectedRouteProps {
## Key Views
### FlashGamesView / HTML5GamesView
Pre-filtered game browsing views.
**Features:**
- Platform-specific filtering
- Grid/List view toggle
- Pagination
- Add to favorites/playlists
### BrowseView
Main game browsing view with full filtering.
@@ -191,9 +180,9 @@ Routes are configured in `App.tsx`:
<AppShell>
<Routes>
{/* Public routes */}
<Route path="/flash-games" element={
<Route path="/browse" element={
<ProtectedRoute requireAuth={false}>
<FlashGamesView />
<BrowseView />
</ProtectedRoute>
} />
-1
View File
@@ -202,7 +202,6 @@ Default: 100 requests per 15 minutes. Headers: `X-RateLimit-Limit`,
- [GET /api/metrics/summary](./admin-api.md#get-metrics-summary)
- [GET /api/metrics/endpoints](./admin-api.md#get-endpoint-metrics)
- [GET /api/jobs](./admin-api.md#list-all-jobs)
- [GET /api/github/stars](./admin-api.md#get-github-star-count)
- [GET /api/settings/auth](./admin-api.md#get-auth-settings)
- [PATCH /api/settings/auth](./admin-api.md#update-auth-settings)
- [GET /game-files/\*](./admin-api.md#get-game-file)
-13
View File
@@ -213,19 +213,6 @@ Returns `202 Accepted`
Returns progress percentage, stage, estimated completion time.
## GitHub Endpoints
### Get GitHub Star Count
`GET /api/github/stars` - No auth required
```json
{
"success": true,
"data": { "stars": 2847 }
}
```
## Statistics Endpoints
### Get Archive Statistics
+31 -6
View File
@@ -6,8 +6,11 @@ walks through a first install; this page covers the details.
## Prerequisites
- **Docker:** Engine 24+ with the Compose plugin.
- **Memory:** 4 GB of RAM. With streaming, allow about 1 GB more per concurrent
stream, since each streamer container gets 1 GB of shared memory.
- **Storage:** more than 2.3 TB for Flashpoint Ultimate 14, or 4.6 TB to keep
the downloaded archives.
- **CPU and memory:** 2 cores and 4 GB of RAM for the site. Streaming adds
roughly 1 to 2 CPU cores and 1 GB of RAM per player streaming at once, since
each player's container runs the game and encodes its video on the CPU.
- **A Flashpoint install** at `FLASHPOINT_HOST_PATH`, with
`Data/flashpoint.sqlite`, `Data/Games/`, `Data/Images/`, `Data/Playlists/`
and `Legacy/htdocs/`. `scripts/setup-flashpoint.sh` produces one from the
@@ -18,11 +21,33 @@ walks through a first install; this page covers the details.
| Service | Image | Profile | Purpose |
| ----------------- | ------------------------------ | ----------- | -------------------------------------------------------- |
| `app` | `dotcom-arcade` | (always) | UI, API and game content on port 3100 |
| `session-manager` | `dotcom-arcade-session-manager` | `streaming` | Starts streamer containers; serves `/stream/` on port 8081 |
| `session-manager` | `dotcom-arcade-session-manager` | `streaming` | Starts streamer containers and serves their streams, through the app |
| `streamer` | `dotcom-arcade-streamer` | `streaming` | Built only; the session manager runs one per player |
All three are built from this repository by `docker compose up -d --build`.
### Prebuilt images
To pull published images instead of building, set `IMAGE_PREFIX` in `.env` to
the registry path they live under, ending in `/`, and optionally `IMAGE_TAG`:
```sh
IMAGE_PREFIX=registry.example.com/owner/
IMAGE_TAG=latest
```
Then run `docker compose pull && docker compose up -d`.
### Publishing images
`.gitea/workflows/images.yml` builds all three images on Gitea Actions and
pushes them to the Gitea instance's container registry, under the repository's
owner. Pushes to `main` publish a `main` tag; `v1.2.3` tags publish `1.2.3`,
`1.2` and `latest`. It needs two repository secrets:
- `REGISTRY_USERNAME`: a user;
- `REGISTRY_TOKEN`: an access token of theirs with package write access.
## Volumes
| Host | Container | Purpose |
@@ -37,14 +62,14 @@ socket, and passes `FLASHPOINT_HOST_PATH` on to the streamer containers.
## Ports and networks
- **App:** listens on `WEB_PORT` (default 80) on the host.
- **Session manager:** serves `/stream/` on `STREAM_PORT` (default 8081).
- **Session manager:** publishes no ports. The app proxies `/stream/` to it.
- **`dotcom-arcade` network:** the app and the session manager share it.
- **`dotcom-arcade-streams` network:** holds the streamer containers. It's
internal, so they have no internet access and only the session manager can
reach them.
Put a reverse proxy with HTTPS in front of the app. Streaming needs HTTPS, and
the proxy must route `/stream/` to the session manager (see
the proxy must pass WebSocket upgrades (see
[Streaming](../10-features/13-streaming.md#reverse-proxy)).
## Health checks
@@ -72,7 +97,7 @@ docker compose logs -f session-manager
`docker compose exec app ls /data/flashpoint/Data`.
- **Missing playlists or settings:** `Data/Playlists` or `preferences.json` is
missing. Re-run `scripts/setup-flashpoint.sh`, which takes them from
`Base_1.zip`, or add `--with-infinity`.
`Base_1.zip`, or pass `--infinity`.
- **Permission errors on `./data` or `./logs`:** set `PUID`/`PGID` to the owner
of those folders.
+9 -1
View File
@@ -103,7 +103,6 @@ Two notes that save debugging time:
| Variable | Default | Description |
| -------------------- | ------- | ------------------------------------------------ |
| `ENABLE_CGI` | false | Enable PHP CGI execution for legacy game content |
| `HOME_RECENT_HOURS` | 24 | Hours to look back for "recently added" games |
**Frontend Serving:**
@@ -116,6 +115,14 @@ and API share one origin.
| `FRONTEND_DIST_PATH` | `<backend>/../frontend/dist` | Location of the built frontend (rarely needed) |
| `RUFFLE_DATA_PATH` | `/app/data/ruffle` in production | Where Ruffle is installed. Must sit on a mounted volume, or an in-app update is lost when the container is recreated |
**Images and updates:**
| Variable | Default | Description |
| ---------------- | -------- | ------------------------------------------------------------------------------------------------- |
| `IMAGE_PREFIX` | - | Registry path to pull images from, ending in `/`. Unset builds them from source |
| `IMAGE_TAG` | `latest` | Image tag to build or pull |
| `APP_UPDATE_URL` | - | "Latest release" API URL (GitHub or Gitea) that Settings checks for new versions; off when unset |
**Streaming:**
Games that can't run in a browser, such as Java applets and Shockwave, can be
@@ -125,6 +132,7 @@ streamed by the session manager under `streaming/`. It runs under the
| Variable | Default | Description |
| --------------------- | ----------------------------- | --------------------------------------------------------------- |
| `STREAMING_API_URL` | `http://session-manager:8080` | Session manager's internal API |
| `STREAMING_STREAM_URL` | `http://session-manager:8081` | Where the app proxies `/stream/` to |
| `STREAMING_API_TOKEN` | - | Token shared with the session manager. Streaming is off without it |
**Trusted Header Login:**
+26 -17
View File
@@ -17,7 +17,7 @@ they need the Flashpoint Launcher.
for Flash games.
- **The session manager** (`streaming/session-manager`) starts one streamer
container per session through the Docker socket, and proxies the browser's
connection to it at `/stream/<id>/`.
connection to it at `/stream/<id>/`. The app forwards `/stream/` to it.
- **The streamer** (`streaming/streamer`) launches the game the way
Flashpoint's Linux launcher does, and streams the screen and sound with
[Selkies](https://github.com/selkies-project/selkies). The display is sized to
@@ -37,13 +37,16 @@ and [streaming/streamer/README.md](../../streaming/streamer/README.md).
- **Infinity for Linux's software.** The streamer runs Flashpoint's Linux
builds of the game server, Java, Wine and the Flash and Shockwave players.
Ultimate's archives only include the Windows builds. Run
`scripts/setup-flashpoint.sh --with-infinity`, which adds them without
Ultimate's archives only include the Windows builds. Download Flashpoint
Infinity for Linux from Flashpoint's downloads page and pass it to
`scripts/setup-flashpoint.sh --infinity`, which adds the software without
replacing anything.
- **HTTPS.** Browsers only allow the video decoder Selkies uses on secure
pages.
- **A reverse proxy route** for `/stream/`, including WebSocket upgrades (see
below).
- **A server with CPU to spare.** Every player streaming at once has a
container running the game and encoding its video on the CPU. Plan on roughly
1 to 2 CPU cores and 1 GB of RAM each, and a few Mbps of upload. Set
`MAX_SESSIONS` so the server isn't overloaded.
## Enabling it
@@ -63,18 +66,24 @@ sets up.
| --------------------- | ----------------------------- | ----------------------------------------------------------------- |
| `STREAMING_API_TOKEN` | - | Shared secret between the app and the session manager; required |
| `STREAMING_API_URL` | `http://session-manager:8080` | Session manager's internal API |
| `STREAM_PORT` | `8081` | Host port the session manager serves `/stream/` on |
| `STREAMING_STREAM_URL` | `http://session-manager:8081` | Where the app proxies `/stream/` to |
| `MAX_SESSIONS` | `4` | Most streams running at once |
| `STREAM_PLATFORMS` | `Java,Shockwave,Flash` | Platforms offered for streaming |
## Reverse proxy
Route `/stream/` to the session manager and everything else to the app. With
nginx:
The app proxies `/stream/` to the session manager itself, so no extra route is
needed. A reverse proxy in front of the app must still:
- pass WebSocket upgrades;
- allow long-lived connections, at least as long as a play session;
- not buffer responses, which would hold back the save-sync event stream.
With nginx:
```nginx
location /stream/ {
proxy_pass http://127.0.0.1:8081;
location / {
proxy_pass http://127.0.0.1:80;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
@@ -84,16 +93,16 @@ location /stream/ {
}
```
If another proxy sits in front of this one, it needs WebSocket support and
long timeouts as well. A stream that connects and then immediately fails with
HTTP 400 usually means a proxy in the chain doesn't pass the WebSocket upgrade.
If another proxy sits in front of this one, it needs the same. A stream that
connects and then immediately fails with HTTP 400 usually means a proxy in the
chain doesn't pass the WebSocket upgrade.
## Security
The session manager holds the Docker socket, which is effectively root on the
host. Expose only its `/stream/` port, and only through your proxy. Streamer
containers run on an internal network with no internet access, reachable only
from the session manager.
host. It publishes no ports; only the app reaches it, on the compose network.
Streamer containers run on an internal network with no internet access,
reachable only from the session manager.
## Troubleshooting
@@ -110,4 +119,4 @@ from the session manager.
`200` means the game can be streamed; `422` means it can't.
- **The stream stays black or disconnects at once.** Check that the page is on
HTTPS, and that `/stream/` passes WebSocket upgrades.
HTTPS, and that your proxy passes WebSocket upgrades.
@@ -1,328 +0,0 @@
# Games Filter Panel & Filter Logic - Code Review
**Date:** 2026-02-15
**Reviewer:** Claude
**Status:** Review Complete
## Overview
The filter system spans frontend components, hooks, API layer, and backend services. Overall, the implementation is solid with good patterns like config-driven rendering and proper caching. However, there are several issues and improvement opportunities.
## Files Reviewed
### Frontend Components
- `frontend/src/components/search/FilterPanel.tsx`
- `frontend/src/components/search/FilterDropdown.tsx`
- `frontend/src/components/search/FilterChips.tsx`
- `frontend/src/components/search/filterConfig.ts`
- `frontend/src/components/search/SearchBar.tsx`
- `frontend/src/components/search/SortControls.tsx`
- `frontend/src/components/library/GameBrowseLayout.tsx`
### Frontend Hooks & Types
- `frontend/src/hooks/useGames.ts`
- `frontend/src/hooks/useFilterOptions.ts`
- `frontend/src/hooks/useFilterDropdowns.ts`
- `frontend/src/types/game.ts`
- `frontend/src/lib/api/games.ts`
### Backend
- `backend/src/routes/games.ts`
- `backend/src/services/GameService.ts`
---
## Issues Found
### 1. Type Safety Issues
| Location | Issue | Severity |
|----------|-------|----------|
| `FilterPanel.tsx:22-23` | Unsafe `split()` on potentially undefined value | Medium |
| `GameBrowseLayout.tsx:72` | Type assertion `as 'asc' | 'desc'` without validation | Medium |
| `FilterDropdown.tsx:42` | No validation that `selectedValues` array items exist in `options` | Low |
```typescript
// FilterPanel.tsx:22-23 - currentValue could be number/undefined for yearFrom/yearTo
const current = typeof currentValue === 'string' ? currentValue.split(',').filter(Boolean) : [];
// This works but paramKey includes non-string fields (yearFrom, yearTo, page, limit)
```
### 2. Missing Year Range Filter in UI
| Location | Issue | Severity |
|----------|-------|----------|
| `filterConfig.ts` | `yearFrom`/`yearTo` filters defined in `GameFilters` but not exposed in UI | Medium |
| `FilterPanel.tsx` | No year range slider or inputs despite backend support | Medium |
The `FilterOptions.yearRange` is fetched but never used - users cannot filter by release year.
### 3. Inconsistent Filter Chips Implementation
| Location | Issue | Severity |
|----------|-------|----------|
| `FilterPanel.tsx:105-140` | `renderFilterBadges()` duplicates logic already in `FilterChips.tsx` | Low |
| `GameBrowseLayout.tsx:110-198` | Separate filter chip generation vs `FilterChips` component | Low |
Two different chip rendering systems exist:
- `FilterPanel.tsx` renders inline badges
- `GameBrowseLayout.tsx` creates `FilterChip[]` array for `FilterChips` component
### 4. Performance: Redundant Handlers Recreation
| Location | Issue | Severity |
|----------|-------|----------|
| `FilterPanel.tsx:68-74` | `createFilterHandlers()` called on every render for every filter | Low |
| `FilterPanel.tsx:113-119` | Same handlers recreated in `renderFilterBadges()` | Low |
```typescript
// Called inside map() on every render
const handlers = createFilterHandlers(
config.paramKey,
config.id,
filters,
updateFilter,
dropdowns
);
```
### 5. Missing Search Debouncing in SearchBar
| Location | Issue | Severity |
|----------|-------|----------|
| `SearchBar.tsx:10-15` | No debounce - navigates immediately on form submit | Low |
| N/A | No "search as you type" with debounce option | Enhancement |
### 6. Backend: Filter Options Limit Inconsistency
| Location | Issue | Severity |
|----------|-------|----------|
| `GameService.ts:706-707` | Series limited to 100 | Low |
| `GameService.ts:730-731` | Developers limited to 100 | Low |
| `GameService.ts:780-781` | Play modes NOT limited | Inconsistent |
| `GameService.ts:849` | Tags limited to 100 | Low |
Play modes query lacks `LIMIT 100` that other filter queries have.
### 7. Missing Input Validation on Frontend
| Location | Issue | Severity |
|----------|-------|----------|
| `GameBrowseLayout.tsx:72` | `page` parsed without `isNaN()` check | Medium |
| `GameBrowseLayout.tsx:58-68` | Year parsing validates `isNaN()` - good! | N/A |
```typescript
// Line 72 - could pass NaN to API
page: parseInt(searchParams.get('page') || '1', 10),
```
### 8. URL Encoding Issues
| Location | Issue | Severity |
|----------|-------|----------|
| `GameBrowseLayout.tsx:134` | `encodeURIComponent()` used for chip IDs but inconsistent | Low |
| `FilterPanel.tsx:125` | Badge key uses raw `value` - may have special chars | Low |
```typescript
// Line 125 - key should be encoded for consistency
<Badge key={value} variant="secondary" className="gap-1">
```
### 9. Missing Error Boundary for Filter Options
| Location | Issue | Severity |
|----------|-------|----------|
| `useFilterOptions.ts` | No `onError` handler | Low |
| `FilterPanel.tsx:79` | Falls back to empty array `|| []` but no loading state | Low |
If filter options fail to load, dropdowns show "No options available" with no retry mechanism.
### 10. Accessibility Issues
| Location | Issue | Severity |
|----------|-------|----------|
| `SortControls.tsx:26` | Native `<select>` used without styling wrapper | Low |
| `FilterDropdown.tsx:103` | `<label>` wraps checkbox - good | N/A |
| `SortControls.tsx:35-45` | Sort order button shows "A-Z/Z-A" but not meaningful for dates | Medium |
```typescript
// "A-Z" / "Z-A" makes sense for title, not for dateAdded
<span className="ml-1 text-xs">{sortOrder === 'asc' ? 'A-Z' : 'Z-A'}</span>
```
---
## Improvements Recommended
### High Priority
1. **Add Year Range Filter UI**
- Add a dual-handle range slider component
- Use `yearRange` from `FilterOptions`
- Connect to `yearFrom`/`yearTo` URL params
2. **Validate Page Parameter**
```typescript
// GameBrowseLayout.tsx:72
page: Math.max(1, parseInt(searchParams.get('page') || '1', 10) || 1),
```
3. **Fix Sort Order Label for Dates**
```typescript
const sortOrderLabel = useMemo(() => {
if (['releaseDate', 'dateAdded', 'dateModified'].includes(sortBy)) {
return sortOrder === 'asc' ? 'Oldest' : 'Newest';
}
return sortOrder === 'asc' ? 'A-Z' : 'Z-A';
}, [sortBy, sortOrder]);
```
### Medium Priority
4. **Consolidate Filter Chip Rendering**
- Remove inline badge rendering from `FilterPanel.tsx`
- Use `FilterChips` component consistently
- Pass chip generation logic via props or shared utility
5. **Add Filter Options Loading State**
```typescript
const { data: filterOptions, isLoading: filterOptionsLoading, error } = useFilterOptions();
// Show skeleton or disabled state while loading
```
6. **Memoize Filter Handlers**
```typescript
const handlers = useMemo(() =>
FILTER_CONFIGS.reduce((acc, config) => ({
...acc,
[config.id]: createFilterHandlers(config.paramKey, config.id, filters, updateFilter, dropdowns)
}), {}),
[filters, updateFilter, dropdowns]
);
```
7. **Add Search-as-You-Type with Debounce**
```typescript
// In a new SearchInput component
const [inputValue, setInputValue] = useState('');
const debouncedSearch = useDebounce(inputValue, 300);
useEffect(() => {
if (debouncedSearch) {
navigate(`/browse?search=${encodeURIComponent(debouncedSearch)}`);
}
}, [debouncedSearch]);
```
### Low Priority
8. **Consistent LIMIT on All Filter Queries**
```sql
-- GameService.ts:780-781
ORDER BY count DESC, playMode ASC
LIMIT 100 -- Add this
```
9. **Add Filter Presets**
- Save/load common filter combinations
- "Popular Flash Games", "Recent Additions", etc.
10. **Add Filter Count in Dropdown Trigger**
- Show total matching games for current filter combination
- Update counts dynamically based on other selected filters (cascading filters)
11. **Keyboard Navigation Improvements**
- Arrow keys to navigate dropdown options
- Enter to toggle selection
- Escape to close dropdown
---
## Code Quality Observations
### Positive Patterns
| Pattern | Location | Notes |
|---------|----------|-------|
| Config-driven filter rendering | `filterConfig.ts` | Eliminates 7 duplicate filter definitions |
| Proper `asyncHandler()` usage | `games.ts` routes | All async routes wrapped |
| Zod validation | `games.ts:27-49` | Comprehensive query validation |
| Window function for count | `GameService.ts:247` | Efficient single-query pagination |
| Cache pre-warming | `GameService.ts:158-166` | Filter options cached at startup |
| Proper URL param handling | `GameBrowseLayout.tsx` | `searchParams` synced correctly |
### Areas for Improvement
| Area | Current | Recommended |
|------|---------|-------------|
| Handler memoization | Created inline per render | Use `useMemo` or move outside component |
| Error handling | Silent fallback to `[]` | Show error state with retry |
| Year range UI | Missing | Add range slider component |
| Filter persistence | URL only | Consider localStorage for user defaults |
---
## Summary
| Category | Issues | Improvements |
|----------|--------|--------------|
| Type Safety | 3 | Validate all URL params |
| Missing Features | 2 | Year filter, presets |
| Performance | 2 | Memoize handlers |
| Consistency | 3 | Unify chip rendering |
| Accessibility | 2 | Sort labels, keyboard nav |
| Backend | 1 | Consistent LIMIT |
| **Total** | **13** | **10 recommendations** |
The filter system is functional and well-architected with the config-driven approach. The main gaps are the missing year range filter UI, sort order labels that don't match the sort type, and some performance optimizations around handler memoization.
---
## Action Items
- [x] Add year range filter slider to FilterPanel *(Fixed: 2026-02-15)*
- [x] Fix page parameter validation in GameBrowseLayout *(Fixed: 2026-02-15)*
- [x] Update SortControls to show context-aware labels (Oldest/Newest vs A-Z/Z-A) *(Fixed: 2026-02-15)*
- [x] Add LIMIT 100 to play modes query in GameService *(Fixed: 2026-02-15)*
- [x] Consolidate filter chip rendering to use FilterChips component only *(Fixed: 2026-02-15)*
- [x] Add loading/error states for filter options *(Fixed: 2026-02-15)*
- [x] Memoize filter handlers in FilterPanel *(Fixed: 2026-02-15)*
## Fixes Applied (2026-02-15)
### Files Modified
1. **`frontend/src/components/library/GameBrowseLayout.tsx`**
- Added `isNaN()` validation for page parameter with `Math.max(1, ...)` fallback
- Added year range chip generation and removal handling
2. **`frontend/src/components/search/SortControls.tsx`**
- Added context-aware sort order labels: "Oldest/Newest" for date fields, "A-Z/Z-A" for text fields
- Improved accessibility with `aria-label`
3. **`frontend/src/components/search/FilterPanel.tsx`**
- Memoized filter handlers using `useMemo` to avoid recreation on every render
- Memoized `updateFilter` with `useCallback`
- Memoized `visibleConfigs` array
- Removed duplicate badge rendering (now uses `FilterChips` only)
- Added loading state for filter options ("Loading..." message)
- Integrated `YearRangeFilter` component
4. **`frontend/src/components/search/YearRangeFilter.tsx`** (new file)
- Created year range filter component with from/to inputs
- Supports compact mode for mobile
- Proper validation of year values
5. **`backend/src/services/GameService.ts`**
- Added `LIMIT 100` to play modes query for consistency with other filter queries
## Additional Improvements (2026-02-15)
### Error State with Retry for Filter Options
**`frontend/src/components/search/FilterPanel.tsx`** - Added:
- Error state detection from `useFilterOptions` hook
- User-friendly error message with alert styling
- Retry button to refetch filter options
- Graceful fallback - shows filters if data exists even with stale error
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,798 +0,0 @@
# Persistent Ruffle Installation Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
> **Implementer assignments:** each task names its implementer agent in an
> `**Implementer:**` line. When executing with
> superpowers:subagent-driven-development, REQUIRED SUB-SKILL:
> dcc-superpower-companions:dispatching-tiered-implementers. Under
> superpowers:executing-plans these lines are inert; ignore them.
**Goal:** A Ruffle version installed through Settings → Update survives container recreation.
**Architecture:** Ruffle installs into `/app/data/ruffle`, which sits on the already-mounted data volume, instead of `/app/frontend/dist/ruffle`, which lives in the container's throwaway writable layer. A fresh volume is seeded from the copy baked into the image, so Flash works immediately and offline. Express serves the persisted directory at `/ruffle`, ahead of the dist handler and with cache headers that let an updated loader actually reach browsers.
**Tech Stack:** Express + TypeScript, vitest + supertest, Docker Compose.
**Spec:** `docs/superpowers/specs/2026-08-13-ruffle-persistent-install-design.md`
## Global Constraints
- Backend tests run from the `backend/` directory with `npx vitest run`. Do **not** use `npm test` from the repository root — it enters watch mode and hangs.
- Never introduce `any` types or non-null assertions (`!`). Use `??` for defaults, never `||`.
- Use `logger` from `../utils/logger`, never `console`.
- Run `npx prettier --write <files>` on every changed file before committing.
- The environment variable name is exactly `RUFFLE_DATA_PATH`. The production default is exactly `/app/data/ruffle`.
- `config.ruffleDataPath` is the install directory itself, not its parent. `path.join(config.ruffleDataPath, 'ruffle')` is always wrong.
- Ruffle's content-hashed files are detected by a hex run of **16 or more** characters in the basename (`core.ruffle.4ca82b563e9711217164.js`, `f2a570ccf4468b20d95a.wasm`). Everything else — `ruffle.js`, `ruffle.js.map`, `package.json` — is stable-named and must revalidate.
- Do not delete `frontend/public/ruffle/`. It is the seed source, and Vite serves it in development.
- Do not add a release-note entry to `docs/09-deployment/upgrade-guide.md`. That file gains a version heading only when a release is cut, and inventing a version number here would be wrong.
---
### Task 1: Persisted install path
**Files:**
- Modify: `backend/src/config.ts:118-119`
- Modify: `backend/src/services/RuffleService.ts:41-52`
- Test: `backend/src/services/RuffleService.test.ts:7,78-85,124-126,138-157`
**Interfaces:**
- Consumes: nothing.
- Produces: `config.ruffleDataPath: string` — the absolute directory Ruffle is installed into. `RuffleService` gains a private `installPath` (the install target) and a private `bundledPath` (the image's baked copy at `<frontendDistPath>/ruffle`), both read by Task 2.
**Implementer:** dcc-superpower-companions:impl-sonnet-max
**Evaluation:** files 1 - spec 0 - coupling 2 - risk 2 = 5
- [ ] **Step 1: Add the config value**
In `backend/src/config.ts`, find this block:
```typescript
// __dirname is backend/dist at runtime, so this resolves to the frontend build
// copied next to the backend in the image (/app/frontend/dist).
frontendDistPath:
process.env.FRONTEND_DIST_PATH ?? path.resolve(__dirname, '../../frontend/dist'),
```
Insert directly after it:
```typescript
// Ruffle installs here rather than under the served frontend build. That build
// is part of the image, so it lives in the container's writable layer and an
// in-app update was discarded whenever the container was recreated.
ruffleDataPath:
process.env.RUFFLE_DATA_PATH ??
(process.env.NODE_ENV === 'production'
? '/app/data/ruffle'
: path.resolve(__dirname, '../data/ruffle')),
```
- [ ] **Step 2: Update the test fixtures to a separate data directory**
In `backend/src/services/RuffleService.test.ts`, change the declaration on line 7:
```typescript
let distDir: string;
```
to:
```typescript
let distDir: string;
let dataDir: string;
```
Replace the config mock (lines 78-85):
```typescript
vi.mock('../config', () => ({
config: {
serveFrontend: true,
get frontendDistPath() {
return distDir;
},
},
}));
```
with:
```typescript
vi.mock('../config', () => ({
config: {
serveFrontend: true,
get frontendDistPath() {
return distDir;
},
get ruffleDataPath() {
return nodePath.join(dataDir, 'ruffle');
},
},
}));
```
Replace the `ruffleDir` helper (lines 124-126):
```typescript
function ruffleDir(): string {
return nodePath.join(distDir, 'ruffle');
}
```
with:
```typescript
function ruffleDir(): string {
return nodePath.join(dataDir, 'ruffle');
}
```
In `beforeEach`, replace the single `distDir` assignment:
```typescript
distDir = nodeFs.mkdtempSync(nodePath.join(os.tmpdir(), 'fp-ruffle-'));
```
with:
```typescript
distDir = nodeFs.mkdtempSync(nodePath.join(os.tmpdir(), 'fp-ruffle-dist-'));
dataDir = nodeFs.mkdtempSync(nodePath.join(os.tmpdir(), 'fp-ruffle-data-'));
```
In `afterEach`, replace:
```typescript
nodeFs.rmSync(distDir, { recursive: true, force: true });
```
with:
```typescript
nodeFs.rmSync(distDir, { recursive: true, force: true });
nodeFs.rmSync(dataDir, { recursive: true, force: true });
```
- [ ] **Step 3: Run the tests to verify they fail**
Run from `backend/`: `npx vitest run src/services/RuffleService.test.ts`
Expected: FAIL. The suite now asserts against `dataDir/ruffle` while the service still installs into `distDir/ruffle`, so assertions such as `expect(nodeFs.readFileSync(nodePath.join(ruffleDir(), 'ruffle.js'), 'utf-8')).toBe('new build')` throw ENOENT.
- [ ] **Step 4: Point the service at the persisted path**
In `backend/src/services/RuffleService.ts`, replace the field declaration and constructor (lines 41-52):
```typescript
private readonly frontendPublicPath: string;
private readonly githubApiUrl = 'https://api.github.com/repos/ruffle-rs/ruffle/releases';
constructor() {
// Install into the directory that is actually served. In a built deployment
// that is the frontend's dist output; in development Vite serves public/.
// Pointing at public/ in a container made every fresh start re-download
// Ruffle even though the image already ships it in dist/.
this.frontendPublicPath = config.serveFrontend
? path.join(config.frontendDistPath, 'ruffle')
: path.resolve(__dirname, '../../../frontend/public/ruffle');
}
```
with:
```typescript
private readonly installPath: string;
private readonly bundledPath: string;
private readonly githubApiUrl = 'https://api.github.com/repos/ruffle-rs/ruffle/releases';
constructor() {
// A built deployment installs onto the mounted data volume and serves it
// from there: the frontend build is part of the image, so anything written
// into it is discarded when the container is recreated. In development Vite
// serves public/, which is the only directory reachable there.
this.installPath = config.serveFrontend
? config.ruffleDataPath
: path.resolve(__dirname, '../../../frontend/public/ruffle');
this.bundledPath = path.join(config.frontendDistPath, 'ruffle');
}
```
Then rename the remaining nine references. Every other occurrence of `this.frontendPublicPath` in the file becomes `this.installPath` — lines 81, 447, 495, 520, 526, 527, 532, 546, and 576. A whole-word replace of `frontendPublicPath` with `installPath` across the file is exact; there are no other matches.
- [ ] **Step 5: Run the tests to verify they pass**
Run from `backend/`: `npx vitest run src/services/RuffleService.test.ts`
Expected: PASS, all existing cases green. They now exercise install, backup, rollback, and the rename/copy fallbacks against the persisted directory.
- [ ] **Step 6: Typecheck**
Run from the repository root: `npm run typecheck`
Expected: no errors.
- [ ] **Step 7: Commit**
```bash
npx prettier --write backend/src/config.ts backend/src/services/RuffleService.ts backend/src/services/RuffleService.test.ts
git add backend/src/config.ts backend/src/services/RuffleService.ts backend/src/services/RuffleService.test.ts
git commit -m "fix(ruffle): install onto the persisted data volume"
```
---
### Task 2: Seed a fresh volume from the bundled copy
**Files:**
- Modify: `backend/src/services/RuffleService.ts` (add two methods after `verifyInstallation`)
- Modify: `backend/src/server.ts:296-314`
- Test: `backend/src/services/RuffleService.test.ts` (append a new `describe` block)
**Interfaces:**
- Consumes: `this.installPath` and `this.bundledPath` from Task 1; the existing private `copyDirectory(source: string, target: string): void`.
- Produces: `RuffleService.ensureInstalled(): Promise<'present' | 'seeded' | 'downloaded'>`, called by `backend/src/server.ts`.
**Implementer:** dcc-superpower-companions:impl-sonnet-xhigh
**Evaluation:** files 1 - spec 0 - coupling 2 - risk 1 = 4
- [ ] **Step 1: Write the failing tests**
Append to `backend/src/services/RuffleService.test.ts`:
```typescript
function installBundledRuffle(): void {
const bundled = nodePath.join(distDir, 'ruffle');
nodeFs.mkdirSync(bundled, { recursive: true });
nodeFs.writeFileSync(nodePath.join(bundled, 'ruffle.js'), 'bundled build');
nodeFs.writeFileSync(
nodePath.join(bundled, 'package.json'),
JSON.stringify({ version: '0.2.0-nightly.2026.1.29' })
);
}
describe('RuffleService.ensureInstalled', () => {
it('leaves an existing installation alone', async () => {
installExistingRuffle();
installBundledRuffle();
const result = await new RuffleService().ensureInstalled();
expect(result).toBe('present');
expect(nodeFs.readFileSync(nodePath.join(ruffleDir(), 'ruffle.js'), 'utf-8')).toBe('old build');
expect(axios.get).not.toHaveBeenCalled();
});
it('seeds from the bundled copy when nothing is installed', async () => {
installBundledRuffle();
const service = new RuffleService();
const result = await service.ensureInstalled();
expect(result).toBe('seeded');
expect(nodeFs.readFileSync(nodePath.join(ruffleDir(), 'ruffle.js'), 'utf-8')).toBe(
'bundled build'
);
expect(service.getCurrentVersion()).toBe('0.2.0-nightly.2026.1.29');
expect(axios.get).not.toHaveBeenCalled();
});
it('downloads when neither an installation nor a bundled copy exists', async () => {
const result = await new RuffleService().ensureInstalled();
expect(result).toBe('downloaded');
expect(nodeFs.readFileSync(nodePath.join(ruffleDir(), 'ruffle.js'), 'utf-8')).toBe('new build');
expect(axios.get).toHaveBeenCalled();
});
});
```
- [ ] **Step 2: Run the tests to verify they fail**
Run from `backend/`: `npx vitest run src/services/RuffleService.test.ts -t ensureInstalled`
Expected: FAIL with `ensureInstalled is not a function`.
- [ ] **Step 3: Implement the methods**
In `backend/src/services/RuffleService.ts`, add both methods directly after `verifyInstallation()`, inside the class:
```typescript
/**
* Copy the Ruffle shipped inside the image into the install directory. A fresh
* data volume otherwise has no player at all until a download finishes — and
* never gets one on a host that cannot reach GitHub.
*/
private seedFromBundle(): boolean {
if (this.bundledPath === this.installPath) {
return false;
}
if (!fs.existsSync(path.join(this.bundledPath, 'ruffle.js'))) {
return false;
}
try {
this.copyDirectory(this.bundledPath, this.installPath);
logger.info(
`✅ Ruffle seeded from the bundled copy (version: ${this.getCurrentVersion() ?? 'unknown'})`
);
return true;
} catch (error: unknown) {
logger.warn('[RuffleService] Could not seed Ruffle from the bundled copy:', error);
return false;
}
}
/**
* Make a Ruffle available, preferring what is already installed, then the copy
* baked into the image, and only then a download.
*/
async ensureInstalled(): Promise<'present' | 'seeded' | 'downloaded'> {
if (this.verifyInstallation()) {
logger.info(`✅ Ruffle verified (version: ${this.getCurrentVersion() ?? 'unknown'})`);
return 'present';
}
if (this.seedFromBundle()) {
return 'seeded';
}
logger.info('🎮 Ruffle not found, downloading latest version...');
await this.updateRuffle();
logger.info('✅ Ruffle installation complete');
return 'downloaded';
}
```
- [ ] **Step 4: Run the tests to verify they pass**
Run from `backend/`: `npx vitest run src/services/RuffleService.test.ts`
Expected: PASS, including the three new cases and every pre-existing one.
- [ ] **Step 5: Call it from startup**
In `backend/src/server.ts`, replace lines 296-314:
```typescript
// Ruffle installs in the background: it downloads from GitHub, and awaiting it
// here kept the port closed for minutes on slow storage — indefinitely if the
// download stalled. Flash games need it, everything else does not.
void (async () => {
try {
const ruffleService = new RuffleService();
if (!ruffleService.verifyInstallation()) {
logger.info('🎮 Ruffle not found, downloading latest version...');
await ruffleService.updateRuffle();
logger.info('✅ Ruffle installation complete');
} else {
const version = ruffleService.getCurrentVersion();
logger.info(`✅ Ruffle verified (version: ${version || 'unknown'})`);
}
} catch (error) {
logger.error('Failed to install Ruffle:', error);
logger.warn('⚠️ Continuing without Ruffle - Flash games will not work');
}
})();
```
with:
```typescript
// Ruffle resolves in the background: seeding copies ~20 MB and the download
// fallback reaches GitHub, and awaiting either here kept the port closed for
// minutes on slow storage. Flash games need it, everything else does not.
void (async () => {
try {
await new RuffleService().ensureInstalled();
} catch (error) {
logger.error('Failed to install Ruffle:', error);
logger.warn('⚠️ Continuing without Ruffle - Flash games will not work');
}
})();
```
- [ ] **Step 6: Typecheck and run the full backend suite**
Run from the repository root: `npm run typecheck`
Then run from `backend/`: `npx vitest run`
Expected: no type errors, no new test failures.
- [ ] **Step 7: Commit**
```bash
npx prettier --write backend/src/services/RuffleService.ts backend/src/services/RuffleService.test.ts backend/src/server.ts
git add backend/src/services/RuffleService.ts backend/src/services/RuffleService.test.ts backend/src/server.ts
git commit -m "feat(ruffle): seed a fresh data volume from the bundled copy"
```
---
### Task 3: Serve the persisted directory with correct cache headers
**Files:**
- Modify: `backend/src/middleware/frontendServing.ts:20-55`
- Modify: `backend/src/middleware/startupOrdering.test.ts:12-17`
- Create: `backend/src/middleware/ruffleServing.test.ts`
**Interfaces:**
- Consumes: `config.ruffleDataPath` from Task 1.
- Produces: nothing consumed by later tasks. `registerFrontendServing(app, distPath?)` keeps its existing signature; the Ruffle mount reads `config.ruffleDataPath` directly.
**Implementer:** dcc-superpower-companions:impl-sonnet-max
**Evaluation:** files 1 - spec 0 - coupling 2 - risk 2 = 5
- [ ] **Step 1: Write the failing test**
Create `backend/src/middleware/ruffleServing.test.ts`:
```typescript
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import express, { Express } from 'express';
import request from 'supertest';
import fs from 'fs';
import os from 'os';
import path from 'path';
vi.mock('../utils/logger', () => ({
logger: { info: vi.fn(), error: vi.fn(), debug: vi.fn(), warn: vi.fn() },
}));
let ruffleDir: string;
vi.mock('../config', () => ({
config: {
serveFrontend: true,
get ruffleDataPath() {
return ruffleDir;
},
},
}));
import { registerFrontendServing } from './frontendServing';
const HASHED_CHUNK = 'core.ruffle.4ca82b563e9711217164.js';
let distDir: string;
let app: Express;
beforeEach(() => {
distDir = fs.mkdtempSync(path.join(os.tmpdir(), 'fp-serve-dist-'));
ruffleDir = fs.mkdtempSync(path.join(os.tmpdir(), 'fp-serve-ruffle-'));
fs.writeFileSync(path.join(distDir, 'index.html'), '<!doctype html><div id="root"></div>');
fs.mkdirSync(path.join(distDir, 'ruffle'));
fs.writeFileSync(path.join(distDir, 'ruffle', 'ruffle.js'), 'bundled loader');
fs.writeFileSync(path.join(distDir, 'ruffle', 'only-in-bundle.js'), 'bundled extra');
fs.writeFileSync(path.join(ruffleDir, 'ruffle.js'), 'updated loader');
fs.writeFileSync(path.join(ruffleDir, HASHED_CHUNK), 'updated chunk');
app = express();
registerFrontendServing(app, distDir);
});
afterEach(() => {
fs.rmSync(distDir, { recursive: true, force: true });
fs.rmSync(ruffleDir, { recursive: true, force: true });
});
describe('Ruffle serving', () => {
it('serves the persisted copy in preference to the bundled one', async () => {
const response = await request(app).get('/ruffle/ruffle.js');
expect(response.status).toBe(200);
expect(response.text).toBe('updated loader');
});
it('falls back to the bundled copy for a file the persisted copy lacks', async () => {
const response = await request(app).get('/ruffle/only-in-bundle.js');
expect(response.status).toBe(200);
expect(response.text).toBe('bundled extra');
});
it('makes the stable-named loader revalidate', async () => {
const response = await request(app).get('/ruffle/ruffle.js');
expect(response.headers['cache-control']).toBe('no-cache, no-store, must-revalidate');
});
it('makes a content-hashed chunk immutable', async () => {
const response = await request(app).get(`/ruffle/${HASHED_CHUNK}`);
expect(response.headers['cache-control']).toBe('public, max-age=31536000, immutable');
});
it('makes the bundled fallback loader revalidate too', async () => {
const response = await request(app).get('/ruffle/only-in-bundle.js');
expect(response.headers['cache-control']).toBe('no-cache, no-store, must-revalidate');
});
});
```
- [ ] **Step 2: Run the test to verify it fails**
Run from `backend/`: `npx vitest run src/middleware/ruffleServing.test.ts`
Expected: FAIL. `/ruffle/ruffle.js` returns `bundled loader` because no persisted mount exists, and its `Cache-Control` is `public, max-age=31536000, immutable`.
- [ ] **Step 3: Add the mount and the cache policy**
In `backend/src/middleware/frontendServing.ts`, add this import alongside the existing ones:
```typescript
import type { Response } from 'express';
```
Add these two declarations directly above `export function registerFrontendServing`:
```typescript
/**
* Ruffle content-hashes its chunks (`core.ruffle.4ca82b563e9711217164.js`,
* `f2a570ccf4468b20d95a.wasm`) but not its entry points. Detecting the hash
* rather than listing the stable names keeps this correct when a release adds a
* file.
*/
const HASHED_RUFFLE_ASSET = /[0-9a-f]{16,}/i;
/**
* `ruffle.js` keeps its name across versions, so serving it immutable pinned
* every returning browser to the loader it first saw — an update reached the
* disk and nothing else.
*/
function setRuffleCacheControl(res: Response, filePath: string): void {
res.setHeader(
'Cache-Control',
HASHED_RUFFLE_ASSET.test(path.basename(filePath))
? 'public, max-age=31536000, immutable'
: 'no-cache, no-store, must-revalidate'
);
}
```
Inside `registerFrontendServing`, insert this immediately after the `index.html` existence guard and before the existing `app.use(express.static(distPath, ...))` call:
```typescript
// Ahead of the dist handler so an updated Ruffle shadows the one baked into
// the image. `fallthrough` lets the baked copy answer for anything the
// persisted directory is missing, including before the first seed finishes.
app.use(
'/ruffle',
express.static(config.ruffleDataPath, {
index: false,
fallthrough: true,
setHeaders: setRuffleCacheControl,
})
);
```
Then replace the `setHeaders` option on the existing dist handler:
```typescript
setHeaders: (res, filePath) => {
// Hashed assets are immutable; index.html must always be revalidated.
if (filePath.endsWith('index.html')) {
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
}
},
```
with:
```typescript
setHeaders: (res, filePath) => {
// Hashed assets are immutable; index.html must always be revalidated.
if (filePath.endsWith('index.html')) {
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
return;
}
if (filePath.startsWith(bundledRufflePath + path.sep)) {
setRuffleCacheControl(res, filePath);
}
},
```
and declare `bundledRufflePath` at the top of the function body, directly below the existing `const indexHtml = path.join(distPath, 'index.html');`:
```typescript
const bundledRufflePath = path.join(distPath, 'ruffle');
```
- [ ] **Step 4: Run the test to verify it passes**
Run from `backend/`: `npx vitest run src/middleware/ruffleServing.test.ts`
Expected: PASS, all five cases.
- [ ] **Step 5: Repair the sibling middleware test's config mock**
`backend/src/middleware/startupOrdering.test.ts` mocks `../config` without `ruffleDataPath`, and `express.static(undefined)` throws `TypeError: root path required`. Replace its mock (lines 12-17):
```typescript
vi.mock('../config', () => ({
config: {
serveFrontend: true,
frontendDistPath: '/unused-in-tests',
},
}));
```
with:
```typescript
vi.mock('../config', () => ({
config: {
serveFrontend: true,
frontendDistPath: '/unused-in-tests',
ruffleDataPath: '/unused-in-tests/ruffle',
},
}));
```
- [ ] **Step 6: Run the full backend suite and typecheck**
Run from `backend/`: `npx vitest run`
Then run from the repository root: `npm run typecheck`
Expected: no failures and no type errors. `startupOrdering.test.ts` in particular must stay green — a missing-directory static mount with `fallthrough: true` calls `next()` instead of throwing.
- [ ] **Step 7: Commit**
```bash
npx prettier --write backend/src/middleware/frontendServing.ts backend/src/middleware/ruffleServing.test.ts backend/src/middleware/startupOrdering.test.ts
git add backend/src/middleware/frontendServing.ts backend/src/middleware/ruffleServing.test.ts backend/src/middleware/startupOrdering.test.ts
git commit -m "fix(ruffle): serve the persisted install ahead of the bundled one"
```
---
### Task 4: Deployment configuration and documentation
**Files:**
- Modify: `docker-compose.dev.yml:28-40`
- Modify: `docker-compose.yml:29-33`
- Modify: `docs/09-deployment/environment-variables.md:113-116`
**Interfaces:**
- Consumes: the `RUFFLE_DATA_PATH` variable name and the `/app/data/ruffle` default from Task 1.
- Produces: nothing.
**Implementer:** dcc-superpower-companions:impl-sonnet-medium
**Evaluation:** files 1 - spec 0 - coupling 1 - risk 0 = 2
- [ ] **Step 1: Set the variable in the dev compose file**
`docker-compose.dev.yml` runs `NODE_ENV=development` with `SERVE_FRONTEND=true`, so the production-keyed default resolves to a relative path inside the container and misses the `backend-db:/app/data` volume. In `docker-compose.dev.yml`, find:
```yaml
# Matches the read-only mount above. Set explicitly because the backend's
# fallback only points here when NODE_ENV=production.
- FLASHPOINT_PATH=/data/flashpoint
```
Insert directly after it:
```yaml
# Same reason as FLASHPOINT_PATH above: this container runs
# NODE_ENV=development, so the production default would put Ruffle outside
# the data volume and lose every update on recreate.
- RUFFLE_DATA_PATH=/app/data/ruffle
```
- [ ] **Step 2: Update both volume comments**
In `docker-compose.yml`, find:
```yaml
- ${FLASHPOINT_HOST_PATH:?FLASHPOINT_HOST_PATH is required}:/data/flashpoint
- ${DATA_PATH:-./data}:/app/data
```
Replace with:
```yaml
- ${FLASHPOINT_HOST_PATH:?FLASHPOINT_HOST_PATH is required}:/data/flashpoint
# Holds user.db and the installed Ruffle. Without this mount an in-app
# Ruffle update is lost the next time the container is recreated.
- ${DATA_PATH:-./data}:/app/data
```
In `docker-compose.dev.yml`, find:
```yaml
- ${FLASHPOINT_HOST_PATH:?FLASHPOINT_HOST_PATH is required}:/data/flashpoint
- backend-db:/app/data
```
Replace with:
```yaml
- ${FLASHPOINT_HOST_PATH:?FLASHPOINT_HOST_PATH is required}:/data/flashpoint
# Holds user.db and the installed Ruffle. Without this mount an in-app
# Ruffle update is lost the next time the container is recreated.
- backend-db:/app/data
```
- [ ] **Step 3: Document the variable**
In `docs/09-deployment/environment-variables.md`, find this table:
```markdown
| Variable | Default | Description |
| -------------------- | ----------------------------- | -------------------------------------------------------------- |
| `SERVE_FRONTEND` | true in production, else false | Serve the built frontend and SPA fallback from the backend |
| `FRONTEND_DIST_PATH` | `<backend>/../frontend/dist` | Location of the built frontend (rarely needed) |
```
Add one row to it:
```markdown
| `RUFFLE_DATA_PATH` | `/app/data/ruffle` in production | Where Ruffle is installed. Must sit on a mounted volume, or an in-app update is lost when the container is recreated |
```
- [ ] **Step 4: Verify both compose files still parse**
Run from the repository root:
```bash
docker compose -f docker-compose.yml config --quiet
docker compose -f docker-compose.dev.yml config --quiet
```
Expected: both exit 0 and print nothing. Each file requires `FLASHPOINT_HOST_PATH` and `JWT_SECRET`; if the repository has no `.env`, supply throwaway values inline:
```bash
FLASHPOINT_HOST_PATH=/tmp/fp JWT_SECRET=x docker compose -f docker-compose.yml config --quiet
FLASHPOINT_HOST_PATH=/tmp/fp JWT_SECRET=x docker compose -f docker-compose.dev.yml config --quiet
```
If the Docker CLI is unavailable in this environment, say so rather than skipping silently, and parse both files directly instead:
```bash
node -e "const y=require('yaml');const fs=require('fs');for(const f of ['docker-compose.yml','docker-compose.dev.yml']){y.parse(fs.readFileSync(f,'utf8'));console.log(f,'ok')}"
```
The `yaml` package is already present in the repository's `node_modules` as a transitive dependency. This proves the YAML parses; it does not check Compose semantics, so report which of the two checks you ran.
- [ ] **Step 5: Commit**
```bash
npx prettier --write docker-compose.yml docker-compose.dev.yml docs/09-deployment/environment-variables.md
git add docker-compose.yml docker-compose.dev.yml docs/09-deployment/environment-variables.md
git commit -m "docs(ruffle): document the persisted install path"
```
---
## Verification
After Task 4, confirm the whole change from the repository root:
```bash
npm run typecheck
npm run build
```
Then from `backend/`: `npx vitest run`
Expected: typecheck clean, build succeeds, backend suite green.
Manual confirmation in a container, which is what the reported bug needs:
1. `docker compose -f docker-compose.dev.yml up -d --build`
2. Settings → Update → update Ruffle, and note the version.
3. `docker compose -f docker-compose.dev.yml up -d --force-recreate`
4. Settings → Update still reports the updated version, and a hard-refreshed
browser loads it.
@@ -1,184 +0,0 @@
# Downloaded games: page, filter, and settings
Date: 2026-08-11
## Goal
1. A "Downloaded" sidebar entry and page listing only downloaded games.
2. A downloaded filter in the filter panel on the existing browse pages.
3. An admin setting to show or hide the Downloaded page.
4. An admin setting that lets guests see the Downloaded page, offered only when
the page itself is enabled.
5. An admin setting that turns the downloaded filter on by default.
## Definition of "downloaded"
A game is downloaded when it has at least one `game_data` row with
`presentOnDisk = 1`.
Games with no `game_data` rows at all — legacy titles served from htdocs — can
never be downloaded under this definition and never appear on the page. This
matches the existing per-game badge, which reads the same flag through the
`MAX(presentOnDisk)` post-processing in `GameService.searchGames`.
## Keeping the flag truthful
`presentOnDisk` is currently written only by the Flashpoint Launcher and by the
explicit Download button (`DownloadManager` → `GameDatabaseUpdater.markAsDownloaded`).
Three gaps must close before the flag can drive a whole page.
**Play-path downloads never set it.** `GameZipServer.downloadAndMountInBackground`
writes the ZIP and returns without touching the database. On success it will call
`GameDatabaseUpdater.markAsDownloaded`. The `game_data.id` is already loaded by
`GameDataService.getGameDataEntry`; thread it through the `mountZip` params rather
than re-querying by `gameId` + `dateAdded`. Mark the row even when `sha256` is
absent, since `GameDataDownloader` only verifies a checksum when one exists and the
file is otherwise a complete, mountable download.
**Local-copy mode erases the flag.** With `ENABLE_LOCAL_DB_COPY=true`, queries and
our writes hit `config.localDbPath`, and `DatabaseService.syncAndReload` copies the
source over that file whenever the Launcher's database changes. A new
`DownloadedReconciler` service restores truth from disk:
- Scan `config.flashpointGamesPath` for files matching `{uuid}-{timestamp}.zip`,
the deterministic name produced by `GameDataDownloader.getFilename`.
- Resolve each to its `game_data` row by `gameId` plus the timestamp derived from
`dateAdded`, and bulk-set `presentOnDisk = 1` in a single transaction, skipping
rows already flagged.
- Update `game.activeDataOnDisk` where the marked row is the game's active data.
- Run once at startup after `DatabaseService` initialization, and again after every
`syncAndReload`. Both runs are fire-and-forget with a `.catch()`; an unreadable
games directory logs a warning and yields zero marks.
The reconciler only ever marks. It never clears the flag for a missing ZIP, because
the Launcher can store game data outside `flashpointGamesPath` and we would be
overwriting its state with a false negative.
This scan also backfills every game downloaded through the Play path before this
feature ships, which is otherwise the largest population of invisible downloads.
**Our own writes trigger a full reload.** In non-copy mode the write lands in the
file `DatabaseService` watches, so the watcher fires, reopens the connection, and
clears the search, flash-SWF, and filter-options caches — per download. Add
`DatabaseService.noteSelfWrite()`, which re-stats the source and advances
`lastModifiedTime` so `syncAndReload` short-circuits, and call it from
`markAsDownloaded` and the reconciler. The caller then clears only the search and
filter-options caches, so a freshly downloaded game appears immediately.
A Launcher write landing in the same millisecond as ours could be swallowed by this
check. The consequence is a delayed reload, not data loss, and the next Launcher
write recovers it.
## Backend filter
`downloaded?: boolean` joins the POST `/api/games` body schema, `GameSearchQuery`,
and the filter-options query schema. `true` restricts to downloaded games;
`false` and `undefined` both mean "no restriction", and the frontend omits the
field when the filter is off.
In `GameService.searchGames` the condition is:
```sql
AND EXISTS (SELECT 1 FROM game_data gd WHERE gd.gameId = g.id AND gd.presentOnDisk = 1)
```
`buildFilterOptionsConditions` gets the same condition so dropdown values and the
year range narrow to downloaded games; qualify the outer column with its table name
there, since those queries have no alias and `game_data` has its own `id` column.
Both `GameSearchCache.generateCacheKey` and `GameService.getFilterOptionsCacheKey`
include `downloaded`, normalized with `??` so `undefined` and `false` stay distinct.
Add `'downloaded'` to the `filters` array in the search route's `logActivity`
callback so the recorded filter count stays accurate.
## Frontend filter
`GameFilters.downloaded?: boolean` carries the value to the API.
`FilterUrlParams.downloaded?: '0' | '1'` carries it in the URL under the unused
single-character key `w`, guarded with `!== undefined` rather than truthiness so an
explicit off survives `toUrlSafeFormat`.
The value is tri-state: `w.1` on, `w.0` off, absent means "use the admin default".
`GameBrowseLayout` resolves it once and passes a plain boolean down.
Because absent means "default", removal cannot clear the parameter when the default
is on — the filter would immediately reapply. When the admin default is on,
`handleRemoveChip` and `handleClearAllFilters` write `w.0` instead of dropping the
key; when it is off, they clear it as every other filter does. `CATEGORY_TO_ABBR`
gains `Downloaded: 'W'` and `categoryToParamKeys` gains the matching entry so the
chip participates in the existing filter hierarchy.
A bookmarked URL with no `w` changes meaning if an admin later flips the default.
That is inherent to the tri-state and accepted.
`FilterPanel` renders a "Downloaded only" switch beside the year range in both the
desktop and mobile rows, and an active filter chip. A new `showDownloadedFilter`
prop hides the switch on the Downloaded page, where the filter is the page.
## Downloaded page
Route `/downloaded` renders `DownloadedView`, a thin wrapper over
`GameBrowseLayout` with the filter pinned on and no library restriction, so results
span arcade and theatre. `GameBrowseLayout`'s `library` prop becomes optional and it
gains `forceDownloaded`.
The page passes `sectionKey={null}`, so game links resolve to the existing
`/games/:id` detail route. No new per-section detail or play routes.
## Settings
Migration `004_downloaded_settings.sql` seeds three public boolean settings in the
`features` category, following the existing `enable_*` naming:
| Key | Default | Effect |
| --- | --- | --- |
| `features.enable_downloaded_page` | 1 | Sidebar entry and `/downloaded` route |
| `features.enable_downloaded_page_for_guests` | 0 | Guests may see the page |
| `features.enable_downloaded_filter_default` | 0 | Browse pages start filtered |
`useFeatureFlags` exposes `enableDownloadedPage` with the usual admin bypass, so an
admin cannot lock themselves out. The other two are read as `=== true`: they default
off, and the hook's `?? true` fallback would otherwise invert them during the
public-settings loading window and whenever the key is missing.
`ProtectedRoute` gains `'enableDownloadedPage'` in its `requireFeature` union and a
new `requireFeatureForGuests` prop, checked only when the viewer is a guest, so a
guest deep-linking to `/downloaded` with the guest flag off is redirected to
`/unauthorized`. A flag check alone would let them through, since guests satisfy
`requireAuth`.
These flags are presentation, not access control: `POST /api/games` is open to
guests and accepts `downloaded` regardless, and the page shows no data a guest
cannot already reach by filtering Browse. No backend enforcement is added.
`FeaturesSettingsTab` gains the three switches, with the guest row rendered only
while the page flag is on. Its stored value persists while hidden and resurfaces if
the page is re-enabled.
## Sidebar
The Downloaded entry sits in the Library section with Favorites. That block is
currently hidden wholesale for guests, so its items never check guest status
themselves. Relaxing the block requires giving each item its own predicate:
Favorites and both playlist entries become explicitly `!isGuest`, Downloaded
becomes `enableDownloadedPage && (!isGuest || enableDownloadedPageForGuests)`, and
the section renders when any item survives. The existing
`isViewingSharedPlaylistWithoutGuestAccess` case is unaffected.
## Testing
- `GameService.searchGames` returns only flagged games with `downloaded: true`, and
is unaffected when the field is absent.
- Filter options narrow when `downloaded` is set, and cache keys distinguish
`undefined`, `false`, and `true`.
- `DownloadedReconciler` maps ZIP filenames to the right `game_data` rows, ignores
unrelated files, and marks nothing when the directory is unreadable.
- `filterUrlCompression` round-trips `w.0` and `w.1`, and an explicit off survives
`buildFilterSearchParams`.
- Chip removal writes an explicit off when the admin default is on.
## Documentation
Update `docs/06-api-reference/games-api.md` (search and filter-options parameters),
`docs/06-api-reference/settings-api.md` (three new settings), and
`docs/10-features/02-game-browsing-filtering.md` (the filter and the page).
@@ -1,234 +0,0 @@
# Web app update check on the Update page
Date: 2026-08-12
## Goal
An admin opening Settings → Update sees whether a newer release of Flashpoint
Web exists, what changed in it, and how to upgrade.
The card notifies only. It never pulls an image, restarts a container, or writes
anything: self-updating from inside the container needs the Docker socket
mounted, which hands the container root-equivalent control of the host.
## What counts as an update
The running version comes from `APP_VERSION`, an environment variable baked into
the image from the `VERSION` build arg that CI already passes (see
`.github/workflows/docker-build-push.yml`). This mirrors `VITE_APP_VERSION`,
which the frontend build consumes for the same value.
A version counts as a release build only when it is three clean numbers, with an
optional leading `v` — `1.0.38` or `v1.0.38`. Anything else is an **unreleased
build**: the variable unset, `dev` from a local `docker build`, `latest` from a
`workflow_dispatch` run, a release candidate like `1.1.0-rc1`, or a
`git describe` string like `1.0.38-5-gabc123`. An unreleased build never claims
to be behind, because its commits may already include everything in the latest
release.
The comparison target is GitHub's
`/repos/darkraise/flashpoint-web/releases/latest`, which excludes prereleases by
definition. No prerelease filtering is needed, and release candidates never
surface.
Verified against the live endpoint on 2026-08-12: it answers 200 with
`tag_name: "v1.0.38"`, `prerelease: false`, `published_at`, `html_url`, and a
markdown `body`. The tag carries a leading `v` that `APP_VERSION` does not, so
both sides are stripped before comparing — the tag always has one, and a manual
`docker build --build-arg VERSION=v1.0.38` would otherwise be misread as
unreleased.
## The two version sources must not contradict each other
The frontend already resolves its own version at build time
(`frontend/vite.config.ts`, `frontend/src/lib/version.ts`) and shows it on the
General tab as "Web App Version". This design adds a second, server-side source,
and the two sit one tab apart.
In Docker they agree: both build stages receive the same `VERSION` build arg. In
a source checkout they can differ — the frontend falls back to `git describe`,
while the backend has no `APP_VERSION` at all — so the General tab could read
`1.0.38` while the card reads "unreleased build". The card's copy therefore
attributes the state to the server rather than to the operator: "The server did
not report a released version, so update checks are off."
`APP_VERSION` is documented in `docs/09-deployment/environment-variables.md` as
an optional operator-settable variable, so a non-Docker deployment can opt into
update checks.
## Backend
### `compareVersions(a, b)` — `backend/src/utils/version.ts`
Returns a negative number, zero, or a positive number by comparing major, minor,
and patch numerically, after stripping an optional leading `v` from each side.
Returns `null` when either input is not exactly three numeric parts, which is
what routes an unreleased build away from the comparison. Numeric comparison
matters: string ordering puts `1.0.9` above `1.0.38`.
A `null` result from a malformed *latest* tag — a hand-cut release tagged `v2.0`
— yields `updateAvailable: false`. Reporting nothing is preferable to reporting
a wrong direction.
### `AppUpdateService` — `backend/src/services/AppUpdateService.ts`
One public method, `getUpdateInfo(force = false)`, returning:
```ts
interface AppUpdateInfo {
currentVersion: string | null; // null when APP_VERSION is unset
latestVersion: string | null; // null when GitHub has never been reached
updateAvailable: boolean;
isUnreleasedBuild: boolean;
publishedAt: string | null;
releaseUrl: string | null;
changelog: string | null;
checkedAt: string | null; // when the held result was fetched
lastCheckFailed: boolean;
}
```
The result is cached in memory for one hour and shared by every admin. The cache
holds the last *successful* result; a failed check returns that result with
`lastCheckFailed: true` rather than discarding it.
**Concurrency.** The in-flight fetch promise is stored and returned to
concurrent callers, so a cold cache hit by several requests at once produces one
GitHub call, not several.
**Forced refresh.** `force` bypasses the one-hour cache but not a 60-second
floor: a forced check within 60 seconds of the last fetch returns the held
result. `rateLimitStandard` is 100 requests per 60-second window
(`backend/src/config.ts`, `RATE_LIMIT_MAX_REQUESTS`), which is no protection for
a 60-per-hour unauthenticated GitHub quota that this feature shares with the
star-count proxy and the Ruffle update check. The floor is what keeps the quota
intact.
`isUnreleasedBuild: true` still fetches and reports `latestVersion` and the
changelog for reference, but forces `updateAvailable: false`.
### Failure handling
The route always answers 200. A total failure — no cached result and GitHub
unreachable — returns `latestVersion: null` with `lastCheckFailed: true`, and
the card renders its failed state.
It does **not** throw `AppError(502)`, because the axios interceptor in
`frontend/src/lib/api/client.ts` fires a global "Server error occurred" toast for
any status at or above 500. A card that renders its own inline error would
double up with that toast, which is what the Ruffle card does today.
`backend/src/routes/github.ts` is the precedent for serving a stale result, but
it is a weaker version of this design and should not be copied line for line: it
throws a plain `Error`, has no request timeout, and its stale path covers only a
non-OK response, so a network failure or hang bypasses the cache entirely. Here
the `try` wraps the whole fetch, and the request carries an explicit timeout.
### `GET /api/updates/app`
Added to `backend/src/routes/updates.ts` behind `authenticate` and
`requirePermission('settings.update')` from `../middleware/rbac`. That matches
the tab's own gate in `UpdateSettingsTab.tsx`; note that two GETs in that file
use `settings.read`, so "the same as its neighbours" is not exact.
The handler returns the bare `AppUpdateInfo` object — `res.json(info)` — as
every endpoint in `updates.ts` does and as `updatesApi` expects. The
`{success, data}` envelope in `github.ts` is that router's convention, not this
one.
`?refresh=true` sets `force`, read as `req.query.refresh === 'true'`. Any other
value, array, or object is falsy, so no schema or bounds check is warranted; the
60-second floor is the real control. `rateLimitStandard` already applies to the
whole router.
### `config.appVersion`
`process.env.APP_VERSION?.trim() || null`, added to `backend/src/config.ts`
beside the Flashpoint version fields. `||` is deliberate here and must not be
"corrected" to `??`: an empty or whitespace-only variable has to collapse to
`null`, which `??` would not do.
### Dockerfile
The final stage already declares `ARG VERSION=dev` for the image label. It gains
`ENV APP_VERSION=$VERSION`, since an `ARG` alone does not survive into the
running container.
## Frontend
`AppUpdateCard` — `frontend/src/components/settings/AppUpdateCard.tsx` — renders
first in `UpdateSettingsTab`, above the metadata and Ruffle cards, admin-only
like both of them.
A `useQuery` on mount performs the check. The Check button cannot be a
`refetch()`, which reuses the original query function and so cannot add
`refresh=true`; it is a `useMutation` calling the endpoint with the flag, writing
its result back through `queryClient.setQueryData`. This is the shape the Ruffle
card already uses.
The card reuses the vocabulary of `RuffleManagementCard`: a collapsible
changelog rendered with `react-markdown` and `remark-gfm`, `FormattedDate` for
the release date, the same button placement and toast behaviour. No sanitizer is
added — without `rehype-raw`, react-markdown escapes raw HTML, and the source is
the project's own CI-generated release notes.
Five states:
| State | Condition | Shows |
| ---------------- | ---------------------------------------- | -------------------------------------------------------------------------------------------------------- |
| Update available | `updateAvailable` | Both versions, release date, changelog expanded, link to the release, upgrade commands |
| Up to date | versions match | Current version and when it was checked |
| Unreleased build | `isUnreleasedBuild` | "The server did not report a released version, so update checks are off", plus the latest release for reference |
| Stale | `lastCheckFailed` with a `latestVersion` | The held result, labelled as a check that failed at `checkedAt` |
| Check failed | `lastCheckFailed`, no `latestVersion` | A muted error and a retry button |
The upgrade command is `docker compose pull && docker compose up -d`, matching
the deployment `docker-compose.yml` documents. That file pins
`image: darkraise/flashpoint-web:${IMAGE_TAG:-latest}`, so the card adds one
line: an operator who set `IMAGE_TAG` to a fixed version must raise it first, or
the pull is a no-op.
The release body generated by CI opens with its own "## Docker Image" section
carrying a `docker pull` line, so the changelog repeats part of this. The card
still states the commands itself, because the compose commands are the actual
upgrade and the changelog is collapsible.
The API call goes through `updatesApi` in `frontend/src/lib/api/updates.ts`, with
`AppUpdateInfo` declared there and re-exported from `lib/api/index.ts`, as the
other update types are.
## Testing
Backend unit tests in the repo's existing style, with no test for the card —
`components/settings` has no test coverage today and this change does not
introduce the pattern.
- `compareVersions`: equal, newer, older, multi-digit ordering (`1.0.38` above
`1.0.9`), a leading `v` on either or both sides, and malformed inputs (`dev`,
`latest`, `1.0.38-5-gabc123`, `1.0`, empty).
- `AppUpdateService` with a mocked fetch: update available, up to date,
unreleased build, malformed latest tag, cache hit inside the hour, forced
refresh honoured after the 60-second floor and ignored inside it, one GitHub
call for concurrent cold-cache requests, a held result returned with
`lastCheckFailed` after a failure, and a 200 with `latestVersion: null` when a
failure has no cache to fall back on.
## Decisions taken
- **Notify only.** Self-update requires the Docker socket; a restart-only button
cannot pull, so it would only ever help someone who had already pulled.
- **The repository is hardcoded** to `darkraise/flashpoint-web`, as it already is
in the star-count proxy. A fork wanting its own release feed would need this
made configurable; nothing here is designed to prevent that later.
- **Docker Compose is the documented deployment**, so it is the only upgrade
command shown.
- **No notification badge** elsewhere in the UI, and no scheduled background
check. The cached on-open check covers the stated goal; a `JobScheduler` job
would be the way to add a badge later.
## Known limitation, not addressed here
A `workflow_dispatch` run of the build workflow tags the image `:latest` and
passes `VERSION=latest`, so that image reports an unreleased build. The card
makes a pre-existing CI wart visible; fixing it means changing what the manual
trigger publishes, which is out of scope for this feature.
@@ -1,131 +0,0 @@
# Persistent Ruffle installation
Date: 2026-08-13
## Goal
A Ruffle version installed through Settings → Update survives container
recreation. Today it does not: the update lands in the container's writable
layer, and the next `docker compose up -d` after an image pull or a config
change throws it away.
## Why it reverts
`RuffleService` installs into `config.frontendDistPath/ruffle` — `/app/frontend/dist/ruffle`
inside the container. The only mounted volumes are `/data/flashpoint`,
`/app/data`, and `/app/logs`, so that path lives entirely in the overlay
writable layer.
`frontend/public/ruffle/` is committed to the repository at
`0.2.0-nightly.2026.1.29`. Vite copies `public/` into `frontend/dist/` at build
time and the Dockerfile bakes that directory into the image. When a recreate
discards the writable layer, the baked copy is what remains.
Startup only downloads when `verifyInstallation()` is false
(`backend/src/server.ts:302`). The baked copy satisfies that check, so the
server logs the stale version and keeps it. The scheduled `ruffle-update` job
would eventually re-apply the update, but only if that job is enabled.
A plain `docker restart` preserves the writable layer and does not trigger this.
The recreate paths do: `docker compose up -d` after an image pull or any change
to the resolved service config, `down` then `up`, `--force-recreate`, and the
update actions in Portainer and Unraid.
## Install location
A new `config.ruffleDataPath` names the install directory:
```
process.env.RUFFLE_DATA_PATH ??
(NODE_ENV === 'production' ? '/app/data/ruffle' : <repo>/backend/data/ruffle)
```
`RuffleService` swaps only its `serveFrontend` branch to this path. The other
branch keeps writing to `frontend/public/ruffle`, because in a source checkout
Vite serves `public/` and an install anywhere else would not be reachable.
`docker-compose.dev.yml` sets `RUFFLE_DATA_PATH=/app/data/ruffle` explicitly. It
runs `NODE_ENV=development` with `SERVE_FRONTEND=true`, so the production-keyed
default would miss its `backend-db:/app/data` volume. The file already sets
`FLASHPOINT_PATH` for exactly this reason, and the new variable follows that
precedent. Production compose needs no change: `NODE_ENV=production` already
resolves to the right path.
The entrypoint chowns `/app/data` to the app user on every start, so a non-default
`PUID` can write the new directory without further work.
## Seeding a fresh install
`RuffleService.ensureInstalled()` replaces the inline block at
`backend/src/server.ts:299-314` and resolves in three steps:
1. `ruffle.js` exists at the data path — log the version and stop.
2. Otherwise the image's bundled `frontend/dist/ruffle` exists — copy it into
the data path via the existing private `copyDirectory`.
3. Otherwise — download the latest release from GitHub, which is the current
behaviour.
Seeding from the bundle rather than downloading means Flash works the moment a
fresh container is up, and works at all on a host that cannot reach GitHub. The
copy is roughly 20 MB and happens once per persisted volume, not once per
container.
The call stays fire-and-forget in the background. Awaiting it would hold the
port closed for the duration of a download, which is the regression the current
comment at `server.ts:296-298` records.
## Serving
`registerFrontendServing` mounts the data path at `/ruffle` **before**
`express.static(distPath)`, so the persisted copy shadows the baked one. The
mount uses `fallthrough: true`: if the persisted tree is missing a file, the
baked `dist/ruffle` still answers rather than 404ing into a dead player.
The frontend needs no change. `RufflePlayer.tsx` already loads
`/ruffle/ruffle.js` and sets `publicPath: '/ruffle/'`, both of which the new
mount serves.
## Cache headers
The dist handler currently serves everything with `maxAge: '1y', immutable: true`,
including `ruffle/ruffle.js`, which is not content-hashed. A browser that has
loaded the page once will therefore keep the old loader for a year, so
persistence alone would not make an update visible to returning users.
A shared helper decides the policy from the basename: `immutable, max-age=1y`
when it contains a hex run of 16 or more characters, otherwise
`no-cache, no-store, must-revalidate`. Ruffle's chunks carry such a run
(`core.ruffle.4ca82b563e9711217164.js`, `f2a570ccf4468b20d95a.wasm`) while its
stable entry points do not (`ruffle.js`, `ruffle.js.map`, `package.json`).
Detecting the hash rather than listing the stable names keeps the rule correct
when a future Ruffle release adds a file.
The helper applies to both the new `/ruffle` mount and to `ruffle/` paths under
the dist handler, so the fallback cannot reintroduce a stale loader.
## Testing
`RuffleService.test.ts` mocks `../config`; that mock gains `ruffleDataPath` and
the existing `ruffleDir()` helper points at it. The update tests are otherwise
unchanged — they exercise the rename and copy fallbacks, which the new path does
not alter.
New cases cover `ensureInstalled()`:
- an existing install is left alone and no download is attempted;
- an empty data path with a bundled copy present is seeded from the bundle, and
no download is attempted;
- an empty data path with no bundle falls through to the download.
A new `frontendServing` test asserts the `/ruffle` mount is registered ahead of
the dist handler, that `ruffle.js` comes back revalidating, and that a hashed
chunk comes back immutable.
## Out of scope
`config.userDbPath` and `config.tempDownloadPath` use the same
`NODE_ENV === 'production'` keying as the new variable. In the dev compose
container that evaluates to `development`, so `user.db` lands at
`/app/backend/user.db` and the `backend-db:/app/data` volume goes unused — dev
container app data is not persisted today. Real, but unrelated to Ruffle, and
touching database paths carries risk this change does not need.
+9 -67
View File
@@ -1,4 +1,4 @@
import { Routes, Route, useNavigate, useLocation } from 'react-router-dom';
import { Routes, Route, Navigate, useNavigate, useLocation } from 'react-router-dom';
import { useEffect, lazy, Suspense } from 'react';
import { useQuery } from '@tanstack/react-query';
import { MainLayout } from './components/layout/MainLayout';
@@ -41,12 +41,6 @@ const HomeView = lazy(() => import('./views/HomeView').then((m) => ({ default: m
const BrowseView = lazy(() =>
import('./views/BrowseView').then((m) => ({ default: m.BrowseView }))
);
const FlashGamesView = lazy(() =>
import('./views/FlashGamesView').then((m) => ({ default: m.FlashGamesView }))
);
const HTML5GamesView = lazy(() =>
import('./views/HTML5GamesView').then((m) => ({ default: m.HTML5GamesView }))
);
const AnimationsView = lazy(() =>
import('./views/AnimationsView').then((m) => ({ default: m.AnimationsView }))
);
@@ -95,6 +89,12 @@ const ActivitiesView = lazy(() =>
import('./views/ActivitiesView').then((m) => ({ default: m.ActivitiesView }))
);
/** The old /flash and /html5 sections are now part of /browse */
function LegacySectionRedirect() {
const { pathname, search } = useLocation();
return <Navigate to={pathname.replace(/^\/(flash|html5)/, '/browse') + search} replace />;
}
function App() {
const { isAuthenticated, isGuest, clearAuth, setGuestMode } = useAuthStore();
const isServerStarting = useServerStatusStore((state) => state.isStarting);
@@ -289,6 +289,8 @@ function App() {
{/* ==================== STANDALONE ==================== */}
<Route path="/maintenance" element={<MaintenancePage />} />
<Route path="/flash/*" element={<LegacySectionRedirect />} />
<Route path="/html5/*" element={<LegacySectionRedirect />} />
{/* ==================== PORTAL LAYOUT ==================== */}
<Route element={<PortalLayout />}>
@@ -304,26 +306,6 @@ function App() {
/>
{/* Public/guest accessible routes */}
<Route
path="/flash"
element={
<ProtectedRoute requireAuth={false}>
<Suspense fallback={<RouteLoadingFallback />}>
<FlashGamesView />
</Suspense>
</ProtectedRoute>
}
/>
<Route
path="/html5"
element={
<ProtectedRoute requireAuth={false}>
<Suspense fallback={<RouteLoadingFallback />}>
<HTML5GamesView />
</Suspense>
</ProtectedRoute>
}
/>
<Route
path="/browse"
element={
@@ -346,46 +328,6 @@ function App() {
/>
{/* Section-based game routes - must come before /games/:id */}
<Route
path="/flash/:id"
element={
<ProtectedRoute requireAuth={false} allowSharedAccess={true}>
<Suspense fallback={<RouteLoadingFallback />}>
<GamePage />
</Suspense>
</ProtectedRoute>
}
/>
<Route
path="/flash/:id/play"
element={
<ProtectedRoute requirePermission="games.play">
<Suspense fallback={<RouteLoadingFallback />}>
<GamePage autoPlay />
</Suspense>
</ProtectedRoute>
}
/>
<Route
path="/html5/:id"
element={
<ProtectedRoute requireAuth={false} allowSharedAccess={true}>
<Suspense fallback={<RouteLoadingFallback />}>
<GamePage />
</Suspense>
</ProtectedRoute>
}
/>
<Route
path="/html5/:id/play"
element={
<ProtectedRoute requirePermission="games.play">
<Suspense fallback={<RouteLoadingFallback />}>
<GamePage autoPlay />
</Suspense>
</ProtectedRoute>
}
/>
<Route
path="/animations/:id"
element={
@@ -35,7 +35,7 @@ interface GameBrowseLayoutProps {
platform?: string;
headerContent?: ReactNode;
breadcrumbContext?: BreadcrumbContext;
/** Section key for URL building ('flash', 'html5', 'animations', 'browse') */
/** Section key for URL building ('animations', 'browse') */
sectionKey?: string | null;
/** Pin the downloaded filter on and hide its switch (used by the Downloaded page) */
forceDownloaded?: boolean;
+1 -1
View File
@@ -29,7 +29,7 @@ interface GameCardProps {
isFavoritePage?: boolean;
shareToken?: string | null;
breadcrumbContext?: BreadcrumbContext;
/** Section key for URL building ('flash', 'html5', 'animations', 'browse') */
/** Section key for URL building ('animations', 'browse') */
sectionKey?: string | null;
/** Rating aggregate data for this game */
ratingData?: BatchRatingAggregate;
+1 -1
View File
@@ -12,7 +12,7 @@ interface GameGridProps {
isFavoritePage?: boolean;
shareToken?: string | null;
breadcrumbContext?: BreadcrumbContext;
/** Section key for URL building ('flash', 'html5', 'animations', 'browse') */
/** Section key for URL building ('animations', 'browse') */
sectionKey?: string | null;
/** Batch rating data keyed by game ID */
ratingDataMap?: Record<string, BatchRatingAggregate>;
+1 -1
View File
@@ -12,7 +12,7 @@ interface GameListProps {
isFavoritePage?: boolean;
shareToken?: string | null;
breadcrumbContext?: BreadcrumbContext;
/** Section key for URL building ('flash', 'html5', 'animations', 'browse') */
/** Section key for URL building ('animations', 'browse') */
sectionKey?: string | null;
/** Batch rating data keyed by game ID */
ratingDataMap?: Record<string, BatchRatingAggregate>;
@@ -28,7 +28,7 @@ interface GameListItemProps {
isFavoritePage?: boolean;
shareToken?: string | null;
breadcrumbContext?: BreadcrumbContext;
/** Section key for URL building ('flash', 'html5', 'animations', 'browse') */
/** Section key for URL building ('animations', 'browse') */
sectionKey?: string | null;
/** Rating aggregate data for this game */
ratingData?: BatchRatingAggregate;
@@ -73,6 +73,9 @@ export function AppUpdateCard() {
setShowChangelog(true);
}
// Without a release source (APP_UPDATE_URL) there is nothing to check
if (info?.enabled === false) return null;
const checkFailedEntirely =
isLoadingError || (info?.lastCheckFailed === true && info.latestVersion === null);
const currentVersionMatchesLatest = info
+2
View File
@@ -38,6 +38,8 @@ export interface AssetDownloadProgress {
}
export interface AppUpdateInfo {
/** False when the server has no release source configured */
enabled: boolean;
currentVersion: string | null;
latestVersion: string | null;
updateAvailable: boolean;
+8 -24
View File
@@ -12,9 +12,9 @@ export interface SectionIcon {
* Section configuration for game routes
*/
export interface SectionConfig {
/** URL prefix for game routes (e.g., '/flash') */
/** URL prefix for game routes (e.g., '/browse') */
prefix: string;
/** Sidebar item path (e.g., '/flash') */
/** Sidebar item path (e.g., '/browse') */
sidebarPath: string;
/** Label for breadcrumbs */
breadcrumbLabel: string;
@@ -28,20 +28,6 @@ export interface SectionConfig {
* Section configurations keyed by section identifier
*/
export const SECTION_CONFIGS: Record<string, SectionConfig> = {
flash: {
prefix: '/flash',
sidebarPath: '/flash',
breadcrumbLabel: 'Flash Games',
breadcrumbHref: '/flash',
icon: { type: 'image', value: '/images/Flash.png' },
},
html5: {
prefix: '/html5',
sidebarPath: '/html5',
breadcrumbLabel: 'HTML5 Games',
breadcrumbHref: '/html5',
icon: { type: 'image', value: '/images/HTML5.png' },
},
animations: {
prefix: '/animations',
sidebarPath: '/animations',
@@ -65,8 +51,6 @@ export const SECTION_CONFIGS: Record<string, SectionConfig> = {
* When breadcrumb context exists, it takes priority over prefix matching.
*/
export const SIDEBAR_PREFIX_MAP: Record<string, readonly string[]> = {
'/flash': ['/flash/'],
'/html5': ['/html5/'],
'/animations': ['/animations/'],
'/browse': ['/browse/', '/games/'], // /games/ only highlights Browse on direct URL access
'/playlists': ['/playlists/'],
@@ -76,7 +60,7 @@ export const SIDEBAR_PREFIX_MAP: Record<string, readonly string[]> = {
/**
* Get section configuration from a URL pathname
* @param pathname - Current URL pathname (e.g., '/flash/abc-123')
* @param pathname - Current URL pathname (e.g., '/browse/abc-123')
* @returns Section config if pathname matches a section route, null otherwise
*/
export function getSectionFromPath(pathname: string): SectionConfig | null {
@@ -90,8 +74,8 @@ export function getSectionFromPath(pathname: string): SectionConfig | null {
/**
* Get section key from a URL pathname
* @param pathname - Current URL pathname (e.g., '/flash/abc-123')
* @returns Section key ('flash', 'html5', etc.) or null
* @param pathname - Current URL pathname (e.g., '/browse/abc-123')
* @returns Section key ('browse', 'animations') or null
*/
export function getSectionKeyFromPath(pathname: string): string | null {
for (const [key, config] of Object.entries(SECTION_CONFIGS)) {
@@ -105,9 +89,9 @@ export function getSectionKeyFromPath(pathname: string): string | null {
/**
* Build a section-based game URL
* @param gameId - Game UUID
* @param sectionKey - Section key ('flash', 'html5', 'animations', 'browse') or null
* @param sectionKey - Section key ('animations', 'browse') or null
* @param isPlay - Whether this is a play URL (adds '/play' suffix)
* @returns URL string (e.g., '/flash/abc-123' or '/flash/abc-123/play')
* @returns URL string (e.g., '/browse/abc-123' or '/browse/abc-123/play')
*/
export function buildSectionGameUrl(
gameId: string,
@@ -127,7 +111,7 @@ export function buildSectionGameUrl(
/**
* Derive breadcrumb context from a URL pathname
* @param pathname - Current URL pathname (e.g., '/flash/abc-123')
* @param pathname - Current URL pathname (e.g., '/browse/abc-123')
* @returns BreadcrumbContext if pathname matches a section route, null otherwise
*/
export function getBreadcrumbContextFromPath(pathname: string): BreadcrumbContext | null {
-1
View File
@@ -154,7 +154,6 @@ export const handlers = [
app: {
maintenance_mode: false,
allow_registration: true,
homeRecentHours: 24,
},
metadata: {
flashpointEdition: 'infinity',
+1 -5
View File
@@ -7,7 +7,6 @@ export interface AppSettings {
maintenanceMessage: string;
defaultTheme?: string;
defaultPrimaryColor?: string;
homeRecentHours?: number;
}
export interface FeatureSettings {
@@ -71,10 +70,7 @@ export interface Domain {
}
export interface PublicSettings {
app: Pick<
AppSettings,
'siteName' | 'tagline' | 'maintenanceMode' | 'maintenanceMessage' | 'homeRecentHours'
>;
app: Pick<AppSettings, 'siteName' | 'tagline' | 'maintenanceMode' | 'maintenanceMessage'>;
auth: Pick<AuthSettings, 'guestAccessEnabled' | 'userRegistrationEnabled'>;
features?: Partial<FeatureSettings>;
metadata?: {
-13
View File
@@ -1,13 +0,0 @@
import { GameBrowseLayout } from '@/components/library/GameBrowseLayout';
export function FlashGamesView() {
return (
<GameBrowseLayout
title="Flash Games"
library="arcade"
platform="Flash"
breadcrumbContext={{ label: 'Flash Games', href: '/flash' }}
sectionKey="flash"
/>
);
}
-13
View File
@@ -1,13 +0,0 @@
import { GameBrowseLayout } from '@/components/library/GameBrowseLayout';
export function HTML5GamesView() {
return (
<GameBrowseLayout
title="HTML5 Games"
library="arcade"
platform="HTML5"
breadcrumbContext={{ label: 'HTML5 Games', href: '/html5' }}
sectionKey="html5"
/>
);
}
+127 -37
View File
@@ -1,44 +1,66 @@
#!/usr/bin/env bash
# Downloads Flashpoint Ultimate and extracts it into a directory Dotcom Arcade
# can serve. Safe to re-run: it resumes the download and skips archives it has
# already extracted.
# Extracts Flashpoint Ultimate into a directory Dotcom Arcade can serve, and
# can download it first from a torrent you got from Flashpoint. Safe to re-run:
# it resumes the download and skips archives it has already extracted.
#
# scripts/setup-flashpoint.sh [options] /path/to/flashpoint
#
# Options:
# --downloads DIR where the torrent downloads to (default: DEST/../downloads)
# --keep-archives keep each archive after extracting it, to keep seeding;
# needs room for both (about 4.6 TB for Ultimate 14)
# --skip-download use archives already in the downloads directory
# --with-infinity add Flashpoint Infinity for Linux's software, which
# streaming needs (FPSoftware/, Server/)
# --torrent FILE download Flashpoint Ultimate with aria2c, from the
# .torrent file on https://flashpointarchive.org/downloads
# --downloads DIR where the archives are or will be downloaded to
# (default: DEST/../downloads)
# --infinity FILE add the software streaming needs (FPSoftware/, Server/)
# from Flashpoint Infinity for Linux (.7z, same page)
# --seed-ratio N after downloading, keep seeding until N times the
# download has been uploaded (e.g. 1.0), then extract
# --seed-hours N after downloading, keep seeding for up to N hours, then
# extract; with --seed-ratio, whichever comes first
# --keep-archives keep each archive after extracting it, e.g. to go on
# seeding with a torrent client; needs room for both copies
# --yes don't ask for confirmation
#
# Requires aria2c (unless --skip-download) and 7-Zip (7z, 7zz or 7za).
# Seeding helps keep Flashpoint's torrent alive for everyone. Seeding before
# extracting needs no extra disk space.
#
# Without --torrent, it uses the Ultimate archives already in the downloads
# directory. Requires 7-Zip (7z, 7zz or 7za), and aria2c with --torrent.
#
# Flashpoint Ultimate is more than 2 TB. This script checks for the space
# before it starts and refuses to run without it.
#
# Flashpoint Archive's legal standing is unsettled. You are responsible for
# complying with copyright law where you live. This script contains no
# download links: it only downloads from a torrent you supply.
set -euo pipefail
ULTIMATE_VERSION=14
TORRENT_URL="https://download.unstable.life/FlashpointUltimate${ULTIMATE_VERSION}.torrent"
INFINITY_URL="https://download.unstable.life/upload/fp14.0.3_lin_20251201.7z"
INFINITY_SHA256=f393a98c5c35e229a744c102b0cb53270b1b4f1b3ebd40d604f98323444a4b1f
# Free space to leave on the disk after each archive
# Free space to leave on the disk after each archive, and headroom for the
# largest archive being extracted while its zip still exists
MARGIN=$((20 * 1024 ** 3))
LARGEST_ARCHIVE=$((100 * 1024 ** 3))
usage() {
sed -n '6,16p' "$0" | sed 's/^# \{0,1\}//'
sed -n '6,34p' "$0" | sed 's/^# \{0,1\}//'
exit 1
}
DEST=
DOWNLOADS=
TORRENT=
INFINITY=
KEEP=false
DOWNLOAD=true
INFINITY=false
YES=false
SEED_RATIO=
SEED_HOURS=
while (($#)); do
case $1 in
--torrent) TORRENT=${2:?}; shift ;;
--downloads) DOWNLOADS=${2:?}; shift ;;
--infinity) INFINITY=${2:?}; shift ;;
--seed-ratio) SEED_RATIO=${2:?}; shift ;;
--seed-hours) SEED_HOURS=${2:?}; shift ;;
--keep-archives) KEEP=true ;;
--skip-download) DOWNLOAD=false ;;
--with-infinity) INFINITY=true ;;
--yes) YES=true ;;
-h | --help) usage ;;
-*) echo "Unknown option: $1" >&2; usage ;;
*) DEST=$1 ;;
@@ -46,6 +68,16 @@ while (($#)); do
shift
done
[[ -n $DEST ]] || usage
# aria2c reads a ratio of 0 as "seed forever", which would never get to extracting
[[ -z $SEED_RATIO || ($SEED_RATIO =~ ^[0-9]+(\.[0-9]+)?$ && $SEED_RATIO =~ [1-9]) ]] ||
{ echo "--seed-ratio takes a number above 0, e.g. 1.0" >&2; exit 1; }
[[ -z $SEED_HOURS || $SEED_HOURS =~ ^[0-9]+$ ]] || { echo "--seed-hours takes a whole number of hours" >&2; exit 1; }
if [[ -z $TORRENT && -n $SEED_RATIO$SEED_HOURS ]]; then
echo "Seeding needs --torrent" >&2
exit 1
fi
[[ -z $TORRENT || -f $TORRENT ]] || { echo "No torrent file at $TORRENT" >&2; exit 1; }
[[ -z $INFINITY || -f $INFINITY ]] || { echo "No Infinity archive at $INFINITY" >&2; exit 1; }
mkdir -p "$DEST"
DEST=$(cd "$DEST" && pwd)
DOWNLOADS=${DOWNLOADS:-$(dirname "$DEST")/downloads}
@@ -53,8 +85,63 @@ mkdir -p "$DOWNLOADS"
SEVENZIP=$(command -v 7zz || command -v 7z || command -v 7za || true)
[[ -n $SEVENZIP ]] || { echo "7-Zip is required (7z, 7zz or 7za)" >&2; exit 1; }
[[ -z $TORRENT ]] || command -v aria2c >/dev/null || { echo "aria2c is required for --torrent" >&2; exit 1; }
gib() { echo "$(($1 / 1024 ** 3))G"; }
free_bytes() { df --output=avail -B1 "$1" | tail -1; }
same_disk() { [[ $(stat -c %d "$1") == $(stat -c %d "$2") ]]; }
DONE=$DOWNLOADS/.extracted
touch "$DONE"
# Once extraction has started, extracted archives are deleted; downloading
# again would fetch them all over
if [[ -n $TORRENT && -s $DONE ]]; then
echo "Extraction already started; continuing it without downloading."
TORRENT=
fi
# What's left to extract, measured from the archives on disk
remaining=0
while IFS= read -r -d '' archive; do
grep -qxF "$(basename "$archive")" "$DONE" || remaining=$((remaining + $(stat -c %s "$archive")))
done < <(find "$DOWNLOADS" -maxdepth 2 -name '*.zip' -print0)
# The download's size, from the torrent
download=0
if [[ -n $TORRENT ]]; then
download=$(aria2c -S "$TORRENT" | awk -F'[()]' '/Total Length/ { gsub(/,/, "", $2); print $2; exit }')
[[ $download =~ ^[0-9]+$ ]] || { echo "Couldn't read the download's size from $TORRENT" >&2; exit 1; }
remaining=$download
fi
# Extracted files take about as much room as the archives. Unless they're
# kept, each archive is deleted once extracted, so on a shared disk only the
# largest one is ever stored twice.
if $KEEP; then extra=$remaining; else extra=$((remaining < LARGEST_ARCHIVE ? remaining : LARGEST_ARCHIVE)); fi
if same_disk "$DOWNLOADS" "$DEST"; then
checks=("$DEST:$((download + extra + MARGIN))")
else
checks=("$DOWNLOADS:$((download + MARGIN))" "$DEST:$((remaining + MARGIN))")
fi
if ((remaining > 0)); then
echo "Flashpoint Ultimate is very large: about $(gib "$remaining") of game data."
short=false
for check in "${checks[@]}"; do
dir=${check%:*} need=${check##*:} avail=$(free_bytes "${check%:*}")
echo " $dir needs about $(gib "$need"), has $(gib "$avail") free"
((avail >= need)) || short=true
done
if $short; then
echo "Not enough free space. Free up room or choose another disk; see the README's hosting requirements." >&2
exit 1
fi
if ! $YES; then
[[ -t 0 ]] || { echo "Run with --yes to continue without a prompt." >&2; exit 1; }
read -r -p "This takes a day or more. Continue? [y/N] " answer
[[ $answer == [yY]* ]] || exit 1
fi
fi
# Extracts ARCHIVE into DEST, or only the entries listed in LIST if given.
# Archives may wrap everything in one top-level folder (e.g. "Flashpoint
@@ -86,19 +173,25 @@ extract() {
}
# 1. Download the packed archives
if $DOWNLOAD; then
command -v aria2c >/dev/null || { echo "aria2c is required to download" >&2; exit 1; }
echo "==> Downloading Flashpoint Ultimate ${ULTIMATE_VERSION} into $DOWNLOADS"
aria2c --dir="$DOWNLOADS" --seed-time=0 --check-integrity=true \
--file-allocation=falloc --bt-max-peers=100 --follow-torrent=mem "$TORRENT_URL"
if [[ -n $TORRENT ]]; then
# aria2c stops seeding at whichever limit it reaches first; with neither, it
# stops as soon as the download completes
seed=(--seed-time=0)
if [[ -n $SEED_RATIO || -n $SEED_HOURS ]]; then
seed=(--seed-ratio="${SEED_RATIO:-0.0}")
[[ -z $SEED_HOURS ]] || seed+=(--seed-time="$((SEED_HOURS * 60))")
echo "==> Downloading into $DOWNLOADS, then seeding (${SEED_RATIO:+ratio $SEED_RATIO}${SEED_RATIO:+${SEED_HOURS:+, }}${SEED_HOURS:+up to $SEED_HOURS hours})"
else
echo "==> Downloading into $DOWNLOADS"
fi
aria2c --dir="$DOWNLOADS" "${seed[@]}" --check-integrity=true \
--file-allocation=falloc --bt-max-peers=100 "$TORRENT"
fi
# fp-index.json stays behind once the archives are extracted and deleted
PACKED=$(find "$DOWNLOADS" -maxdepth 2 \( -name GameData_1.zip -o -name fp-index.json \) -printf '%h\n' | head -1)
[[ -n $PACKED ]] || { echo "No Ultimate archives found under $DOWNLOADS" >&2; exit 1; }
# 2. Extract the game data, images and htdocs, one archive at a time
DONE=$DOWNLOADS/.extracted
touch "$DONE"
shopt -s nullglob
for archive in "$PACKED"/Images_*.zip "$PACKED"/Htdocs_*.zip "$PACKED"/GameData_*.zip "$PACKED"/Extras_*.zip; do
name=$(basename "$archive")
@@ -128,22 +221,19 @@ if [[ ! -f $DEST/preferences.json && -f $DEST/.preferences.defaults.json ]]; the
fi
# 4. Optionally add Infinity for Linux's software, without replacing anything
if $INFINITY; then
infinity=$DOWNLOADS/$(basename "$INFINITY_URL")
if [[ ! -f $infinity ]]; then
echo "==> Downloading Flashpoint Infinity for Linux"
curl -fL --retry 3 -o "$infinity.part" "$INFINITY_URL"
mv "$infinity.part" "$infinity"
fi
echo "$INFINITY_SHA256 $infinity" | sha256sum -c --quiet
if [[ -n $INFINITY ]]; then
echo "==> $(basename "$INFINITY")"
staging=$DEST/.extracting
rm -rf "$staging"
"$SEVENZIP" x -y -bso0 -bsp1 -o"$staging" "$infinity"
"$SEVENZIP" x -y -bso0 -bsp1 -o"$staging" "$INFINITY"
root=$(dirname "$(find "$staging" -maxdepth 3 -name version.txt | head -1)")
cp -an "$root/." "$DEST/"
rm -rf "$staging"
fi
[[ -f $DEST/Data/flashpoint.sqlite ]] ||
echo "Warning: no Data/flashpoint.sqlite. Re-run with --with-infinity to take Infinity's." >&2
echo "Warning: no Data/flashpoint.sqlite. Re-run with --infinity to take Infinity's." >&2
echo "Done. Set FLASHPOINT_HOST_PATH=$DEST in .env."
if [[ -n $TORRENT && -z $SEED_RATIO$SEED_HOURS ]]; then
echo "Consider seeding next time (--seed-ratio 1.0, or --seed-hours 24) to help keep Flashpoint's torrent alive."
fi
+1 -1
View File
@@ -12,7 +12,7 @@ application path of `LocalGames\<name>\<game>.exe`.
A Linux Flashpoint install. The streamer uses the Infinity build's
`Server/FlashpointGameServer`, `FPSoftware/Java` and `FPSoftware/Wine`, which
Ultimate's archives only include for Windows.
`scripts/setup-flashpoint.sh --with-infinity` adds them without overwriting
`scripts/setup-flashpoint.sh --infinity` adds them without overwriting
anything.
## Running one by hand