Merge pull request 'Dotcom Arcade: rename, one compose file, Ultimate setup script and docs' (#1) from rebrand-and-docs into main
Reviewed-on: #1 Reviewed-by: branderson <brad@branderson.io>
This commit was merged in pull request #1.
This commit is contained in:
+16
-13
@@ -1,8 +1,5 @@
|
||||
# Flashpoint Web - Environment Template
|
||||
# Copy to .env and fill in required values.
|
||||
#
|
||||
# Production: docker compose up -d
|
||||
# Development: docker compose -f docker-compose.dev.yml up -d --build
|
||||
# Dotcom Arcade - Environment Template
|
||||
# Copy to .env and fill in required values, then: docker compose up -d --build
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Required
|
||||
@@ -11,10 +8,15 @@ FLASHPOINT_HOST_PATH=/path/to/Flashpoint
|
||||
JWT_SECRET=replace-with-a-secure-random-string
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Container Image (production only)
|
||||
# Container Images
|
||||
# ---------------------------------------------------------------------------
|
||||
# Override image tag (image from Docker Hub: darkraise/flashpoint-web)
|
||||
# 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)
|
||||
@@ -43,7 +45,7 @@ LOG_LEVEL=info
|
||||
OTEL_ENABLED=false
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
|
||||
# OTEL_API_KEY=your-api-key-here
|
||||
OTEL_SERVICE_NAME=flashpoint-web
|
||||
OTEL_SERVICE_NAME=dotcom-arcade
|
||||
OTEL_TRACES_ENABLED=true
|
||||
OTEL_METRICS_ENABLED=true
|
||||
# OTEL_METRICS_EXPORT_INTERVAL=60000
|
||||
@@ -103,15 +105,16 @@ ENABLE_CACHE_PREWARM=true
|
||||
# ---------------------------------------------------------------------------
|
||||
# Application
|
||||
# ---------------------------------------------------------------------------
|
||||
# HOME_RECENT_HOURS=24
|
||||
# ENABLE_CGI=false
|
||||
# Streamed play for games that can't run in a browser, such as Java applets.
|
||||
# Off unless both are set. See docs/09-deployment/environment-variables.md.
|
||||
# STREAMING_API_URL=http://flashpoint-session-manager:8080
|
||||
# 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=
|
||||
# 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
|
||||
# both set. See docs/09-deployment/environment-variables.md.
|
||||
# both set. See docs/10-features/14-proxy-sign-in.md.
|
||||
# TRUSTED_HEADER_USERNAME=X-authentik-username
|
||||
# TRUSTED_HEADER_EMAIL=X-authentik-email
|
||||
# TRUSTED_PROXIES=192.0.2.10
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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 }}
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
## Project Overview
|
||||
|
||||
Flashpoint Web: self-hosted web app for browsing/playing Flashpoint Archive
|
||||
games.
|
||||
Dotcom Arcade: a self-hosted, Flash-era game portal for Flashpoint Ultimate.
|
||||
Built on darkraise's flashpoint-web.
|
||||
|
||||
- **Backend** (Express/TypeScript, port 3100): REST API + game content via
|
||||
`/game-proxy/*`, `/game-zip/*`
|
||||
@@ -11,6 +11,8 @@ games.
|
||||
Tailwind, Ruffle
|
||||
- **Databases**: `flashpoint.sqlite` (read-only, never write) + `user.db` (app
|
||||
data)
|
||||
- **Streaming** (`streaming/`): a session manager and per-player streamer
|
||||
containers for games that can't run in a browser; see `streaming/README.md`
|
||||
|
||||
## Commands
|
||||
|
||||
@@ -1191,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`
|
||||
|
||||
+6
-9
@@ -1,8 +1,8 @@
|
||||
# Contributing to Flashpoint Web
|
||||
# Contributing to Dotcom Arcade
|
||||
|
||||
Thanks for your interest in contributing! We welcome bug fixes, features, documentation improvements, and questions.
|
||||
|
||||
## What is Flashpoint Web?
|
||||
## What is Dotcom Arcade?
|
||||
|
||||
A self-hosted web application for browsing and playing games from [Flashpoint Archive](https://flashpointarchive.org/).
|
||||
|
||||
@@ -57,17 +57,14 @@ docs/ # Documentation
|
||||
cp .env.example .env
|
||||
# Edit .env - set FLASHPOINT_HOST_PATH and JWT_SECRET (required)
|
||||
|
||||
# Build and start (development)
|
||||
docker compose -f docker-compose.dev.yml up -d --build
|
||||
# Build and start
|
||||
docker compose up -d --build
|
||||
|
||||
# View logs
|
||||
docker compose -f docker-compose.dev.yml logs -f
|
||||
|
||||
# Rebuild specific service
|
||||
docker compose -f docker-compose.dev.yml up -d --build backend
|
||||
docker compose logs -f
|
||||
```
|
||||
|
||||
Frontend runs on `http://localhost:80`, backend API on `http://localhost:3100`.
|
||||
The app (UI and API) runs on `http://localhost:80`.
|
||||
|
||||
### Option 2: Manual Setup (Recommended for Development)
|
||||
|
||||
|
||||
+4
-5
@@ -1,8 +1,8 @@
|
||||
# Production Dockerfile for Flashpoint Web (single image: API + UI)
|
||||
# Production Dockerfile for Dotcom Arcade (single image: API + UI)
|
||||
#
|
||||
# The backend serves both the REST API / game content and the built React UI,
|
||||
# so this image runs a single Node process. Build from the repo root:
|
||||
# docker build -t flashpoint-web .
|
||||
# docker build -t dotcom-arcade .
|
||||
|
||||
# =============================================================================
|
||||
# Stage 1: Frontend builder - Build the React app
|
||||
@@ -54,10 +54,9 @@ FROM node:20.18.2-alpine
|
||||
|
||||
# Metadata labels
|
||||
ARG VERSION=dev
|
||||
LABEL org.opencontainers.image.title="Flashpoint Web"
|
||||
LABEL org.opencontainers.image.description="Self-hosted web app for Flashpoint Archive (API + UI)"
|
||||
LABEL org.opencontainers.image.title="Dotcom Arcade"
|
||||
LABEL org.opencontainers.image.description="Self-hosted Flash-era game portal for Flashpoint Ultimate (API + UI)"
|
||||
LABEL org.opencontainers.image.version="${VERSION}"
|
||||
LABEL org.opencontainers.image.source="https://github.com/darkraise/flashpoint-web"
|
||||
|
||||
# An ARG does not survive into the running container; the server reads this to
|
||||
# decide whether it is a released build.
|
||||
|
||||
@@ -1,238 +1,207 @@
|
||||
# Flashpoint Web
|
||||
# Dotcom Arcade
|
||||
|
||||
<div align="center">
|
||||
**Your own Flash-era game portal, powered by
|
||||
[Flashpoint Ultimate](https://flashpointarchive.org/).**
|
||||
|
||||
**A modern web interface for the Flashpoint Archive**
|
||||
Dotcom Arcade serves a [Flashpoint Ultimate](https://flashpointarchive.org/)
|
||||
collection as a website you'd have played at a friend's house or in the computer
|
||||
lab, with modern UX. It aims to play as much of the archive as possible in any
|
||||
browser: Flash and HTML5 run in the page, and Java, Shockwave and the original
|
||||
Flash Player stream from your server.
|
||||
|
||||
Browse and play 200,000+ preserved Flash games and animations directly in your
|
||||
browser.
|
||||

|
||||
|
||||
<!-- Tech Stack Badges -->
|
||||
<p>
|
||||
<img src="https://img.shields.io/badge/Node.js-339933?style=for-the-badge&logo=nodedotjs&logoColor=white" alt="Node.js" />
|
||||
<img src="https://img.shields.io/badge/TypeScript-3178C6?style=for-the-badge&logo=typescript&logoColor=white" alt="TypeScript" />
|
||||
<img src="https://img.shields.io/badge/React-61DAFB?style=for-the-badge&logo=react&logoColor=black" alt="React" />
|
||||
<img src="https://img.shields.io/badge/Express-000000?style=for-the-badge&logo=express&logoColor=white" alt="Express" />
|
||||
<img src="https://img.shields.io/badge/Vite-646CFF?style=for-the-badge&logo=vite&logoColor=white" alt="Vite" />
|
||||
<img src="https://img.shields.io/badge/Tailwind_CSS-06B6D4?style=for-the-badge&logo=tailwindcss&logoColor=white" alt="Tailwind CSS" />
|
||||
<img src="https://img.shields.io/badge/SQLite-003B57?style=for-the-badge&logo=sqlite&logoColor=white" alt="SQLite" />
|
||||
<img src="https://img.shields.io/badge/Docker-2496ED?style=for-the-badge&logo=docker&logoColor=white" alt="Docker" />
|
||||
</p>
|
||||
|
||||
<!-- Project Badges -->
|
||||
<p>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/License-GPL--3.0-blue?style=flat-square" alt="License" /></a>
|
||||
<img src="https://img.shields.io/badge/Node.js-20%2B-339933?style=flat-square&logo=nodedotjs&logoColor=white" alt="Node.js 20+" />
|
||||
<img src="https://img.shields.io/badge/PRs-Welcome-brightgreen?style=flat-square" alt="PRs Welcome" />
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
> [!IMPORTANT]
|
||||
> [!NOTE]
|
||||
>
|
||||
> **Already running 1.0.31 or earlier? Updating is a breaking change.** The
|
||||
> separate `flashpoint-backend` and `flashpoint-frontend` containers were
|
||||
> replaced in 1.0.32 by a single `darkraise/flashpoint-web` image, and the old
|
||||
> images are no longer published. Your compose file and `.env` both need changes
|
||||
> — see **[Upgrading](#upgrading)** before you pull.
|
||||
> Dotcom Arcade is an unofficial community project, not affiliated with
|
||||
> Flashpoint Archive. It's built on
|
||||
> [flashpoint-web](https://github.com/darkraise/flashpoint-web) by darkraise.
|
||||
|
||||
## What is Flashpoint Web?
|
||||
|
||||
Flashpoint Web is a self-hosted web application for accessing your local
|
||||
[Flashpoint Archive](https://flashpointarchive.org/) collection. It provides a
|
||||
modern, browser-based alternative to the Flashpoint Launcher with multi-user
|
||||
support, play tracking, and an intuitive interface.
|
||||
|
||||
### Key Features
|
||||
|
||||
- **Browse & Search** - Fast search across 200,000+ games with advanced
|
||||
filtering by platform, developer, tags, and more
|
||||
- **Downloaded View** - A dedicated page and filter for games whose data is
|
||||
already on disk, so a curated collection is one click away
|
||||
- **Play in Browser** - Flash games via [Ruffle](https://ruffle.rs/) emulator,
|
||||
HTML5 games natively
|
||||
- **Multi-User Support** - Individual accounts with role-based permissions
|
||||
(admin, moderator, user, guest)
|
||||
- **Play Tracking** - Track playtime, completion status, and view play
|
||||
statistics
|
||||
- **Playlists & Favorites** - Organize your game collection with custom
|
||||
playlists
|
||||
- **22 Theme Palettes** - Customizable dark/light modes with multiple color
|
||||
schemes
|
||||
- **Responsive Design** - Works on desktop and mobile devices
|
||||
- **Database Hot-Reload** - Automatically syncs when Flashpoint Launcher updates
|
||||
metadata
|
||||
|
||||
## Demo: [flash.quangtc.dev](https://flash.quangtc.dev)
|
||||
Register new account and try your favorite flash games
|
||||
|
||||
## Upgrading
|
||||
|
||||
Routine updates need nothing beyond
|
||||
`docker compose pull && docker compose up -d`.
|
||||
## Before you start: hosting requirements
|
||||
|
||||
> [!WARNING]
|
||||
>
|
||||
> **Coming from 1.0.31 or earlier, that is not enough.** 1.0.32 replaced the
|
||||
> two-container deployment with a single image, so an existing setup needs:
|
||||
>
|
||||
> - a new `docker-compose.yml` with one `flashpoint-web` service, mapping
|
||||
> `WEB_PORT` to container port `3100`
|
||||
> - `API_PORT`, `BACKEND_HOST`, and `BACKEND_PORT` removed from `.env` — they no
|
||||
> longer do anything
|
||||
> - a reverse proxy pointing at one upstream instead of two; any rule sending
|
||||
> `/api` to port 3100 will break
|
||||
> - the Flashpoint mount switched to read-write, or the Downloaded page stays
|
||||
> empty
|
||||
>
|
||||
> Accounts, playlists, and play history are preserved.
|
||||
> **Dotcom Arcade needs a lot of disk, and streaming needs a lot of CPU.** Check
|
||||
> these before you download anything.
|
||||
|
||||
Full migration steps, every environment variable added, removed, or changed, and
|
||||
per-version release notes are in the
|
||||
**[Upgrade Guide](docs/09-deployment/upgrade-guide.md)**.
|
||||
| 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. |
|
||||
|
||||
## Getting Started
|
||||
## Features
|
||||
|
||||
### Prerequisites
|
||||
- **Plays almost everything.**
|
||||
- Flash runs in [Ruffle](https://ruffle.rs/) and HTML5 runs natively.
|
||||
- Games no browser can run stream from the server into the page, each player
|
||||
in their own container: Java applets, Shockwave and Director games, and
|
||||
Flash games that need the real Flash Player. With streaming on, any Flash
|
||||
game can switch to the original Flash Player.
|
||||
- **An arcade, not a file browser.** A portal-style layout with genre categories
|
||||
and glossy tiles, and color schemes defined as data, so new themes are easy to
|
||||
add.
|
||||
- **Discovery first.** The homepage offers:
|
||||
- a Game of the Day;
|
||||
- a Time Machine of games released in any week, with a date picker;
|
||||
- shelves from Flashpoint's curated playlists;
|
||||
- your recently played games.
|
||||
|
||||
- [Docker](https://docs.docker.com/get-docker/) and
|
||||
[Docker Compose](https://docs.docker.com/compose/install/)
|
||||
- [Flashpoint Archive](https://flashpointarchive.org/) installed locally
|
||||
Browse lists default to a shuffle, not A to Z.
|
||||
|
||||
### 1. Create a project directory
|
||||
- **One page per game.** It has a click-to-play stage, details and tags, and
|
||||
related games ranked by shared tags, series and developer.
|
||||
- **Accounts and saves.**
|
||||
- Multi-user, with roles.
|
||||
- Favorites, playlists and play tracking.
|
||||
- Game saves follow you between browsers, with the last 10 kept as backups.
|
||||
- **Sign in through your proxy.** Authentik, Authelia, oauth2-proxy and similar
|
||||
forward-auth proxies can sign users in.
|
||||
- **Mature content filter.** Adult and extreme content is hidden unless a user
|
||||
opts in.
|
||||
|
||||
```bash
|
||||
mkdir flashpoint-web && cd flashpoint-web
|
||||
<p align="center">
|
||||
<img src="docs/images/game.png" alt="A game page" width="49%" />
|
||||
<img src="docs/images/playing.png" alt="Playing a game in the page" width="49%" />
|
||||
</p>
|
||||
|
||||
## Getting started
|
||||
|
||||
Check the [hosting requirements](#before-you-start-hosting-requirements) first.
|
||||
|
||||
### 1. Get Flashpoint Ultimate
|
||||
|
||||
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 --torrent FlashpointUltimate14.torrent \
|
||||
--infinity fp14.0.3_lin_20251201.7z --seed-ratio 1.0 /srv/flashpoint
|
||||
```
|
||||
|
||||
### 2. Create `docker-compose.yml`
|
||||
#### Please seed
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
```yaml
|
||||
services:
|
||||
flashpoint-web:
|
||||
image: darkraise/flashpoint-web:${IMAGE_TAG:-latest}
|
||||
container_name: flashpoint-web
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${WEB_PORT:-80}:3100"
|
||||
volumes:
|
||||
- ${FLASHPOINT_HOST_PATH:?FLASHPOINT_HOST_PATH is required}:/data/flashpoint
|
||||
- ${DATA_PATH:-./data}:/app/data
|
||||
- ${LOGS_PATH:-./logs}:/app/logs
|
||||
environment:
|
||||
- PUID=${PUID:-1000}
|
||||
- PGID=${PGID:-1000}
|
||||
- NODE_ENV=production
|
||||
- TZ=${TZ:-UTC}
|
||||
- DOMAIN=${DOMAIN:-http://localhost:${WEB_PORT:-80}}
|
||||
- LOG_LEVEL=${LOG_LEVEL:-info}
|
||||
- JWT_SECRET=${JWT_SECRET:?JWT_SECRET is required}
|
||||
- ENABLE_LOCAL_DB_COPY=${ENABLE_LOCAL_DB_COPY:-false}
|
||||
- SQLITE_CACHE_SIZE=${SQLITE_CACHE_SIZE:--64000}
|
||||
- SQLITE_MMAP_SIZE=${SQLITE_MMAP_SIZE:-268435456}
|
||||
- ENABLE_CACHE_PREWARM=${ENABLE_CACHE_PREWARM:-true}
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "--fail", "http://localhost:3100/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
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
|
||||
|
||||
```sh
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
The Flashpoint directory is mounted read-write: the app records which games are
|
||||
downloaded in `flashpoint.sqlite` and saves downloaded game data under
|
||||
`Data/Games`. Mount it read-only and browsing still works, but downloads and the
|
||||
Downloaded page do not.
|
||||
Set at least these:
|
||||
|
||||
### 3. Create `.env`
|
||||
|
||||
Two values are required. This is a complete, working `.env`:
|
||||
|
||||
```env
|
||||
FLASHPOINT_HOST_PATH=/srv/Flashpoint
|
||||
JWT_SECRET=paste-the-output-of-openssl-rand-hex-32-here
|
||||
```sh
|
||||
FLASHPOINT_HOST_PATH=/srv/flashpoint
|
||||
JWT_SECRET=<output of: openssl rand -hex 32>
|
||||
PUID=1000 # your user, from `id -u` and `id -g`
|
||||
PGID=1000
|
||||
```
|
||||
|
||||
Generate the secret with `openssl rand -hex 32`. Everything else has a working
|
||||
default; add only what you need:
|
||||
### 3. Start
|
||||
|
||||
```env
|
||||
# WEB_PORT=8080 # Host port for the app (UI + API) (default: 80)
|
||||
# PUID=1000 # Host user ID (Linux — run 'id -u')
|
||||
# PGID=1000 # Host group ID (Linux — run 'id -g')
|
||||
# LOG_LEVEL=info # debug, info, warn, error
|
||||
# TZ=UTC # Timezone
|
||||
# DATA_PATH=./data # Persistent app data
|
||||
# LOGS_PATH=./logs # Log files
|
||||
|
||||
# Extra allowed origins, comma-separated. Not needed for a normal install:
|
||||
# same-origin requests are always allowed. Set this only when the UI is served
|
||||
# from a different origin, or behind a proxy that rewrites the Host header.
|
||||
# DOMAIN=http://192.168.0.10,https://flashpoint.example.com
|
||||
|
||||
# Database performance (network storage / large collections)
|
||||
# ENABLE_LOCAL_DB_COPY=false # Copy flashpoint.sqlite to local storage
|
||||
# SQLITE_CACHE_SIZE=-64000 # SQLite cache in KB (64MB default)
|
||||
# SQLITE_MMAP_SIZE=268435456 # Memory-mapped I/O in bytes (256MB default)
|
||||
# ENABLE_CACHE_PREWARM=true # Pre-warm common queries on startup
|
||||
```sh
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
### 4. Start it
|
||||
Open `http://<server>/` and create the admin account. On first start the app
|
||||
indexes the collection, which takes a few minutes on a full Ultimate install.
|
||||
Under **Settings → App** you can set the site name and tagline.
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
### 4. Optional: streaming
|
||||
|
||||
Open **http://localhost** in your browser. The first user to register
|
||||
automatically becomes the **admin**.
|
||||
Streaming plays the games browsers can't. To turn it on:
|
||||
|
||||
A single container serves both the UI and the API on one port — `WEB_PORT` on
|
||||
the host, 3100 inside the container.
|
||||
1. Run the setup script with `--infinity`.
|
||||
2. Add to `.env`:
|
||||
|
||||
### Common Commands
|
||||
```sh
|
||||
COMPOSE_PROFILES=streaming
|
||||
STREAMING_API_TOKEN=<output of: openssl rand -hex 32>
|
||||
```
|
||||
|
||||
```bash
|
||||
docker compose up -d # Start
|
||||
docker compose down # Stop
|
||||
docker compose logs -f # Follow logs
|
||||
docker compose restart # Restart
|
||||
docker compose pull && docker compose up -d # Update to the latest image
|
||||
3. Rebuild with `docker compose up -d --build`.
|
||||
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. It publishes no ports: only the app talks to it.
|
||||
|
||||
### Signing in through a reverse proxy
|
||||
|
||||
If your site sits behind Authentik, Authelia or another forward-auth proxy, the
|
||||
app can trust the username it passes along. See
|
||||
[proxy sign-in](docs/10-features/14-proxy-sign-in.md).
|
||||
|
||||
## Updating
|
||||
|
||||
```sh
|
||||
git pull
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
For architecture details, API reference, development setup, contributing
|
||||
guidelines, and more, visit the
|
||||
**[Wiki](https://github.com/darkraise/flashpoint-web/wiki)**.
|
||||
The [docs](docs/README.md) cover features, deployment, configuration, the API
|
||||
and development. Useful starting points:
|
||||
|
||||
## Related Projects
|
||||
- [Environment variables](docs/09-deployment/environment-variables.md)
|
||||
- [Discovery portal](docs/10-features/12-discovery-portal.md)
|
||||
- [Streaming](docs/10-features/13-streaming.md) and
|
||||
[streaming internals](streaming/README.md)
|
||||
- [Development setup](docs/08-development/setup-guide.md)
|
||||
|
||||
- [Flashpoint Archive](https://flashpointarchive.org/) - The game preservation
|
||||
project
|
||||
- [Flashpoint Launcher](https://github.com/FlashpointProject/launcher) -
|
||||
Official desktop launcher
|
||||
- [Ruffle](https://ruffle.rs/) - Flash Player emulator
|
||||
## Credits
|
||||
|
||||
- [Flashpoint Archive](https://flashpointarchive.org/), for preserving web games
|
||||
and animations, and for the curated playlists the homepage draws on.
|
||||
- [flashpoint-web](https://github.com/darkraise/flashpoint-web) by darkraise,
|
||||
which this project is built on.
|
||||
- [Ruffle](https://ruffle.rs/), for running Flash in modern browsers.
|
||||
- [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
|
||||
|
||||
This project is licensed under the GNU General Public License v3.0 - see the
|
||||
[LICENSE](LICENSE) file for details.
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
- **Flashpoint Team** - For preserving web gaming history
|
||||
- **Ruffle Contributors** - For making Flash playable in modern browsers
|
||||
- **Community** - For testing, feedback, and contributions
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
|
||||
**[Wiki](https://github.com/darkraise/flashpoint-web/wiki)** |
|
||||
**[Report Bug](https://github.com/darkraise/flashpoint-web/issues)** |
|
||||
**[Request Feature](https://github.com/darkraise/flashpoint-web/issues)**
|
||||
|
||||
</div>
|
||||
GPL-3.0, like flashpoint-web. See [LICENSE](LICENSE).
|
||||
|
||||
@@ -22,7 +22,6 @@ BCRYPT_SALT_ROUNDS=10
|
||||
# ENABLE_CGI=false
|
||||
|
||||
# Misc
|
||||
HOME_RECENT_HOURS=24
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Database Performance Optimization (for network storage)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "flashpoint-web-backend",
|
||||
"name": "dotcom-arcade-backend",
|
||||
"version": "1.0.0",
|
||||
"description": "Backend API for Flashpoint Archive web application",
|
||||
"main": "dist/server.js",
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
-- ===================================
|
||||
-- Migration 011: Default site name
|
||||
-- ===================================
|
||||
-- The project is now Dotcom Arcade. Installs still on the old default take the
|
||||
-- new one; a name an admin chose is left alone.
|
||||
|
||||
UPDATE system_settings
|
||||
SET
|
||||
default_value = 'Dotcom Arcade',
|
||||
value = CASE WHEN value = 'Flashpoint Web' THEN 'Dotcom Arcade' ELSE value END
|
||||
WHERE key = 'app.site_name';
|
||||
@@ -61,7 +61,7 @@ const clientErrorLogger = winston.createLogger({
|
||||
})(),
|
||||
winston.format.json()
|
||||
),
|
||||
defaultMeta: { service: 'flashpoint-web-client' },
|
||||
defaultMeta: { service: 'dotcom-arcade-client' },
|
||||
transports: [
|
||||
new winston.transports.File({
|
||||
filename: ERROR_LOG_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;
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -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 = {};
|
||||
|
||||
@@ -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,
|
||||
|
||||
+11
-1
@@ -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
|
||||
@@ -225,11 +234,12 @@ async function startServer() {
|
||||
// pre-warm can run for minutes, and a closed port shows the user a connection
|
||||
// error instead of a page that explains itself.
|
||||
const server = app.listen(config.port, config.host, () => {
|
||||
logger.info(`🚀 Flashpoint Web API server running on http://${config.host}:${config.port}`);
|
||||
logger.info(`🚀 Dotcom Arcade server running on http://${config.host}:${config.port}`);
|
||||
logger.info(`📁 Flashpoint path: ${config.flashpointPath}`);
|
||||
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
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -17,7 +17,7 @@ import { logger } from '../utils/logger';
|
||||
* - OTEL_ENABLED: Enable/disable telemetry (true/false)
|
||||
* - OTEL_EXPORTER_OTLP_ENDPOINT: OTLP endpoint (e.g., http://localhost:4318)
|
||||
* - OTEL_API_KEY: API key for authentication (sent as "Authorization: Api-Key <key>")
|
||||
* - OTEL_SERVICE_NAME: Service name (default: flashpoint-web-backend)
|
||||
* - OTEL_SERVICE_NAME: Service name (default: dotcom-arcade-backend)
|
||||
* - OTEL_TRACES_ENABLED: Enable traces (default: true)
|
||||
* - OTEL_METRICS_ENABLED: Enable metrics (default: true)
|
||||
* - OTEL_METRICS_EXPORT_INTERVAL: Metrics export interval in ms (default: 60000)
|
||||
@@ -40,7 +40,7 @@ function getOtelConfig() {
|
||||
enabled: process.env.OTEL_ENABLED === 'true',
|
||||
endpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT || 'http://localhost:4318',
|
||||
apiKey: process.env.OTEL_API_KEY || '',
|
||||
serviceName: process.env.OTEL_SERVICE_NAME || 'flashpoint-web-backend',
|
||||
serviceName: process.env.OTEL_SERVICE_NAME || 'dotcom-arcade-backend',
|
||||
serviceVersion: process.env.npm_package_version || '1.0.0',
|
||||
tracesEnabled: process.env.OTEL_TRACES_ENABLED !== 'false',
|
||||
metricsEnabled: process.env.OTEL_METRICS_ENABLED !== 'false',
|
||||
|
||||
@@ -154,7 +154,7 @@ export const logger = winston.createLogger({
|
||||
otelFormat(),
|
||||
winston.format.json()
|
||||
),
|
||||
defaultMeta: { service: 'flashpoint-web-backend' },
|
||||
defaultMeta: { service: 'dotcom-arcade-backend' },
|
||||
transports,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
# =============================================================================
|
||||
# Flashpoint Web - Development Docker Compose
|
||||
# =============================================================================
|
||||
# Builds the single API + UI image locally from source.
|
||||
#
|
||||
# Usage:
|
||||
# docker compose -f docker-compose.dev.yml up -d --build
|
||||
#
|
||||
# View logs:
|
||||
# docker compose -f docker-compose.dev.yml logs -f
|
||||
#
|
||||
# User permissions:
|
||||
# Set PUID/PGID to match your host user (run 'id -u' and 'id -g' to find)
|
||||
# This ensures bind-mounted volumes have correct ownership
|
||||
# =============================================================================
|
||||
|
||||
services:
|
||||
flashpoint-web:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./Dockerfile
|
||||
image: flashpoint-web:dev
|
||||
container_name: flashpoint-web
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
# Serves both the UI and the API (/api, /game-proxy, /game-zip) on one port
|
||||
- "${WEB_PORT:-80}:3100"
|
||||
volumes:
|
||||
# Read-write: the app records downloaded state in flashpoint.sqlite and
|
||||
# writes downloaded game data under Data/Games.
|
||||
- ${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
|
||||
- ${LOGS_PATH:-./logs}:/app/logs
|
||||
environment:
|
||||
- PUID=${PUID:-1000}
|
||||
- PGID=${PGID:-1000}
|
||||
- NODE_ENV=${NODE_ENV:-development}
|
||||
- SERVE_FRONTEND=true
|
||||
# Matches the read-only mount above. Set explicitly because the backend's
|
||||
# fallback only points here when NODE_ENV=production.
|
||||
- FLASHPOINT_PATH=/data/flashpoint
|
||||
# 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
|
||||
- TZ=${TZ:-UTC}
|
||||
- DOMAIN=${DOMAIN:-http://localhost:${WEB_PORT:-80}}
|
||||
- LOG_LEVEL=${LOG_LEVEL:-info}
|
||||
# File I/O parallelism: images and ZIP reads run on libuv's threadpool,
|
||||
# whose default of 4 throttles every filesystem request in the process.
|
||||
- UV_THREADPOOL_SIZE=${UV_THREADPOOL_SIZE:-16}
|
||||
- JWT_SECRET=${JWT_SECRET:?JWT_SECRET is required}
|
||||
- ENABLE_LOCAL_DB_COPY=${ENABLE_LOCAL_DB_COPY:-false}
|
||||
- SQLITE_CACHE_SIZE=${SQLITE_CACHE_SIZE:--64000}
|
||||
- SQLITE_MMAP_SIZE=${SQLITE_MMAP_SIZE:-268435456}
|
||||
- ENABLE_CACHE_PREWARM=${ENABLE_CACHE_PREWARM:-true}
|
||||
# Auth & rate limiting
|
||||
- JWT_EXPIRES_IN=${JWT_EXPIRES_IN:-1h}
|
||||
- BCRYPT_SALT_ROUNDS=${BCRYPT_SALT_ROUNDS:-10}
|
||||
- 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}
|
||||
- ENABLE_CGI=${ENABLE_CGI:-false}
|
||||
# Streamed play for games that can't run in a browser (off unless both are set)
|
||||
- STREAMING_API_URL=${STREAMING_API_URL:-}
|
||||
- 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:-}
|
||||
- TRUSTED_HEADER_EMAIL=${TRUSTED_HEADER_EMAIL:-}
|
||||
- TRUSTED_PROXIES=${TRUSTED_PROXIES:-}
|
||||
# OpenTelemetry
|
||||
- OTEL_ENABLED=${OTEL_ENABLED:-false}
|
||||
- OTEL_EXPORTER_OTLP_ENDPOINT=${OTEL_EXPORTER_OTLP_ENDPOINT:-}
|
||||
- OTEL_API_KEY=${OTEL_API_KEY:-}
|
||||
- OTEL_SERVICE_NAME=${OTEL_SERVICE_NAME:-flashpoint-web}
|
||||
- OTEL_TRACES_ENABLED=${OTEL_TRACES_ENABLED:-true}
|
||||
- OTEL_METRICS_ENABLED=${OTEL_METRICS_ENABLED:-true}
|
||||
- OTEL_METRICS_EXPORT_INTERVAL=${OTEL_METRICS_EXPORT_INTERVAL:-60000}
|
||||
- OTEL_LOG_LEVEL=${OTEL_LOG_LEVEL:-info}
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "--fail", "http://localhost:3100/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
networks:
|
||||
- flashpoint-network
|
||||
|
||||
networks:
|
||||
flashpoint-network:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
backend-db:
|
||||
driver: local
|
||||
+63
-21
@@ -1,25 +1,27 @@
|
||||
# =============================================================================
|
||||
# Flashpoint Web - Production Docker Compose
|
||||
# Dotcom Arcade
|
||||
# =============================================================================
|
||||
# Single image serving both the API and the web UI.
|
||||
# Uses a pre-built image from the container registry.
|
||||
# The app serves the UI and API. With the "streaming" profile, a session
|
||||
# manager also streams games that can't run in a browser (Java, Shockwave,
|
||||
# Flash Player, Windows games), one container per player.
|
||||
#
|
||||
# Usage:
|
||||
# 1. Copy .env.example to .env and configure required variables
|
||||
# 2. docker compose up -d
|
||||
# 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
|
||||
#
|
||||
# For local development with image building, use:
|
||||
# docker compose -f docker-compose.dev.yml up -d --build
|
||||
#
|
||||
# User permissions:
|
||||
# Set PUID/PGID to match your host user (run 'id -u' and 'id -g' to find)
|
||||
# This ensures bind-mounted volumes have correct ownership
|
||||
# Set PUID/PGID to your host user (`id -u`, `id -g`) so bind-mounted volumes
|
||||
# get the right ownership.
|
||||
# =============================================================================
|
||||
|
||||
services:
|
||||
flashpoint-web:
|
||||
image: darkraise/flashpoint-web:${IMAGE_TAG:-latest}
|
||||
container_name: flashpoint-web
|
||||
app:
|
||||
build: .
|
||||
image: ${IMAGE_PREFIX:-}dotcom-arcade:${IMAGE_TAG:-latest}
|
||||
container_name: dotcom-arcade
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
# Serves both the UI and the API (/api, /game-proxy, /game-zip) on one port
|
||||
@@ -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 both are set)
|
||||
- STREAMING_API_URL=${STREAMING_API_URL:-}
|
||||
# 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:-}
|
||||
@@ -67,7 +71,7 @@ services:
|
||||
- OTEL_ENABLED=${OTEL_ENABLED:-false}
|
||||
- OTEL_EXPORTER_OTLP_ENDPOINT=${OTEL_EXPORTER_OTLP_ENDPOINT:-}
|
||||
- OTEL_API_KEY=${OTEL_API_KEY:-}
|
||||
- OTEL_SERVICE_NAME=${OTEL_SERVICE_NAME:-flashpoint-web}
|
||||
- OTEL_SERVICE_NAME=${OTEL_SERVICE_NAME:-dotcom-arcade}
|
||||
- OTEL_TRACES_ENABLED=${OTEL_TRACES_ENABLED:-true}
|
||||
- OTEL_METRICS_ENABLED=${OTEL_METRICS_ENABLED:-true}
|
||||
- OTEL_METRICS_EXPORT_INTERVAL=${OTEL_METRICS_EXPORT_INTERVAL:-60000}
|
||||
@@ -94,8 +98,46 @@ services:
|
||||
cpus: "0.5"
|
||||
memory: 512M
|
||||
networks:
|
||||
- flashpoint-network
|
||||
- arcade
|
||||
|
||||
# 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: ${IMAGE_PREFIX:-}dotcom-arcade-session-manager:${IMAGE_TAG:-latest}
|
||||
container_name: dotcom-arcade-session-manager
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
# Starts and removes streamer containers. Docker socket access is
|
||||
# effectively root on the host.
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- ${FLASHPOINT_HOST_PATH:?FLASHPOINT_HOST_PATH is required}:/flashpoint:ro
|
||||
environment:
|
||||
- FLASHPOINT_HOST_PATH
|
||||
# The session manager refuses to start without it
|
||||
- API_TOKEN=${STREAMING_API_TOKEN:-}
|
||||
- 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}
|
||||
- PUID=${PUID:-1000}
|
||||
- PGID=${PGID:-1000}
|
||||
networks:
|
||||
- arcade
|
||||
- streams
|
||||
|
||||
# The image the session manager runs per player; built here, never started by compose
|
||||
streamer:
|
||||
profiles: [streaming]
|
||||
build: streaming/streamer
|
||||
image: ${IMAGE_PREFIX:-}dotcom-arcade-streamer:${IMAGE_TAG:-latest}
|
||||
scale: 0
|
||||
|
||||
networks:
|
||||
flashpoint-network:
|
||||
driver: bridge
|
||||
arcade:
|
||||
name: dotcom-arcade
|
||||
# Streamer containers: reachable only from the session manager, no internet access
|
||||
streams:
|
||||
name: dotcom-arcade-streams
|
||||
internal: true
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Introduction
|
||||
|
||||
Flashpoint Web is a self-hosted web application designed to browse and play
|
||||
Dotcom Arcade is a self-hosted web application designed to browse and play
|
||||
games from the Flashpoint Archive. It provides a modern, user-friendly interface
|
||||
to access thousands of preserved Flash games, HTML5 games, and animations from
|
||||
the early web era.
|
||||
@@ -14,9 +14,9 @@ dedicated to saving games and animations from the web. With Adobe Flash Player's
|
||||
end-of-life in December 2020, millions of Flash games and animations became
|
||||
unplayable. Flashpoint preserves these games and provides tools to play them.
|
||||
|
||||
## What is Flashpoint Web?
|
||||
## What is Dotcom Arcade?
|
||||
|
||||
Flashpoint Web is a **web-based alternative** to the Flashpoint Launcher,
|
||||
Dotcom Arcade is a **web-based alternative** to the Flashpoint Launcher,
|
||||
offering:
|
||||
|
||||
- **Browser-based access** to your local Flashpoint collection
|
||||
@@ -65,7 +65,7 @@ offering:
|
||||
|
||||
## Architecture Highlights
|
||||
|
||||
Flashpoint Web consists of two independent services:
|
||||
Dotcom Arcade consists of two independent services:
|
||||
|
||||
1. **Backend (Port 3100)** - REST API server handling game metadata, user
|
||||
management, authentication, and game file serving
|
||||
@@ -127,7 +127,7 @@ This separation allows:
|
||||
|
||||
## Comparison with Flashpoint Launcher
|
||||
|
||||
| Feature | Flashpoint Launcher | Flashpoint Web |
|
||||
| Feature | Flashpoint Launcher | Dotcom Arcade |
|
||||
| -------------- | ----------------------------- | ------------------------------- |
|
||||
| Platform | Desktop (Windows, Linux, Mac) | Web browser |
|
||||
| Interface | Desktop application | Web application |
|
||||
@@ -141,7 +141,7 @@ This separation allows:
|
||||
|
||||
## License
|
||||
|
||||
Flashpoint Web is open-source software. (Note: Add appropriate license
|
||||
Dotcom Arcade is open-source software. (Note: Add appropriate license
|
||||
information)
|
||||
|
||||
## Credits
|
||||
|
||||
@@ -442,4 +442,4 @@ For questions about the architecture:
|
||||
|
||||
**Last Updated**: 2025-01-18
|
||||
|
||||
**Maintainers**: Flashpoint Web Development Team
|
||||
**Maintainers**: Dotcom Arcade Development Team
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Overview
|
||||
|
||||
Flashpoint Web uses JWT-based authentication with role-based access control
|
||||
Dotcom Arcade uses JWT-based authentication with role-based access control
|
||||
(RBAC).
|
||||
|
||||
## Authentication Architecture
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Overview
|
||||
|
||||
Flashpoint Web uses HTTP-based communication between two services. Frontend
|
||||
Dotcom Arcade uses HTTP-based communication between two services. Frontend
|
||||
communicates with backend via REST API. The backend handles both business logic
|
||||
and game file serving through integrated routes.
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Overview
|
||||
|
||||
Flashpoint Web is a monorepo containing **2 independent services**: frontend (React UI)
|
||||
Dotcom Arcade is a monorepo containing **2 independent services**: frontend (React UI)
|
||||
and backend (Express API with integrated game file serving). Services communicate via HTTP
|
||||
with clean separation of concerns. The backend handles metadata, authentication, business
|
||||
logic, and game file serving with hardened resource management (connection limits, stream
|
||||
@@ -445,7 +445,7 @@ consistent tooling
|
||||
|
||||
## Conclusion
|
||||
|
||||
Flashpoint Web balances simplicity with scalability, maintaining clean separation
|
||||
Dotcom Arcade balances simplicity with scalability, maintaining clean separation
|
||||
of concerns between frontend UI and backend business logic. Game file serving is
|
||||
integrated into the backend, eliminating inter-service HTTP overhead while
|
||||
keeping the codebase maintainable and type-safe. The two-service design is
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Backend Service
|
||||
|
||||
The backend is a REST API built with Express and TypeScript that serves as the
|
||||
central hub for the Flashpoint Web application. It manages game metadata, user
|
||||
central hub for the Dotcom Arcade application. It manages game metadata, user
|
||||
accounts, authentication, and serves game content directly via integrated
|
||||
game-proxying (formerly provided by a separate game-service).
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Backend Architecture
|
||||
|
||||
Architectural patterns and design decisions for the Flashpoint Web backend
|
||||
Architectural patterns and design decisions for the Dotcom Arcade backend
|
||||
service.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Backend Configuration
|
||||
|
||||
This document describes all environment variables and configuration options for
|
||||
the Flashpoint Web backend service.
|
||||
the Dotcom Arcade backend service.
|
||||
|
||||
## Configuration File
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Frontend Architecture
|
||||
|
||||
This document describes the architectural patterns, design decisions, and
|
||||
technical implementation details of the Flashpoint Web frontend.
|
||||
technical implementation details of the Dotcom Arcade frontend.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
@@ -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>
|
||||
} />
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Component Overview
|
||||
|
||||
This document provides an overview of the component architecture and
|
||||
organization in the Flashpoint Web frontend.
|
||||
organization in the Dotcom Arcade frontend.
|
||||
|
||||
## Component Organization
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Layout Components
|
||||
|
||||
This document describes the layout components that form the structure of the
|
||||
Flashpoint Web application.
|
||||
Dotcom Arcade application.
|
||||
|
||||
Public pages use `PortalLayout` instead of `AppShell`; see
|
||||
[Discovery Portal](../../10-features/12-discovery-portal.md). `AppShell` remains
|
||||
@@ -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;
|
||||
};
|
||||
```
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Custom Hooks
|
||||
|
||||
Comprehensive documentation of all custom React hooks in the Flashpoint Web
|
||||
Comprehensive documentation of all custom React hooks in the Dotcom Arcade
|
||||
frontend.
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Zustand Stores
|
||||
|
||||
This document describes the Zustand stores used for client-side state management
|
||||
in the Flashpoint Web frontend.
|
||||
in the Dotcom Arcade frontend.
|
||||
|
||||
## Overview
|
||||
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
# Views and Routing
|
||||
|
||||
Complete documentation of all views, routes, and route protection in the
|
||||
Flashpoint Web frontend.
|
||||
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,6 +1,6 @@
|
||||
# API Reference Documentation
|
||||
|
||||
Complete REST API reference for Flashpoint Web backend service.
|
||||
Complete REST API reference for Dotcom Arcade backend service.
|
||||
|
||||
## Base URL
|
||||
|
||||
@@ -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)
|
||||
@@ -227,5 +226,4 @@ Default: 100 requests per 15 minutes. Headers: `X-RateLimit-Limit`,
|
||||
|
||||
## Support
|
||||
|
||||
For issues: [GitHub Issues](https://github.com/darkraise/flashpoint-web/issues)
|
||||
Documentation: [docs/README.md](../README.md)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -111,7 +111,7 @@ Clears permission cache entries.
|
||||
| auth | userRegistrationEnabled | boolean | true | - |
|
||||
| auth | maxLoginAttempts | number | 5 | 1-20 |
|
||||
| auth | lockoutDurationMinutes | number | 15 | 1-1440 |
|
||||
| app | siteName | string | Flashpoint Web | maxLength: 100 |
|
||||
| app | siteName | string | Dotcom Arcade | maxLength: 100 |
|
||||
| app | maintenanceMode | boolean | false | - |
|
||||
| app | defaultTheme | string | dark | light\|dark\|system |
|
||||
| app | defaultPrimaryColor | string | blue | blue\|green\|red\|purple\|orange\|pink |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Design System Overview
|
||||
|
||||
The Flashpoint Web design system provides a consistent, accessible, and
|
||||
The Dotcom Arcade design system provides a consistent, accessible, and
|
||||
beautiful UI framework built on Tailwind CSS, Shadcn UI components, and Lucide
|
||||
icons.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Design Patterns
|
||||
|
||||
Common UI patterns and best practices for Flashpoint Web.
|
||||
Common UI patterns and best practices for Dotcom Arcade.
|
||||
|
||||
## Layout Patterns
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Development Documentation
|
||||
|
||||
This directory contains comprehensive documentation for developers working on
|
||||
the Flashpoint Web application.
|
||||
the Dotcom Arcade application.
|
||||
|
||||
## Contents
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Coding Standards
|
||||
|
||||
Coding standards and best practices for Flashpoint Web development.
|
||||
Coding standards and best practices for Dotcom Arcade development.
|
||||
|
||||
## TypeScript Standards
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Commands Reference
|
||||
|
||||
All npm commands available in Flashpoint Web monorepo.
|
||||
All npm commands available in Dotcom Arcade monorepo.
|
||||
|
||||
## Monorepo Commands
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Common Pitfalls
|
||||
|
||||
Known issues and solutions for Flashpoint Web development.
|
||||
Known issues and solutions for Dotcom Arcade development.
|
||||
|
||||
## Environment Setup Issues
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Debugging Guide
|
||||
|
||||
Debugging Flashpoint Web across all services.
|
||||
Debugging Dotcom Arcade across all services.
|
||||
|
||||
## Development Tools
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Project Structure
|
||||
|
||||
Flashpoint Web codebase organization.
|
||||
Dotcom Arcade codebase organization.
|
||||
|
||||
## Repository Overview
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Development Setup Guide
|
||||
|
||||
Complete guide to setting up Flashpoint Web development environment.
|
||||
Complete guide to setting up Dotcom Arcade development environment.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@@ -38,8 +38,8 @@ git --version # Verify
|
||||
### 1. Clone Repository
|
||||
|
||||
```bash
|
||||
git clone https://github.com/darkraise/flashpoint-web.git
|
||||
cd flashpoint-web
|
||||
git clone <repository-url> dotcom-arcade
|
||||
cd dotcom-arcade
|
||||
```
|
||||
|
||||
### 2. Install Dependencies
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Testing Guide
|
||||
|
||||
Testing practices for Flashpoint Web.
|
||||
Testing practices for Dotcom Arcade.
|
||||
|
||||
## Testing Philosophy
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Deployment Documentation
|
||||
|
||||
Complete deployment guides for Flashpoint Web in development, staging, and
|
||||
Complete deployment guides for Dotcom Arcade in development, staging, and
|
||||
production environments.
|
||||
|
||||
## Quick Links
|
||||
|
||||
@@ -1,242 +1,108 @@
|
||||
# Docker Deployment
|
||||
|
||||
Deploy Flashpoint Web using Docker Compose.
|
||||
Deploy Dotcom Arcade with Docker Compose. The [README](../../README.md#getting-started)
|
||||
walks through a first install; this page covers the details.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Docker Engine 20.10+
|
||||
- Docker Compose 2.0+
|
||||
- 4GB+ RAM, 50GB+ disk space
|
||||
- Flashpoint installation with:
|
||||
- `Data/flashpoint.sqlite`
|
||||
- `Data/Games/`
|
||||
- `Data/Images/`
|
||||
- `Legacy/htdocs/`
|
||||
- **Docker:** Engine 24+ with the Compose plugin.
|
||||
- **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
|
||||
Flashpoint Ultimate torrent.
|
||||
|
||||
## Quick Start
|
||||
## Services
|
||||
|
||||
1. Set Flashpoint path:
|
||||
| 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 and serves their streams, through the app |
|
||||
| `streamer` | `dotcom-arcade-streamer` | `streaming` | Built only; the session manager runs one per player |
|
||||
|
||||
```bash
|
||||
# Linux/Mac
|
||||
export FLASHPOINT_HOST_PATH=/path/to/flashpoint
|
||||
All three are built from this repository by `docker compose up -d --build`.
|
||||
|
||||
# Windows PowerShell
|
||||
$env:FLASHPOINT_HOST_PATH="D:\Flashpoint"
|
||||
### 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
|
||||
```
|
||||
|
||||
2. Start services:
|
||||
Then run `docker compose pull && docker compose up -d`.
|
||||
|
||||
```bash
|
||||
docker-compose up -d --build
|
||||
### 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 |
|
||||
| ----------------------- | ------------------ | -------------------------------------------------------------------- |
|
||||
| `FLASHPOINT_HOST_PATH` | `/data/flashpoint` | The Flashpoint install. Read-write: downloaded game data and download state are recorded there |
|
||||
| `DATA_PATH` (`./data`) | `/app/data` | `user.db` and the installed Ruffle. Back this up |
|
||||
| `LOGS_PATH` (`./logs`) | `/app/logs` | Log files |
|
||||
|
||||
The session manager mounts the Flashpoint install read-only and the Docker
|
||||
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:** 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 pass WebSocket upgrades (see
|
||||
[Streaming](../10-features/13-streaming.md#reverse-proxy)).
|
||||
|
||||
## Health checks
|
||||
|
||||
The app reports `starting` at `/health` until its database copy and cache
|
||||
pre-warm finish, which can take minutes on network storage. The compose file
|
||||
allows 5 minutes before counting failures.
|
||||
|
||||
```sh
|
||||
docker compose ps
|
||||
docker inspect dotcom-arcade --format '{{json .State.Health}}'
|
||||
```
|
||||
|
||||
3. Access:
|
||||
## Logs
|
||||
|
||||
- Frontend: http://localhost
|
||||
- Backend API: http://localhost:3100
|
||||
|
||||
## Container Architecture
|
||||
|
||||
| Service | Image | Port | Purpose |
|
||||
| -------- | -------------------------- | ---- | --------------------------------- |
|
||||
| backend | flashpoint-backend:latest | 3100 | REST API (includes game service) |
|
||||
| frontend | flashpoint-frontend:latest | 80 | Web UI (Nginx) |
|
||||
|
||||
**Resource Recommendations:**
|
||||
|
||||
- Backend: 2 CPUs, 2GB RAM (includes game service)
|
||||
- Frontend: 0.5 CPU, 256MB RAM
|
||||
|
||||
## Volume Configuration
|
||||
|
||||
**Flashpoint Data (read-write):**
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- ${FLASHPOINT_HOST_PATH}:/data/flashpoint
|
||||
```
|
||||
|
||||
**User Database (persistent):**
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- user-db:/app/data
|
||||
```
|
||||
|
||||
## Network Configuration
|
||||
|
||||
**Port Mappings:**
|
||||
|
||||
| Service | Internal | Host | Notes |
|
||||
| -------- | -------- | ---- | ----- |
|
||||
| Frontend | 8080 | 80 | Nginx |
|
||||
| Backend | 3100 | 3100 | API |
|
||||
|
||||
**Custom ports** via environment variables:
|
||||
|
||||
```bash
|
||||
export WEB_PORT=8080
|
||||
export API_PORT=8001
|
||||
export FLASHPOINT_HOST_PATH=/path/to/flashpoint
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
## Health Checks
|
||||
|
||||
All services include health checks:
|
||||
|
||||
```yaml
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
'CMD',
|
||||
'wget',
|
||||
'--quiet',
|
||||
'--tries=1',
|
||||
'--spider',
|
||||
'http://localhost:3100/api/health',
|
||||
]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
```
|
||||
|
||||
Check status:
|
||||
|
||||
```bash
|
||||
docker-compose ps
|
||||
```
|
||||
|
||||
## Nginx Configuration
|
||||
|
||||
The frontend uses a template-based configuration with environment variable
|
||||
substitution.
|
||||
|
||||
**Key files:**
|
||||
|
||||
- `frontend/.nginx/nginx.conf` - Global settings (worker processes, logging)
|
||||
- `frontend/.nginx/default.conf.template` - Server block with `${BACKEND_HOST}`,
|
||||
`${BACKEND_PORT}` variables
|
||||
|
||||
**At runtime:**
|
||||
|
||||
1. Docker entrypoint runs `envsubst` to substitute variables
|
||||
2. Processed config written to `/etc/nginx/conf.d/default.conf`
|
||||
3. Nginx starts with final configuration
|
||||
|
||||
**Features:**
|
||||
|
||||
- SPA routing (serves index.html for non-static routes)
|
||||
- Gzip compression
|
||||
- Security headers (HSTS, CSP, X-Frame-Options)
|
||||
- Static asset caching (1 year for versioned assets, no-cache for HTML)
|
||||
|
||||
## Running Containers
|
||||
|
||||
Start all services:
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
Stop services:
|
||||
|
||||
```bash
|
||||
docker-compose down
|
||||
```
|
||||
|
||||
Restart with rebuild:
|
||||
|
||||
```bash
|
||||
docker-compose up -d --build --force-recreate
|
||||
```
|
||||
|
||||
## Monitoring and Logs
|
||||
|
||||
View logs:
|
||||
|
||||
```bash
|
||||
docker-compose logs -f backend
|
||||
docker-compose logs -f frontend
|
||||
```
|
||||
|
||||
Container stats:
|
||||
|
||||
```bash
|
||||
docker stats
|
||||
```
|
||||
|
||||
Execute command in container:
|
||||
|
||||
```bash
|
||||
docker-compose exec backend node -v
|
||||
docker-compose exec backend sh # Open shell
|
||||
```sh
|
||||
docker compose logs -f app
|
||||
docker compose logs -f session-manager
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Port already in use:**
|
||||
- **"Source database not found":** `FLASHPOINT_HOST_PATH` doesn't point at a
|
||||
folder with `Data/flashpoint.sqlite`. Check with
|
||||
`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 pass `--infinity`.
|
||||
- **Permission errors on `./data` or `./logs`:** set `PUID`/`PGID` to the owner
|
||||
of those folders.
|
||||
|
||||
```bash
|
||||
# Linux/Mac
|
||||
lsof -i :3100
|
||||
|
||||
# Windows
|
||||
netstat -ano | findstr :3100
|
||||
```
|
||||
|
||||
**Volume mount failed:**
|
||||
|
||||
- Verify `FLASHPOINT_HOST_PATH` is set
|
||||
- Check path exists and is readable
|
||||
- On Windows, ensure Docker has drive access
|
||||
|
||||
**Health check failing:**
|
||||
|
||||
```bash
|
||||
# Check directly
|
||||
curl http://localhost:3100/api/health
|
||||
|
||||
# From container
|
||||
docker-compose exec backend curl http://localhost:3100/api/health
|
||||
|
||||
# View health details
|
||||
docker inspect flashpoint-backend --format='{{json .State.Health}}' | jq
|
||||
```
|
||||
|
||||
**Database connection issues:**
|
||||
|
||||
```bash
|
||||
# Verify database exists
|
||||
docker-compose exec backend ls -la /data/flashpoint/Data/flashpoint.sqlite
|
||||
|
||||
# Test connection
|
||||
docker-compose exec backend sqlite3 /data/flashpoint/Data/flashpoint.sqlite "SELECT COUNT(*) FROM game;"
|
||||
```
|
||||
|
||||
**Service communication:**
|
||||
|
||||
```bash
|
||||
# Test backend health
|
||||
docker-compose exec backend curl http://localhost:3100/api/health
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. Use named volumes for persistent data
|
||||
2. Set resource limits to prevent resource exhaustion
|
||||
3. Enable health checks for automatic recovery
|
||||
4. Restrict the Flashpoint mount to the single directory the app needs
|
||||
5. Run as non-root user
|
||||
6. Keep images updated with security patches
|
||||
7. Monitor logs regularly
|
||||
8. Backup user database before updates
|
||||
9. Use .env file for configuration
|
||||
10. Tag images with version numbers in production
|
||||
|
||||
## Next Steps
|
||||
## Next steps
|
||||
|
||||
- [Environment Variables](./environment-variables.md)
|
||||
- [Security Considerations](./security-considerations.md)
|
||||
- [Upgrade Guide](./upgrade-guide.md)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Environment Variables
|
||||
|
||||
Configuration reference for all Flashpoint Web services.
|
||||
Configuration reference for all Dotcom Arcade services.
|
||||
|
||||
## Backend Variables
|
||||
|
||||
@@ -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,23 +115,30 @@ 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 |
|
||||
|
||||
**Streaming:**
|
||||
|
||||
Games that can't run in a browser, such as Java applets, can be streamed from a
|
||||
server-side container by a separate session manager (flashpoint-ultimate-server).
|
||||
The backend asks it which games are streamable and starts sessions for players.
|
||||
Browsers reach each stream at `/stream/<id>/` on this site, so the reverse proxy
|
||||
in front of flashpoint-web must route `/stream/` to the session manager,
|
||||
including WebSocket upgrades. Saves for streamed games are kept in each
|
||||
browser's IndexedDB.
|
||||
**Images and updates:**
|
||||
|
||||
| Variable | Default | Description |
|
||||
| --------------------- | ------- | ---------------------------------------------------------------------- |
|
||||
| `STREAMING_API_URL` | - | Session manager's internal API, e.g. `http://flashpoint-session-manager:8080` |
|
||||
| `STREAMING_API_TOKEN` | - | Token the session manager expects. Streaming is off unless both are set |
|
||||
| ---------------- | -------- | ------------------------------------------------------------------------------------------------- |
|
||||
| `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
|
||||
streamed by the session manager under `streaming/`. It runs under the
|
||||
`streaming` compose profile. See [Streaming](../10-features/13-streaming.md).
|
||||
|
||||
| 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:**
|
||||
|
||||
See also [Proxy Sign-In](../10-features/14-proxy-sign-in.md).
|
||||
|
||||
When a reverse proxy such as Authentik (forward auth) authenticates users in
|
||||
front of flashpoint-web, it can pass the signed-in username in a header so
|
||||
users don't log in twice. The backend believes the header only on connections
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Health Checks Configuration
|
||||
|
||||
Docker container health checks for Flashpoint Web services.
|
||||
Docker container health checks for Dotcom Arcade services.
|
||||
|
||||
## Health Check Configuration
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Security Considerations
|
||||
|
||||
Production deployment security checklist for Flashpoint Web.
|
||||
Production deployment security checklist for Dotcom Arcade.
|
||||
|
||||
## Pre-Production Checklist
|
||||
|
||||
|
||||
@@ -1,244 +1,36 @@
|
||||
# Upgrade Guide and Release Notes
|
||||
# Upgrade Guide
|
||||
|
||||
How to move an existing deployment to a newer version, and what changed in each
|
||||
release. Versions follow the git tags published on Docker Hub.
|
||||
## Updating
|
||||
|
||||
> **Upgrading from 1.0.31 or earlier?** The deployment model changed in 1.0.32:
|
||||
> the separate backend and frontend containers were replaced by a single image.
|
||||
> Read [Upgrading from 1.0.31](#upgrading-from-1031) before pulling.
|
||||
|
||||
---
|
||||
|
||||
## Upgrading from 1.0.31
|
||||
|
||||
### What changed
|
||||
|
||||
Up to 1.0.31 a deployment ran two containers: `darkraise/flashpoint-backend`
|
||||
(Node/Express, port 3100) and `darkraise/flashpoint-frontend` (nginx serving the
|
||||
built React app on port 8080, proxying `/api` to the backend).
|
||||
|
||||
From 1.0.32 there is one image, `darkraise/flashpoint-web`. The Express backend
|
||||
serves the built React UI and its SPA fallback alongside the API and game
|
||||
content, so the whole app lives on one origin and one port.
|
||||
|
||||
```
|
||||
Before (<= 1.0.31) After (>= 1.0.32)
|
||||
flashpoint-frontend :80 -> nginx flashpoint-web :80 -> :3100
|
||||
flashpoint-backend :3100 -> Express / built React UI
|
||||
/api, /game-proxy, /game-zip
|
||||
```sh
|
||||
git pull
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
The old `flashpoint-backend` and `flashpoint-frontend` images are no longer
|
||||
built or published. 1.0.31 is the last version available under those names.
|
||||
Database migrations for `user.db` run automatically when the app starts. Back
|
||||
up `DATA_PATH` (default `./data`) first if you want a way back.
|
||||
|
||||
### Migration steps
|
||||
## Moving from flashpoint-web
|
||||
|
||||
1. **Stop and remove the old stack.** From the directory holding your existing
|
||||
compose file:
|
||||
Dotcom Arcade keeps flashpoint-web's data format, so an existing install can
|
||||
switch without losing accounts, playlists, favorites or play history.
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
```
|
||||
1. Stop the old stack in its own directory with `docker compose down`. If you
|
||||
ran flashpoint-ultimate-server's session manager, stop that too.
|
||||
2. Copy the old `.env` next to Dotcom Arcade's `docker-compose.yml`. Then
|
||||
compare it with `.env.example` for the new streaming settings.
|
||||
3. Point `DATA_PATH` at the old data directory, which holds `user.db` and the
|
||||
installed Ruffle.
|
||||
4. Start with `docker compose up -d --build`.
|
||||
|
||||
This removes the `flashpoint-backend` and `flashpoint-frontend` containers.
|
||||
Named volumes and bind mounts are kept.
|
||||
What changes:
|
||||
|
||||
2. **Replace `docker-compose.yml`** with the single-service version from the
|
||||
[README](../../README.md#2-create-docker-composeyml). The service is now named
|
||||
`flashpoint-web`, maps `${WEB_PORT:-80}` to container port `3100`, and has no
|
||||
`frontend` service and no `depends_on`.
|
||||
|
||||
3. **Update `.env`** — see the variable tables below. At minimum, delete
|
||||
`API_PORT`, `BACKEND_HOST`, and `BACKEND_PORT`; they are no longer read by
|
||||
anything.
|
||||
|
||||
4. **Check the Flashpoint mount.** As of 1.0.36 the shipped compose mounts it
|
||||
read-write (no `:ro`). Downloading games, recording downloaded state in
|
||||
`flashpoint.sqlite`, and metadata sync all write into that directory. Leaving
|
||||
`:ro` in place still allows browsing and playing already-downloaded games, but
|
||||
the Downloaded page will stay empty and the reconciler logs a warning on every
|
||||
boot.
|
||||
|
||||
5. **Update your reverse proxy, if you have one.** Configurations that defined
|
||||
two upstreams — the UI on the web port and the API on port 3100 — must be
|
||||
collapsed into a single upstream on `WEB_PORT`. Any rule routing `/api` to
|
||||
port 3100 will break, because nothing listens there on the host anymore.
|
||||
Firewall rules opening port 3100 can be removed.
|
||||
|
||||
6. **Pull and start.**
|
||||
|
||||
```bash
|
||||
docker compose pull && docker compose up -d
|
||||
```
|
||||
|
||||
### What is preserved
|
||||
|
||||
Application data is untouched by the migration. `user.db` (accounts, playlists,
|
||||
favourites, play history, settings) lives in the `DATA_PATH` bind mount at
|
||||
`/app/data` and is used unchanged by the new image. Log and Flashpoint mounts
|
||||
keep their paths. No database migration is required beyond the ones the backend
|
||||
applies on startup.
|
||||
|
||||
### Environment variable changes
|
||||
|
||||
**Removed** — delete these from `.env`; they have no effect:
|
||||
|
||||
| Variable | Replacement |
|
||||
| -------------- | ---------------------------------------------------- |
|
||||
| `API_PORT` | `WEB_PORT` — one port now serves both the UI and API |
|
||||
| `BACKEND_HOST` | none — there is no cross-container proxy |
|
||||
| `BACKEND_PORT` | none — same reason |
|
||||
|
||||
`PORT` and `HOST` were documented as configurable before 1.0.32 but never were:
|
||||
the backend binds `0.0.0.0:3100` unconditionally, and the Docker `EXPOSE` and
|
||||
healthcheck depend on that. The docs were corrected rather than the code.
|
||||
|
||||
**Added:**
|
||||
|
||||
| Variable | Default | Since | Description |
|
||||
| ------------------------------ | -------------------------------- | ------ | --------------------------------------------------------------- |
|
||||
| `SERVE_FRONTEND` | `true` in production, else false | 1.0.32 | Serve the built UI and SPA fallback from the backend |
|
||||
| `FRONTEND_DIST_PATH` | `/app/frontend/dist` in image | 1.0.32 | Where the built UI lives; rarely needs overriding |
|
||||
| `FLASHPOINT_GAMES_PATH` | `<FLASHPOINT_PATH>/Data/Games` | 1.0.32 | Override the game ZIP directory |
|
||||
| `OTEL_METRICS_EXPORT_INTERVAL` | `60000` | 1.0.32 | Metrics export interval in milliseconds |
|
||||
| `OTEL_LOG_LEVEL` | `info` | 1.0.32 | OpenTelemetry SDK log level |
|
||||
| `COOKIE_SECURE` | `auto` | 1.0.34 | Secure flag on auth cookies: `auto`, `true`, `false` |
|
||||
| `UV_THREADPOOL_SIZE` | `16` | 1.0.36 | File I/O threads; Node's own default of 4 throttles image reads |
|
||||
| `DB_WORKER_COUNT` | `4` | 1.0.36 | Read-only SQLite query worker threads (1–16) |
|
||||
|
||||
`UV_THREADPOOL_SIZE=16` is baked into the image, so it applies whether or not
|
||||
your compose file passes it.
|
||||
|
||||
**Changed:**
|
||||
|
||||
| Variable | Change |
|
||||
| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `DOMAIN` | Now accepts a comma-separated list of origins. Same-origin requests are always allowed, so a standard single-image install needs no value at all. Default ports are normalized: `http://host:80` and `http://host` are treated as the same origin. |
|
||||
| `IMAGE_TAG` | Selects the tag of `darkraise/flashpoint-web` instead of the two old images. |
|
||||
| `OTEL_SERVICE_NAME` | Default changed from `flashpoint-web-backend` to `flashpoint-web`. Update dashboards and alert queries that filter on the old name. |
|
||||
| `LOGS_PATH` | Dev compose mounts it at `/app/logs` directly; it previously mounted `${LOGS_PATH}/backend`. |
|
||||
|
||||
**Newly passed through compose** (these already existed in the backend, but the
|
||||
shipped compose files now forward them with documented defaults):
|
||||
`JWT_EXPIRES_IN`, `BCRYPT_SALT_ROUNDS`, `RATE_LIMIT_WINDOW_MS`,
|
||||
`RATE_LIMIT_MAX_REQUESTS`, `HOME_RECENT_HOURS`, `ENABLE_CGI`.
|
||||
|
||||
`FLASHPOINT_PATH=/data/flashpoint` is now set explicitly in the dev compose file.
|
||||
The backend's fallback only points there when `NODE_ENV=production`, so a
|
||||
development container previously looked at the wrong path.
|
||||
|
||||
### Rolling back
|
||||
|
||||
Pin the old images (`darkraise/flashpoint-backend:1.0.31` and
|
||||
`darkraise/flashpoint-frontend:1.0.31`) and restore the two-service compose file
|
||||
and the `.env` entries removed in step 3. Application data written by 1.0.32+ is
|
||||
readable by 1.0.31. Downloaded state is recorded in Flashpoint's own
|
||||
`presentOnDisk` and `path` columns, which the Launcher already understands;
|
||||
1.0.31 simply does not surface it.
|
||||
|
||||
---
|
||||
|
||||
## [1.0.36] — 2026-08-12
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Flashpoint directory is mounted read-write by default.** The shipped compose
|
||||
files mounted it `:ro`, which silently failed every write the app makes:
|
||||
downloaded-state flags in `flashpoint.sqlite`, downloaded game data under
|
||||
`Data/Games`, and metadata sync. If you copied an earlier compose file, drop
|
||||
the `:ro` suffix.
|
||||
- Image preloads are cancelled when a request is abandoned, so scrolling a large
|
||||
library no longer queues work for images that left the viewport.
|
||||
|
||||
### Added
|
||||
|
||||
- A metadata sync can optionally download game images afterwards.
|
||||
|
||||
### Performance
|
||||
|
||||
- Heavy read-only queries run in SQLite worker threads (`DB_WORKER_COUNT`,
|
||||
default 4). `better-sqlite3` is synchronous, and a filter-options query over a
|
||||
slow mount was measured blocking the entire server for 167 seconds — `/health`
|
||||
timed out along with everything else.
|
||||
- `UV_THREADPOOL_SIZE` is set to 16 in the image. libuv's default of 4 throttled
|
||||
every image and ZIP read in the process, which dominates on network storage.
|
||||
|
||||
## [1.0.35] — 2026-08-12
|
||||
|
||||
### Added
|
||||
|
||||
- Admins can choose the metadata source used for syncs.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Ruffle installs in the background and into the directory that is actually
|
||||
served (`frontend/dist/ruffle`). Startup previously awaited the download, so
|
||||
the port stayed closed for minutes on slow storage — indefinitely if the
|
||||
download stalled — and a fresh container re-downloaded the Ruffle it already
|
||||
shipped.
|
||||
- Locally served images are cached and no longer block on `stat`.
|
||||
- `npm test` runs the backend suite once instead of entering watch mode.
|
||||
|
||||
## [1.0.34] — 2026-08-11
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Auth cookies are marked `Secure` per connection, not per `NODE_ENV`.**
|
||||
Production builds previously always set `Secure`, and browsers discard such a
|
||||
cookie when it arrives over plain HTTP — logging LAN users straight back out
|
||||
after login. The new default, `COOKIE_SECURE=auto`, decides per request based
|
||||
on TLS (reading `X-Forwarded-Proto` when behind a proxy), so one deployment can
|
||||
serve HTTP on a LAN and HTTPS through a proxy at once. Set `COOKIE_SECURE=true`
|
||||
if your TLS-terminating proxy does not send that header.
|
||||
|
||||
### Documentation
|
||||
|
||||
- README carries a minimal working `.env` example and refreshed sections.
|
||||
|
||||
## [1.0.33] — 2026-08-11
|
||||
|
||||
### Fixed
|
||||
|
||||
- **CORS origins are normalized and same-origin requests are always allowed.**
|
||||
With the UI and API on one origin, most deployments no longer need `DOMAIN` at
|
||||
all. `DOMAIN` also accepts a comma-separated list, and explicit default ports
|
||||
(`:80`, `:443`) match the port-less origin a browser actually sends.
|
||||
- The dev compose file passes `FLASHPOINT_PATH=/data/flashpoint` explicitly.
|
||||
|
||||
### Performance
|
||||
|
||||
- Downloaded filter options are cached indefinitely.
|
||||
|
||||
## [1.0.32] — 2026-08-11
|
||||
|
||||
### Breaking
|
||||
|
||||
- **Single image deployment.** The backend and frontend images were collapsed
|
||||
into `darkraise/flashpoint-web`, which serves the API, game content, and the
|
||||
built UI from one Express process on port 3100. The `frontend` service, its
|
||||
nginx config and entrypoint, and the per-service Dockerfiles are gone; the
|
||||
release workflow builds one image instead of a two-service matrix. See
|
||||
[Upgrading from 1.0.31](#upgrading-from-1031).
|
||||
- `API_PORT`, `BACKEND_HOST`, and `BACKEND_PORT` were removed. Map a host port
|
||||
with `WEB_PORT` instead.
|
||||
|
||||
### Added
|
||||
|
||||
- Downloaded games page, filter, and settings: browse games whose data is already
|
||||
on disk, with the filter encoded in the URL and downloaded state reconciled
|
||||
from disk at startup.
|
||||
- 5-star game rating system.
|
||||
- Setting to toggle the "I'm Feeling Lucky" button.
|
||||
|
||||
### Security
|
||||
|
||||
- The SPA-oriented CSP was ported from nginx into Helmet and hardened, dropping
|
||||
`unsafe-inline` and `unsafe-eval`. The strict API-only policy still applies
|
||||
when `SERVE_FRONTEND` is off.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Guest browsing survives auth-only 401s instead of falling into a redirect loop
|
||||
(thanks @MaxfieldKassel).
|
||||
- Invalid `SQLITE_CACHE_SIZE` and `SQLITE_MMAP_SIZE` values fall back to their
|
||||
defaults instead of becoming `NaN`.
|
||||
- **Container and network names.** The app container is now `dotcom-arcade`,
|
||||
and the network is `dotcom-arcade`. If your reverse proxy reached the old
|
||||
`flashpoint-web` container by name, update it.
|
||||
- **Site name.** If your site name was still the default "Flashpoint Web", it
|
||||
becomes "Dotcom Arcade". A name you set yourself is kept.
|
||||
- **Streaming config.** Streaming is now part of the same compose file. Replace
|
||||
the separate `API_TOKEN` and `STREAMING_API_URL` with `STREAMING_API_TOKEN`
|
||||
and `COMPOSE_PROFILES=streaming` (see [Streaming](../10-features/13-streaming.md)).
|
||||
The `/stream/` proxy route is unchanged.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Overview
|
||||
|
||||
Flashpoint Web provides three systems for organizing games:
|
||||
Dotcom Arcade provides three systems for organizing games:
|
||||
|
||||
1. **Flashpoint Playlists** - JSON files compatible with Flashpoint Launcher
|
||||
2. **User Playlists** - Database-driven per-user playlists with sharing
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
## Overview
|
||||
|
||||
System Settings provides flexible, centralized configuration management for
|
||||
Flashpoint Web. Settings are organized into categories, validated using JSON
|
||||
Dotcom Arcade. Settings are organized into categories, validated using JSON
|
||||
schemas, cached for performance, and accessible through web UI and REST API.
|
||||
|
||||
## Settings Categories
|
||||
@@ -98,7 +98,7 @@ to `window.location.origin`.
|
||||
|
||||
#### Update Tab
|
||||
|
||||
- **Flashpoint Web** - Compares the running version against the latest GitHub
|
||||
- **Dotcom Arcade** - Compares the running version against the latest GitHub
|
||||
release and shows the changelog and upgrade command. Notify only: it never
|
||||
pulls or restarts anything. A build with no release version — a source
|
||||
checkout, or an image built without the `VERSION` build arg — reports that
|
||||
@@ -184,7 +184,7 @@ All settings use JSON Schema validation:
|
||||
|
||||
**App Defaults:**
|
||||
|
||||
- siteName: "Flashpoint Web"
|
||||
- siteName: "Dotcom Arcade"
|
||||
- maintenanceMode: false
|
||||
- defaultTheme: "dark"
|
||||
- defaultPrimaryColor: "blue"
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
## Overview
|
||||
|
||||
Date & Time Formatting provides customizable date and time display formats
|
||||
throughout Flashpoint Web. Users can choose from multiple format options that
|
||||
throughout Dotcom Arcade. Users can choose from multiple format options that
|
||||
apply globally to all date and time displays, ensuring consistent and localized
|
||||
presentation.
|
||||
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
# Streaming
|
||||
|
||||
## Overview
|
||||
|
||||
Some Flashpoint games can't run in a browser at all: Java applets, Shockwave
|
||||
and Director movies, and Flash games that need the real Flash Player. Dotcom
|
||||
Arcade runs each one natively on the server and streams it into the game page.
|
||||
Every player gets their own container, which is removed when they leave.
|
||||
|
||||
Streaming is optional and off by default. Without it, those games' pages say
|
||||
they need the Flashpoint Launcher.
|
||||
|
||||
## How it works
|
||||
|
||||
- **The app** (`backend/`) asks the session manager whether each game can be
|
||||
streamed. It then offers Play, plus the Ruffle / original Flash Player switch
|
||||
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>/`. 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
|
||||
the game: a Director stage, a SWF's stage, or a page's applet or largest
|
||||
embed.
|
||||
|
||||
A session is removed if nothing connects within 2 minutes, or 30 seconds after
|
||||
the player's last connection closes. At most `MAX_SESSIONS` run at once. Games
|
||||
save to files inside the container; those are sent back to the player's browser
|
||||
as they change, and restored at the start of the next session (see
|
||||
[Game Saves](./15-game-saves.md)).
|
||||
|
||||
Implementation details are in [streaming/README.md](../../streaming/README.md)
|
||||
and [streaming/streamer/README.md](../../streaming/streamer/README.md).
|
||||
|
||||
## Requirements
|
||||
|
||||
- **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. 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 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
|
||||
|
||||
Add to `.env`:
|
||||
|
||||
```sh
|
||||
COMPOSE_PROFILES=streaming
|
||||
STREAMING_API_TOKEN=<output of: openssl rand -hex 32>
|
||||
```
|
||||
|
||||
Then run `docker compose up -d --build`. This builds the session manager and
|
||||
streamer images and starts the session manager. The app reaches the session
|
||||
manager's internal API at `http://session-manager:8080`, which the compose file
|
||||
sets up.
|
||||
|
||||
| Variable | Default | Description |
|
||||
| --------------------- | ----------------------------- | ----------------------------------------------------------------- |
|
||||
| `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 |
|
||||
| `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
|
||||
|
||||
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 / {
|
||||
proxy_pass http://127.0.0.1:80;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 1h;
|
||||
}
|
||||
```
|
||||
|
||||
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. 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
|
||||
|
||||
- **No "Original Flash Player" button, or "needs the Flashpoint Launcher" on
|
||||
Java and Shockwave games.** The session manager isn't running, the tokens
|
||||
don't match, or it can't resolve the game's launcher; check that Infinity's
|
||||
software is installed. Ask it directly from the app's container:
|
||||
|
||||
```sh
|
||||
docker compose exec app sh -c 'curl -s -o /dev/null -w "%{http_code}\n" \
|
||||
-H "Authorization: Bearer $STREAMING_API_TOKEN" \
|
||||
http://session-manager:8080/games/<game id>'
|
||||
```
|
||||
|
||||
`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 your proxy passes WebSocket upgrades.
|
||||
@@ -0,0 +1,60 @@
|
||||
# Proxy Sign-In
|
||||
|
||||
## Overview
|
||||
|
||||
If an authenticating reverse proxy sits in front of Dotcom Arcade, such as
|
||||
Authentik, Authelia or oauth2-proxy, the app can sign users in as whoever the
|
||||
proxy says they are, so they don't log in twice. The proxy handles the login,
|
||||
including any OAuth or OpenID Connect provider behind it, and passes the
|
||||
username along in a header.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Variable | Default | Description |
|
||||
| ------------------------- | ------- | ------------------------------------------------------------------------ |
|
||||
| `TRUSTED_HEADER_USERNAME` | - | Header holding the username |
|
||||
| `TRUSTED_HEADER_EMAIL` | - | Optional header holding the email used for new accounts |
|
||||
| `TRUSTED_PROXIES` | - | Comma-separated proxy addresses or CIDR ranges allowed to set the header |
|
||||
|
||||
Proxy sign-in is off unless both `TRUSTED_HEADER_USERNAME` and
|
||||
`TRUSTED_PROXIES` are set.
|
||||
|
||||
The headers each proxy sets:
|
||||
|
||||
| Proxy | Username header | Email header |
|
||||
| ------------------------------------------ | ---------------------- | ----------------------- |
|
||||
| Authentik (forward auth) | `X-authentik-username` | `X-authentik-email` |
|
||||
| Authelia (forward auth) | `Remote-User` | `Remote-Email` |
|
||||
| oauth2-proxy (`--set-xauthrequest`) | `X-Auth-Request-User` | `X-Auth-Request-Email` |
|
||||
|
||||
With nginx forward auth, copy the header from the auth response into the
|
||||
request to the app. Authentik's generated nginx snippet already does this;
|
||||
adding the lines again sends each header twice. For oauth2-proxy:
|
||||
|
||||
```nginx
|
||||
auth_request_set $user $upstream_http_x_auth_request_user;
|
||||
auth_request_set $email $upstream_http_x_auth_request_email;
|
||||
proxy_set_header X-Auth-Request-User $user;
|
||||
proxy_set_header X-Auth-Request-Email $email;
|
||||
```
|
||||
|
||||
## Security
|
||||
|
||||
The app believes the header only on connections from `TRUSTED_PROXIES`, checked
|
||||
against the connection's own address, not `X-Forwarded-For`. So:
|
||||
|
||||
- The proxy must always set the header, overwriting anything a client sends.
|
||||
- The app must not be reachable from untrusted networks except through the
|
||||
proxy.
|
||||
- If the header arrives more than once with different values, the request is
|
||||
refused.
|
||||
|
||||
## Accounts
|
||||
|
||||
- **Matching:** users are matched by username, so an existing account with that
|
||||
name is signed in.
|
||||
- **New accounts:** otherwise an account is created. The first one becomes
|
||||
admin, as with registration. New accounts get a random password.
|
||||
- **Passwords:** while signed in through the proxy, users can set a password
|
||||
from Settings without knowing the current one. They can then log in with it
|
||||
when reaching the app without the proxy, such as on a LAN.
|
||||
@@ -0,0 +1,59 @@
|
||||
# Game Saves
|
||||
|
||||
## Overview
|
||||
|
||||
Games keep their saves in the player's browser. For signed-in players, Dotcom
|
||||
Arcade also keeps them on their account, so a save made on one computer is
|
||||
there on the next, and the last 10 distinct saves of each game can be restored.
|
||||
Guests keep browser-only saves.
|
||||
|
||||
## What a save is
|
||||
|
||||
A save is a snapshot of up to three kinds of browser storage:
|
||||
|
||||
- **Streamed games' files:** what a streamed game wrote to its save directory.
|
||||
It's sent back from the streamer as it changes, and kept in the
|
||||
`flashpoint-stream-saves` IndexedDB database.
|
||||
- **localStorage:** Ruffle's Flash SharedObjects, and HTML5 games' localStorage.
|
||||
- **IndexedDB:** HTML5 games' databases, including Unity WebGL's `/idbfs`.
|
||||
|
||||
Games share the app's origin, so their keys can't tell which game wrote them.
|
||||
The app works it out by watching instead: whatever changes while a game is
|
||||
playing belongs to that game, and it remembers that for next time. The app's own
|
||||
storage is never included.
|
||||
|
||||
## Sync
|
||||
|
||||
Before a game starts, the app compares the browser's save with the account's
|
||||
latest:
|
||||
|
||||
- If they're the same, nothing happens.
|
||||
- If only one side changed since this browser last synced, that side wins.
|
||||
- If both changed, the newer one wins. The other is still kept as a backup.
|
||||
|
||||
While playing, changes are uploaded 10 seconds after they happen, when the
|
||||
player leaves the game, and when the tab closes.
|
||||
|
||||
## Restoring
|
||||
|
||||
The **Saves** button under the game lists the account's backups with their
|
||||
times and sizes. Restoring one applies it and restarts the game. The save it
|
||||
replaced stays in the list.
|
||||
|
||||
## Limits
|
||||
|
||||
- **Size:** each snapshot is at most 10 MB.
|
||||
- **History:** the 10 newest distinct snapshots per user and game are kept.
|
||||
- **Not covered:** cookies set by HTML5 games.
|
||||
- **No merging:** when two browsers both changed a save, the newer one wins
|
||||
whole. Saves aren't combined.
|
||||
|
||||
## API
|
||||
|
||||
Endpoints require a signed-in user with `games.play`:
|
||||
|
||||
| Endpoint | Purpose |
|
||||
| ------------------------------------------- | ------------------------------------------------------------ |
|
||||
| `GET /api/game-saves/:gameId` | Latest snapshot with its data, plus metadata for all backups |
|
||||
| `GET /api/game-saves/:gameId/:id` | One snapshot with its data |
|
||||
| `POST /api/game-saves/:gameId` | Upload `{data, hash, savedAt}`; a duplicate of the latest is a no-op |
|
||||
@@ -29,6 +29,12 @@ Web application.
|
||||
during system updates while allowing admin access
|
||||
12. [Discovery Portal](./12-discovery-portal.md) - Arcade portal layout, color
|
||||
schemes, discovery homepage and mature content filtering
|
||||
13. [Streaming](./13-streaming.md) - Java, Shockwave and original Flash Player
|
||||
games streamed from the server, one container per player
|
||||
14. [Proxy Sign-In](./14-proxy-sign-in.md) - Sign users in through Authentik,
|
||||
Authelia, oauth2-proxy and other forward-auth proxies
|
||||
15. [Game Saves](./15-game-saves.md) - Saves kept on the player's account, with
|
||||
backups to restore
|
||||
|
||||
## Quick Reference
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Mermaid Diagrams
|
||||
|
||||
This directory contains Mermaid diagram source files for the Flashpoint Web
|
||||
This directory contains Mermaid diagram source files for the Dotcom Arcade
|
||||
documentation. These diagrams can be rendered in any Markdown viewer that
|
||||
supports Mermaid syntax (GitHub, GitLab, VS Code with Mermaid extension, etc.).
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
%% Flashpoint Web System Architecture
|
||||
%% Dotcom Arcade System Architecture
|
||||
%% Complete system architecture showing all three services, databases, and data flow
|
||||
|
||||
graph TB
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Reference Documentation
|
||||
|
||||
This directory contains comprehensive reference materials for the Flashpoint Web
|
||||
This directory contains comprehensive reference materials for the Dotcom Arcade
|
||||
application, including database schemas, type definitions, terminology, and
|
||||
dependency information.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Database Schema Reference
|
||||
|
||||
Reference for Flashpoint Web database schemas: flashpoint.sqlite (read-only) and
|
||||
Reference for Dotcom Arcade database schemas: flashpoint.sqlite (read-only) and
|
||||
user.db (read-write).
|
||||
|
||||
## Flashpoint Database (flashpoint.sqlite)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# External Dependencies Reference
|
||||
|
||||
Reference for all npm packages used across the Flashpoint Web monorepo.
|
||||
Reference for all npm packages used across the Dotcom Arcade monorepo.
|
||||
|
||||
## Backend Dependencies
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Glossary
|
||||
|
||||
Technical terms and project-specific terminology used throughout Flashpoint Web.
|
||||
Technical terms and project-specific terminology used throughout Dotcom Arcade.
|
||||
|
||||
## Project-Specific Terms
|
||||
|
||||
@@ -50,7 +50,7 @@ origins for public game embedding.
|
||||
**bcrypt** - Password hashing function based on Blowfish cipher. Cost factor 10
|
||||
(2^10 = 1024 rounds). Automatically salts and one-way hashes.
|
||||
|
||||
**SQLite** - Serverless SQL database. Flashpoint Web uses two: flashpoint.sqlite
|
||||
**SQLite** - Serverless SQL database. Dotcom Arcade uses two: flashpoint.sqlite
|
||||
(read-only) and user.db (read-write). No separate server required.
|
||||
|
||||
**Hot-Reload** - Automatic reloading when database files change. DatabaseService
|
||||
@@ -183,7 +183,7 @@ ActionScript, timeline animation. MIME: application/x-shockwave-flash.
|
||||
3 (performance/modern OOP).
|
||||
|
||||
**Shockwave** - Predecessor to Flash by Adobe/Macromedia. File format: DCR. Not
|
||||
currently supported in Flashpoint Web.
|
||||
currently supported in Dotcom Arcade.
|
||||
|
||||
**HTML5 Games** - Games using standard web tech (HTML, CSS, JavaScript). Canvas,
|
||||
Web Audio, WebGL APIs.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Overview
|
||||
|
||||
This document catalogs all security measures implemented in the Flashpoint Web
|
||||
This document catalogs all security measures implemented in the Dotcom Arcade
|
||||
application, addressing OWASP Top 10 vulnerabilities and security best
|
||||
practices.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Type Definitions Reference
|
||||
|
||||
Core TypeScript types and interfaces used throughout Flashpoint Web.
|
||||
Core TypeScript types and interfaces used throughout Dotcom Arcade.
|
||||
|
||||
## Game Types
|
||||
|
||||
|
||||
@@ -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
|
||||
+11
-3
@@ -1,7 +1,7 @@
|
||||
# Flashpoint Web - Technical Documentation
|
||||
# Dotcom Arcade - Technical Documentation
|
||||
|
||||
Complete technical documentation for the Flashpoint Web project, a self-hosted
|
||||
web application for browsing and playing games from the Flashpoint Archive.
|
||||
Technical documentation for Dotcom Arcade, a self-hosted, Flash-era game portal
|
||||
for Flashpoint Ultimate.
|
||||
|
||||
## Documentation Structure
|
||||
|
||||
@@ -154,6 +154,14 @@ web application for browsing and playing games from the Flashpoint Archive.
|
||||
- [System Settings](10-features/09-system-settings.md) - System configuration
|
||||
- [Date/Time Formatting](10-features/10-date-time-formatting.md) - Date display
|
||||
formatting
|
||||
- [Maintenance Mode](10-features/11-maintenance-mode.md) - Restricting access
|
||||
during updates
|
||||
- [Discovery Portal](10-features/12-discovery-portal.md) - Portal layout,
|
||||
homepage discovery, game page and mature content filter
|
||||
- [Streaming](10-features/13-streaming.md) - Games streamed from the server
|
||||
- [Proxy Sign-In](10-features/14-proxy-sign-in.md) - Sign-in through Authentik,
|
||||
Authelia or oauth2-proxy
|
||||
- [Game Saves](10-features/15-game-saves.md) - Saves kept on players' accounts
|
||||
|
||||
### 11. Diagrams
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 571 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 800 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 730 KiB |
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.
|
||||
+1
-1
@@ -21,7 +21,7 @@
|
||||
form-action 'self';
|
||||
"
|
||||
/>
|
||||
<title>Flashpoint Archive</title>
|
||||
<title>Dotcom Arcade</title>
|
||||
<!-- Blocking script to prevent FOUC (Flash of Unstyled Content) -->
|
||||
<!-- Applies theme before first paint by reading from localStorage -->
|
||||
<script>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "flashpoint-web-frontend",
|
||||
"name": "dotcom-arcade-frontend",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
|
||||
+9
-67
@@ -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={
|
||||
|
||||
@@ -32,7 +32,7 @@ export function Header({ hideSidebarToggle = false, hideSearch = false }: Header
|
||||
const { logout } = useAuth();
|
||||
const { data: publicSettings } = usePublicSettings();
|
||||
|
||||
const siteName = publicSettings?.app?.siteName ?? 'Flashpoint Archive';
|
||||
const siteName = publicSettings?.app?.siteName ?? 'Dotcom Arcade';
|
||||
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 1024;
|
||||
const effectiveCollapsed = isMobile ? false : sidebarCollapsed;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -20,7 +20,7 @@ import { useMatureStore } from '@/store/mature';
|
||||
export const PortalHeader = forwardRef<HTMLInputElement>(function PortalHeader(_props, searchRef) {
|
||||
const navigate = useNavigate();
|
||||
const { data: publicSettings } = usePublicSettings();
|
||||
const siteName = publicSettings?.app?.siteName ?? 'Flashpoint Archive';
|
||||
const siteName = publicSettings?.app?.siteName ?? 'Dotcom Arcade';
|
||||
const tagline = publicSettings?.app?.tagline;
|
||||
const [query, setQuery] = useState('');
|
||||
|
||||
|
||||
@@ -51,14 +51,14 @@ export function AppUpdateCard() {
|
||||
if (result.lastCheckFailed) {
|
||||
showToast('Could not reach GitHub to check for updates', 'error');
|
||||
} else if (result.updateAvailable) {
|
||||
showToast(`Flashpoint Web ${result.latestVersion} is available`, 'success');
|
||||
showToast(`Dotcom Arcade ${result.latestVersion} is available`, 'success');
|
||||
} else if (result.isUnreleasedBuild) {
|
||||
showToast(
|
||||
`Update checks are off for this build (latest release: ${result.latestVersion ?? 'unknown'})`,
|
||||
'info'
|
||||
);
|
||||
} else if (versionsMatch(result.currentVersion, result.latestVersion)) {
|
||||
showToast('Flashpoint Web is up to date', 'success');
|
||||
showToast('Dotcom Arcade is up to date', 'success');
|
||||
} else {
|
||||
showToast(`Latest release: ${result.latestVersion ?? 'unknown'}`, 'info');
|
||||
}
|
||||
@@ -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
|
||||
@@ -84,7 +87,7 @@ export function AppUpdateCard() {
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Rocket size={24} className="text-primary" aria-hidden="true" />
|
||||
<h2 className="text-xl font-semibold">Flashpoint Web</h2>
|
||||
<h2 className="text-xl font-semibold">Dotcom Arcade</h2>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -33,7 +33,7 @@ export interface SaveSyncState {
|
||||
const STATE_DB = 'flashpoint-save-sync';
|
||||
const STATE_STORE = 'games';
|
||||
|
||||
/** flashpoint-web's own storage, never part of a game's save */
|
||||
/** The app's own storage, never part of a game's save */
|
||||
const APP_DATABASES = new Set(['flashpoint-stream-saves', STATE_DB]);
|
||||
// Unity WebGL's asset cache: large and re-downloadable, not a save
|
||||
const IGNORED_DATABASES = new Set(['UnityCache']);
|
||||
@@ -170,7 +170,7 @@ async function gameDatabaseHashes(): Promise<Map<string, string>> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Notices what a running game changes. Games share flashpoint-web's origin and
|
||||
* Notices what a running game changes. Games share the app's origin and
|
||||
* Ruffle saves may use a site-wide path, so storage that changes while a game
|
||||
* runs is attributed to it rather than guessed from names.
|
||||
*/
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user