diff --git a/.env.example b/.env.example index 2b613c1..9b56190 100644 --- a/.env.example +++ b/.env.example @@ -11,17 +11,17 @@ FLASHPOINT_HOST_PATH=/path/to/Flashpoint JWT_SECRET=replace-with-a-secure-random-string # --------------------------------------------------------------------------- -# Container Images (production only) +# Container Image (production only) # --------------------------------------------------------------------------- -# Override image tag (images from Docker Hub: darkraise/flashpoint-*) +# Override image tag (image from Docker Hub: darkraise/flashpoint-web) # IMAGE_TAG=latest # --------------------------------------------------------------------------- -# Ports (host mapping) +# Port (host mapping) # --------------------------------------------------------------------------- -# These map host ports to container ports (container ports are hardcoded) +# Maps a host port to the container, which serves both the UI and the API +# on port 3100 WEB_PORT=80 -API_PORT=3100 # --------------------------------------------------------------------------- # Data Storage @@ -43,7 +43,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-backend +OTEL_SERVICE_NAME=flashpoint-web OTEL_TRACES_ENABLED=true OTEL_METRICS_ENABLED=true # OTEL_METRICS_EXPORT_INTERVAL=60000 @@ -93,10 +93,6 @@ ENABLE_CACHE_PREWARM=true # --------------------------------------------------------------------------- # HOME_RECENT_HOURS=24 # ENABLE_CGI=false - -# --------------------------------------------------------------------------- -# Frontend Nginx (Docker only) -# --------------------------------------------------------------------------- -# BACKEND_HOST=backend -# BACKEND_PORT=3100 +# Serve the built frontend from the backend (on by default in production) +# SERVE_FRONTEND=true diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..fc005a9 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Shell scripts must use LF so they run inside Linux containers +*.sh text eol=lf diff --git a/.github/workflows/README.md b/.github/workflows/README.md index bad0832..4678e9e 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -6,18 +6,17 @@ This directory contains automated workflows for the Flashpoint Web project. ### 1. Docker Build and Push (`docker-build-push.yml`) -Builds and pushes Docker images for all three services to container registries. +Builds and pushes the single Docker image to the container registry. #### Triggers -- **Version tags** (`v*.*.*`): Builds and pushes images with version tags and `latest` tag +- **Version tags** (`v*.*.*`): Builds and pushes the image with version tags and `latest` tag - **Manual dispatch**: Can be triggered manually from GitHub Actions tab -#### Services Built +#### Image Built -- `flashpoint-backend` - REST API server -- `flashpoint-frontend` - React web UI (Nginx) -- `flashpoint-game-service` - Game content proxy and ZIP server +- `flashpoint-web` - Single image serving both the REST API (including game + content) and the React web UI #### Container Registry @@ -43,11 +42,9 @@ The workflow pushes images to **Docker Hub**. - `DOCKERHUB_USERNAME`: Your Docker Hub username - `DOCKERHUB_TOKEN`: The access token from step 2 -4. **Images will be pushed to**: +4. **Image will be pushed to**: ``` - docker.io/darkraise/flashpoint-backend:latest - docker.io/darkraise/flashpoint-frontend:latest - docker.io/darkraise/flashpoint-game-service:latest + docker.io/darkraise/flashpoint-web:latest ``` #### Image Tags @@ -98,51 +95,34 @@ The workflow uses GitHub Actions cache to speed up builds: #### Usage Examples -##### Pull and Run Images +##### Pull and Run the Image ```bash -# Pull latest images -docker pull darkraise/flashpoint-backend:latest -docker pull darkraise/flashpoint-frontend:latest -docker pull darkraise/flashpoint-game-service:latest +# Pull the latest image +docker pull darkraise/flashpoint-web:latest # Or use a specific version -docker pull darkraise/flashpoint-backend:1.0.0 +docker pull darkraise/flashpoint-web:1.0.0 ``` ##### Use in Docker Compose -Update your `docker-compose.yml` to use pre-built images: +Update your `docker-compose.yml` to use the pre-built image: ```yaml -version: '3.8' - services: - backend: - image: darkraise/flashpoint-backend:latest + flashpoint-web: + image: darkraise/flashpoint-web:latest # Remove build section ports: - - "3100:3100" - # ... rest of config - - frontend: - image: darkraise/flashpoint-frontend:latest - ports: - - "80:8080" - # ... rest of config - - game-service: - image: darkraise/flashpoint-game-service:latest - ports: - - "22500:22500" - - "22501:22501" + - "80:3100" # ... rest of config ``` Then simply run: ```bash -docker-compose pull # Pull latest images -docker-compose up -d # Start services +docker compose pull # Pull the latest image +docker compose up -d # Start the service ``` #### Making Images Public @@ -150,7 +130,7 @@ docker-compose up -d # Start services 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-backend`) +2. Click on the repository (e.g., `flashpoint-web`) 3. Click "Settings" 4. Change visibility to "Public" @@ -179,10 +159,9 @@ docker login docker.io #### Performance Typical build times (optimized for amd64 only): -- First build: 3-5 minutes per service -- Subsequent builds (with cache): 1-2 minutes per service -- Parallel execution: All 3 services build simultaneously -- Total workflow time: ~6-10 minutes (down from 12+ minutes) +- 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 diff --git a/.github/workflows/docker-build-push.yml b/.github/workflows/docker-build-push.yml index 5a6dff7..2dc3d92 100644 --- a/.github/workflows/docker-build-push.yml +++ b/.github/workflows/docker-build-push.yml @@ -13,16 +13,6 @@ jobs: build-and-push: runs-on: ubuntu-latest timeout-minutes: 30 - strategy: - fail-fast: false - matrix: - service: - - name: backend - context: . - dockerfile: ./backend/Dockerfile - - name: frontend - context: . - dockerfile: ./frontend/Dockerfile permissions: contents: read @@ -52,7 +42,7 @@ jobs: id: meta uses: docker/metadata-action@v5 with: - images: ${{ env.REGISTRY_DOCKERHUB }}/${{ secrets.DOCKERHUB_USERNAME }}/flashpoint-${{ matrix.service.name }} + images: ${{ env.REGISTRY_DOCKERHUB }}/${{ secrets.DOCKERHUB_USERNAME }}/flashpoint-web tags: | # Tag with full version (v1.0.0 -> 1.0.0) type=semver,pattern={{version}} @@ -63,16 +53,16 @@ jobs: # Latest tag type=raw,value=latest - - name: Build and push Docker images + - name: Build and push Docker image uses: docker/build-push-action@v5 with: - context: ${{ matrix.service.context }} - file: ${{ matrix.service.dockerfile }} + context: . + file: ./Dockerfile push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha,scope=${{ matrix.service.name }} - cache-to: type=gha,scope=${{ matrix.service.name }},mode=max + 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'] }} @@ -90,17 +80,15 @@ jobs: run: | echo "## Docker Build Summary" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY - echo "### Images Built:" >> $GITHUB_STEP_SUMMARY - echo "- \`flashpoint-backend\`" >> $GITHUB_STEP_SUMMARY - echo "- \`flashpoint-frontend\`" >> $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-*\`" >> $GITHUB_STEP_SUMMARY + echo "- Docker Hub: \`${{ secrets.DOCKERHUB_USERNAME }}/flashpoint-web\`" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY - echo "### Pull Commands:" >> $GITHUB_STEP_SUMMARY + echo "### Pull Command:" >> $GITHUB_STEP_SUMMARY echo "\`\`\`bash" >> $GITHUB_STEP_SUMMARY - echo "docker pull ${{ secrets.DOCKERHUB_USERNAME }}/flashpoint-backend:latest" >> $GITHUB_STEP_SUMMARY - echo "docker pull ${{ secrets.DOCKERHUB_USERNAME }}/flashpoint-frontend:latest" >> $GITHUB_STEP_SUMMARY + echo "docker pull ${{ secrets.DOCKERHUB_USERNAME }}/flashpoint-web:latest" >> $GITHUB_STEP_SUMMARY echo "\`\`\`" >> $GITHUB_STEP_SUMMARY create-release: @@ -127,13 +115,12 @@ jobs: # Get the previous tag PREV_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "") - echo "## Docker Images" >> release_notes.md + echo "## Docker Image" >> release_notes.md echo "" >> release_notes.md - echo "Pull the images from Docker Hub:" >> 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-backend:${{ steps.version.outputs.version }}" >> release_notes.md - echo "docker pull ${{ secrets.DOCKERHUB_USERNAME }}/flashpoint-frontend:${{ steps.version.outputs.version }}" >> 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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e8f4785 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,115 @@ +# Production Dockerfile for Flashpoint Web (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 . + +# ============================================================================= +# Stage 1: Frontend builder - Build the React app +# ============================================================================= +FROM node:20.18.2-alpine AS frontend-builder + +WORKDIR /app + +# Copy workspace root and frontend package files +COPY package.json package-lock.json ./ +COPY frontend/package.json ./frontend/ + +# Install frontend dependencies (frozen lockfile for reproducible builds) +RUN npm ci --workspace=frontend --include-workspace-root --prefer-offline --no-audit + +# Copy frontend source and build static assets +COPY frontend ./frontend/ +WORKDIR /app/frontend +RUN npm run build + +# ============================================================================= +# Stage 2: Backend builder - Compile TypeScript +# ============================================================================= +FROM node:20.18.2-alpine AS backend-builder + +WORKDIR /app + +# Copy workspace root and backend package files +COPY package.json package-lock.json ./ +COPY backend/package.json ./backend/ + +# Install backend dependencies (frozen lockfile for reproducible builds) +RUN npm ci --workspace=backend --include-workspace-root --prefer-offline --no-audit + +# Copy backend source and build +COPY backend ./backend/ +WORKDIR /app/backend +RUN npm run build + +# ============================================================================= +# Stage 3: Production - Optimized runtime +# ============================================================================= +FROM node:20.18.2-alpine + +# Metadata labels +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.version="1.0.0" +LABEL org.opencontainers.image.source="https://github.com/darkraise/flashpoint-web" + +WORKDIR /app + +# Install runtime dependencies +# - curl: health checks +# - tzdata: timezone support +# - su-exec: lightweight tool to drop privileges (like gosu) +# - shadow: provides usermod/groupmod for dynamic UID/GID +RUN apk add --no-cache curl tzdata su-exec shadow + +# Copy workspace root package files +COPY --from=backend-builder /app/package.json /app/package-lock.json ./ + +# Copy backend package files +COPY --from=backend-builder /app/backend/package.json ./backend/ + +# Install production dependencies only and clean npm cache to reduce image size +RUN npm ci --workspace=backend --include-workspace-root --omit=dev --prefer-offline --no-audit && \ + npm cache clean --force + +# Copy built backend +COPY --from=backend-builder /app/backend/dist ./backend/dist + +# Copy migration files to dist directory (needed at runtime) +COPY --from=backend-builder /app/backend/src/migrations ./backend/dist/migrations + +# Copy entrypoint script +COPY --from=backend-builder /app/backend/docker-entrypoint.sh ./backend/docker-entrypoint.sh + +# Copy built frontend — served by the backend (config.frontendDistPath resolves +# to /app/frontend/dist at runtime) +COPY --from=frontend-builder /app/frontend/dist ./frontend/dist + +# Create data and logs directories, make entrypoint executable, fix ownership +# Uses existing 'node' user (UID 1000) from base image +RUN mkdir -p /app/data /app/logs && \ + chmod +x /app/backend/docker-entrypoint.sh && \ + chown -R node:node /app + +# Set working directory to backend (entrypoint runs `node dist/server.js`) +WORKDIR /app/backend + +# Expose port (serves both API and UI) +EXPOSE 3100 + +# Health check is configured in docker-compose.yml for flexibility + +# Environment variables +ENV NODE_ENV=production +ENV PUID=1000 +ENV PGID=1000 + +# Start as root, entrypoint will drop privileges after setting up user +USER root + +# Production command using entrypoint script +# The entrypoint handles: +# 1. Dynamic UID/GID setup (if PUID/PGID differ from defaults) +# 2. Database copy (if enabled) +# 3. Privilege drop and application startup +ENTRYPOINT ["./docker-entrypoint.sh"] diff --git a/README.md b/README.md index 4741a3d..663f567 100644 --- a/README.md +++ b/README.md @@ -74,12 +74,12 @@ mkdir flashpoint-web && cd flashpoint-web ```yaml services: - backend: - image: darkraise/flashpoint-backend:${IMAGE_TAG:-latest} - container_name: flashpoint-backend + flashpoint-web: + image: darkraise/flashpoint-web:${IMAGE_TAG:-latest} + container_name: flashpoint-web restart: unless-stopped ports: - - "${API_PORT:-3100}:3100" + - "${WEB_PORT:-80}:3100" volumes: - ${FLASHPOINT_HOST_PATH:?FLASHPOINT_HOST_PATH is required}:/data/flashpoint:ro - ${DATA_PATH:-./data}:/app/data @@ -102,28 +102,6 @@ services: timeout: 10s retries: 3 start_period: 40s - - frontend: - image: darkraise/flashpoint-frontend:${IMAGE_TAG:-latest} - container_name: flashpoint-frontend - restart: unless-stopped - ports: - - "${WEB_PORT:-80}:8080" - environment: - - PUID=${PUID:-1000} - - PGID=${PGID:-1000} - - TZ=${TZ:-UTC} - - BACKEND_HOST=${BACKEND_HOST:-backend} - - BACKEND_PORT=${BACKEND_PORT:-3100} - depends_on: - backend: - condition: service_healthy - healthcheck: - test: ["CMD", "wget", "--quiet", "--spider", "http://localhost:8080/"] - interval: 30s - timeout: 3s - retries: 3 - start_period: 10s ``` ### 3. Create `.env` @@ -136,11 +114,10 @@ FLASHPOINT_HOST_PATH=/path/to/Flashpoint JWT_SECRET=your-secure-secret-key # Optional — uncomment and edit as needed -# WEB_PORT=80 # Frontend port (default: 80) -# API_PORT=3100 # Backend API port (default: 3100) +# WEB_PORT=80 # 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') -# DOMAIN=http://localhost # Public URL of the frontend +# DOMAIN=http://localhost # Public URL of the app # LOG_LEVEL=info # debug, info, warn, error # TZ=UTC # Timezone # DATA_PATH=./data # Persistent app data diff --git a/backend/Dockerfile b/backend/Dockerfile deleted file mode 100644 index 67b0414..0000000 --- a/backend/Dockerfile +++ /dev/null @@ -1,94 +0,0 @@ -# Production Dockerfile for Flashpoint Web Backend - -# ============================================================================= -# Stage 1: Builder - Build TypeScript -# ============================================================================= -FROM node:20.18.2-alpine AS builder - -WORKDIR /app - -# Copy workspace root package files -COPY package.json package-lock.json ./ - -# Copy backend package files -COPY backend/package.json ./backend/ - -# Install all dependencies for the backend workspace -# Use npm ci with frozen lockfile for faster, reproducible builds -RUN npm ci --workspace=backend --include-workspace-root --prefer-offline --no-audit - -# Copy backend source code -COPY backend ./backend/ - -# Build TypeScript (run from backend directory) -WORKDIR /app/backend -RUN npm run build - -# ============================================================================= -# Stage 2: Production - Optimized runtime -# ============================================================================= -FROM node:20.18.2-alpine - -# Metadata labels -LABEL org.opencontainers.image.title="Flashpoint Web Backend" -LABEL org.opencontainers.image.description="REST API for Flashpoint Archive" -LABEL org.opencontainers.image.version="1.0.0" -LABEL org.opencontainers.image.source="https://github.com/darkraise/flashpoint-web" - -WORKDIR /app - -# Install runtime dependencies -# - curl: health checks -# - tzdata: timezone support -# - su-exec: lightweight tool to drop privileges (like gosu) -# - shadow: provides usermod/groupmod for dynamic UID/GID -RUN apk add --no-cache curl tzdata su-exec shadow - -# Copy workspace root package files -COPY --from=builder /app/package.json /app/package-lock.json ./ - -# Copy backend package files -COPY --from=builder /app/backend/package.json ./backend/ - -# Install production dependencies only and clean npm cache to reduce image size -RUN npm ci --workspace=backend --include-workspace-root --omit=dev --prefer-offline --no-audit && \ - npm cache clean --force - -# Copy built application -COPY --from=builder /app/backend/dist ./backend/dist - -# Copy migration files to dist directory (needed at runtime) -COPY --from=builder /app/backend/src/migrations ./backend/dist/migrations - -# Copy entrypoint script -COPY --from=builder /app/backend/docker-entrypoint.sh ./backend/docker-entrypoint.sh - -# Create data and logs directories -# Make entrypoint script executable -# Use existing 'node' user (UID 1000) from base image -RUN mkdir -p /app/data /app/logs && \ - chmod +x /app/backend/docker-entrypoint.sh && \ - chown -R node:node /app - -# Set working directory to backend -WORKDIR /app/backend - -# Expose port -EXPOSE 3100 - -# Health check is configured in docker-compose.yml for flexibility - -# Environment variables -ENV NODE_ENV=production -ENV PUID=1000 -ENV PGID=1000 - -# Start as root, entrypoint will drop privileges after setting up user -USER root - -# Production command using entrypoint script -# The entrypoint handles: -# 1. Dynamic UID/GID setup (if PUID/PGID differ from defaults) -# 2. Database copy (if enabled) -# 3. Privilege drop and application startup -ENTRYPOINT ["./docker-entrypoint.sh"] diff --git a/backend/src/config.ts b/backend/src/config.ts index 7d9e80b..f0d1cb3 100644 --- a/backend/src/config.ts +++ b/backend/src/config.ts @@ -69,6 +69,16 @@ export const config = { port: 3100, // Hardcoded — Docker EXPOSE and healthcheck depend on this value host: '0.0.0.0', + // Single-image deployment: the backend also serves the built frontend and its + // SPA fallback. On by default in production; opt in elsewhere via SERVE_FRONTEND. + serveFrontend: process.env.SERVE_FRONTEND + ? process.env.SERVE_FRONTEND === 'true' + : process.env.NODE_ENV === 'production', + // __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'), + flashpointPath, flashpointDbPath: `${flashpointPath}/Data/flashpoint.sqlite`, flashpointHtdocsPath: `${flashpointPath}/Legacy/htdocs`, diff --git a/backend/src/server.ts b/backend/src/server.ts index fc3f0c1..5cf50be 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -1,6 +1,8 @@ // IMPORTANT: OpenTelemetry must be imported FIRST for auto-instrumentation import './telemetry'; +import path from 'path'; +import fs from 'fs'; import express, { Express } from 'express'; import cors from 'cors'; import helmet from 'helmet'; @@ -82,13 +84,37 @@ async function startServer() { app.use('/game-proxy', gameCors, gameProxyRouter); app.use('/game-zip', gameCors, gameZipRouter); + // When the backend also serves the SPA, the CSP mirrors the policy the former + // nginx frontend used. 'unsafe-inline' (styles) is required by Radix/shadcn + // inline style attributes; 'unsafe-eval' (scripts) is required by Ruffle's WASM. + // No upgrade-insecure-requests, so self-hosted HTTP deployments keep working. + // When running API-only, keep the strict 'none' policy. + // TODO(security): tighten via per-request nonces + 'wasm-unsafe-eval' once the + // SPA and Ruffle are verified to work without the broad 'unsafe-*' allowances. + const cspDirectives: Record = config.serveFrontend + ? { + defaultSrc: ["'self'"], + scriptSrc: ["'self'", "'unsafe-inline'", "'unsafe-eval'"], + styleSrc: ["'self'", "'unsafe-inline'"], + imgSrc: ["'self'", 'data:', 'https:'], + fontSrc: ["'self'", 'data:'], + connectSrc: ["'self'"], + frameSrc: ["'self'"], + objectSrc: ["'none'"], + baseUri: ["'self'"], + formAction: ["'self'"], + frameAncestors: ["'none'"], + } + : { + defaultSrc: ["'none'"], + frameAncestors: ["'none'"], + }; + app.use( helmet({ contentSecurityPolicy: { - directives: { - defaultSrc: ["'none'"], - frameAncestors: ["'none'"], - }, + useDefaults: !config.serveFrontend, + directives: cspDirectives, }, crossOriginEmbedderPolicy: false, frameguard: { action: 'deny' }, @@ -121,6 +147,51 @@ async function startServer() { app.use(cookieParser()); app.use(compression()); + + // Single-image deployment: the backend serves the built frontend and its SPA + // fallback. Registered before auth/maintenance so the app shell always loads + // (the SPA then reflects login/maintenance state from API responses), and + // before the API catch-all 404 in setupRoutes so client routes reach index.html. + if (config.serveFrontend) { + const distPath = config.frontendDistPath; + const indexHtml = path.join(distPath, 'index.html'); + + if (fs.existsSync(indexHtml)) { + app.use( + express.static(distPath, { + index: false, + maxAge: '1y', + immutable: true, + 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'); + } + }, + }) + ); + + const backendPrefixes = ['/api/', '/game-proxy/', '/game-zip/', '/proxy/']; + app.get('*', (req, res, next) => { + if ( + req.path === '/health' || + backendPrefixes.some((prefix) => req.path.startsWith(prefix)) + ) { + next(); + return; + } + res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); + res.sendFile(indexHtml); + }); + + logger.info(`🖥️ Serving frontend from ${distPath}`); + } else { + logger.warn( + `⚠️ SERVE_FRONTEND is enabled but no build was found at ${indexHtml} — skipping static serving` + ); + } + } + app.use(requestTimeout(TimeoutConfig.DEFAULT)); app.use((req, res, next) => { @@ -282,7 +353,7 @@ async function startServer() { server.keepAliveTimeout = 65000; // 65s - slightly above common load balancer timeout server.headersTimeout = 66000; // Must be > keepAliveTimeout server.timeout = 120000; // 2 min max for any request (including game file streaming) - server.maxConnections = 500; // Defense-in-depth; nginx handles primary limiting + server.maxConnections = 500; // Defense-in-depth against connection exhaustion const playTrackingService = new PlayTrackingService(); const playSessionCleanupInterval = setInterval( diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index fe9b9a0..b04bd77 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -1,14 +1,11 @@ # ============================================================================= # Flashpoint Web - Development Docker Compose # ============================================================================= -# Builds images locally from source code. +# Builds the single API + UI image locally from source. # # Usage: # docker compose -f docker-compose.dev.yml up -d --build # -# Rebuild specific service: -# docker compose -f docker-compose.dev.yml up -d --build backend -# # View logs: # docker compose -f docker-compose.dev.yml logs -f # @@ -18,24 +15,25 @@ # ============================================================================= services: - backend: + flashpoint-web: build: context: . - dockerfile: ./backend/Dockerfile - image: flashpoint-backend:dev - container_name: flashpoint-backend + dockerfile: ./Dockerfile + image: flashpoint-web:dev + container_name: flashpoint-web restart: unless-stopped ports: - - "${API_PORT:-3100}:3100" - # Backend now includes game content serving on /game-proxy and /game-zip + # Serves both the UI and the API (/api, /game-proxy, /game-zip) on one port + - "${WEB_PORT:-80}:3100" volumes: - ${FLASHPOINT_HOST_PATH:?FLASHPOINT_HOST_PATH is required}:/data/flashpoint:ro - backend-db:/app/data - - ${LOGS_PATH:-./logs}/backend:/app/logs + - ${LOGS_PATH:-./logs}:/app/logs environment: - PUID=${PUID:-1000} - PGID=${PGID:-1000} - NODE_ENV=${NODE_ENV:-development} + - SERVE_FRONTEND=true - TZ=${TZ:-UTC} - DOMAIN=${DOMAIN:-http://localhost:${WEB_PORT:-80}} - LOG_LEVEL=${LOG_LEVEL:-info} @@ -56,7 +54,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-backend} + - 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} @@ -70,33 +68,6 @@ services: networks: - flashpoint-network - frontend: - build: - context: . - dockerfile: ./frontend/Dockerfile - image: flashpoint-frontend:dev - container_name: flashpoint-frontend - restart: unless-stopped - ports: - - "${WEB_PORT:-80}:8080" - environment: - - PUID=${PUID:-1000} - - PGID=${PGID:-1000} - - TZ=${TZ:-UTC} - - BACKEND_HOST=${BACKEND_HOST:-backend} - - BACKEND_PORT=${BACKEND_PORT:-3100} - depends_on: - backend: - condition: service_healthy - healthcheck: - test: ["CMD", "wget", "--quiet", "--spider", "http://localhost:8080/"] - interval: 30s - timeout: 3s - retries: 3 - start_period: 10s - networks: - - flashpoint-network - networks: flashpoint-network: driver: bridge diff --git a/docker-compose.yml b/docker-compose.yml index 30c87e6..f8e8dc3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,8 @@ # ============================================================================= # Flashpoint Web - Production Docker Compose # ============================================================================= -# Uses pre-built images from container registry. +# Single image serving both the API and the web UI. +# Uses a pre-built image from the container registry. # # Usage: # 1. Copy .env.example to .env and configure required variables @@ -16,13 +17,13 @@ # ============================================================================= services: - backend: - image: darkraise/flashpoint-backend:${IMAGE_TAG:-latest} - container_name: flashpoint-backend + flashpoint-web: + image: darkraise/flashpoint-web:${IMAGE_TAG:-latest} + container_name: flashpoint-web restart: unless-stopped ports: - - "${API_PORT:-3100}:3100" - # Backend now includes game content serving on /game-proxy and /game-zip + # Serves both the UI and the API (/api, /game-proxy, /game-zip) on one port + - "${WEB_PORT:-80}:3100" volumes: - ${FLASHPOINT_HOST_PATH:?FLASHPOINT_HOST_PATH is required}:/data/flashpoint:ro - ${DATA_PATH:-./data}:/app/data @@ -31,6 +32,7 @@ services: - PUID=${PUID:-1000} - PGID=${PGID:-1000} - NODE_ENV=production + - SERVE_FRONTEND=true - TZ=${TZ:-UTC} - DOMAIN=${DOMAIN:-http://localhost:${WEB_PORT:-80}} - LOG_LEVEL=${LOG_LEVEL:-info} @@ -51,7 +53,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-backend} + - 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} @@ -78,43 +80,6 @@ services: networks: - flashpoint-network - frontend: - image: darkraise/flashpoint-frontend:${IMAGE_TAG:-latest} - container_name: flashpoint-frontend - restart: unless-stopped - ports: - - "${WEB_PORT:-80}:8080" - environment: - - PUID=${PUID:-1000} - - PGID=${PGID:-1000} - - TZ=${TZ:-UTC} - - BACKEND_HOST=${BACKEND_HOST:-backend} - - BACKEND_PORT=${BACKEND_PORT:-3100} - depends_on: - backend: - condition: service_healthy - healthcheck: - test: ["CMD", "wget", "--quiet", "--spider", "http://localhost:8080/"] - interval: 30s - timeout: 3s - retries: 3 - start_period: 10s - logging: - driver: json-file - options: - max-size: "5m" - max-file: "3" - deploy: - resources: - limits: - cpus: "0.5" - memory: 256M - reservations: - cpus: "0.25" - memory: 128M - networks: - - flashpoint-network - networks: flashpoint-network: driver: bridge diff --git a/docs/09-deployment/environment-variables.md b/docs/09-deployment/environment-variables.md index 012cef4..8de15d5 100644 --- a/docs/09-deployment/environment-variables.md +++ b/docs/09-deployment/environment-variables.md @@ -67,6 +67,16 @@ openssl rand -hex 64 | `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:** + +The backend serves the built React UI in the single-image deployment, so the app +and API share one origin. + +| Variable | Default | Description | +| -------------------- | ----------------------------- | -------------------------------------------------------------- | +| `SERVE_FRONTEND` | true in production, else false | Serve the built frontend and SPA fallback from the backend | +| `FRONTEND_DIST_PATH` | `/../frontend/dist` | Location of the built frontend (rarely needed) | + **OpenTelemetry:** | Variable | Default | Description | @@ -74,7 +84,7 @@ openssl rand -hex 64 | `OTEL_ENABLED` | false | Enable distributed tracing and metrics | | `OTEL_EXPORTER_OTLP_ENDPOINT` | http://localhost:4318 | OTLP collector endpoint | | `OTEL_API_KEY` | - | API key for authentication | -| `OTEL_SERVICE_NAME` | flashpoint-web-backend | Service name in traces/metrics | +| `OTEL_SERVICE_NAME` | flashpoint-web | Service name in traces/metrics | | `OTEL_TRACES_ENABLED` | true | Enable trace export (when OTEL enabled) | | `OTEL_METRICS_ENABLED` | true | Enable metrics export (when OTEL enabled)| | `OTEL_METRICS_EXPORT_INTERVAL`| 60000 | Metrics export interval (ms) | @@ -82,11 +92,11 @@ openssl rand -hex 64 ## Frontend Variables -The frontend has no environment variables in development or production. API -calls are proxied through the backend. +The frontend has no environment variables of its own. API calls use relative +paths (`/api`), so they resolve against whatever origin serves the app. -- **Development**: Via Vite proxy to `http://localhost:3100` -- **Production**: Via Nginx reverse proxy (configured by `BACKEND_HOST`/`BACKEND_PORT`) +- **Development**: Vite dev server on port 5173 proxies `/api` to `http://localhost:3100` +- **Production**: The backend serves the built UI and the API from the same origin ## Docker Environment Variables @@ -101,10 +111,9 @@ Location: `.env` in project root (used by docker-compose) **Port Mapping:** -| Variable | Default | Description | -| ---------- | ------- | --------------------- | -| `WEB_PORT` | 80 | Frontend exposed port | -| `API_PORT` | 3100 | Backend exposed port | +| Variable | Default | Description | +| ---------- | ------- | ------------------------------------------------------ | +| `WEB_PORT` | 80 | Host port mapped to the container (serves UI + API) | **Container Settings:** @@ -117,13 +126,6 @@ Location: `.env` in project root (used by docker-compose) | `DATA_PATH` | ./data | Host path for persistent data (production compose) | | `LOGS_PATH` | ./logs | Host path for log files | -**Frontend Nginx:** - -| Variable | Default | Description | -| -------------- | -------- | --------------------------------------- | -| `BACKEND_HOST` | backend | Backend hostname (Docker service name) | -| `BACKEND_PORT` | 3100 | Backend port for Nginx proxy_pass | - All backend variables listed above are also passable via docker-compose. See `docker-compose.yml` for the full list with defaults. @@ -137,7 +139,6 @@ FLASHPOINT_HOST_PATH=/path/to/flashpoint JWT_SECRET=$(openssl rand -hex 64) DOMAIN=https://flashpoint.example.com WEB_PORT=80 -API_PORT=3100 LOG_LEVEL=warn EOF ``` @@ -168,7 +169,6 @@ FLASHPOINT_HOST_PATH=/data/flashpoint JWT_SECRET=CHANGE-THIS-TO-A-RANDOM-64-CHARACTER-STRING DOMAIN=https://flashpoint.example.com WEB_PORT=80 -API_PORT=3100 LOG_LEVEL=warn ``` diff --git a/frontend/.nginx/default.conf.template b/frontend/.nginx/default.conf.template deleted file mode 100644 index 6dd4770..0000000 --- a/frontend/.nginx/default.conf.template +++ /dev/null @@ -1,125 +0,0 @@ -# Nginx configuration for Flashpoint Web Frontend -# Serves static files and handles SPA routing - -server { - listen 8080; - server_name localhost; - root /usr/share/nginx/html; - index index.html; - - # Gzip compression for better performance - gzip on; - gzip_vary on; - gzip_proxied any; - gzip_comp_level 6; - gzip_types text/plain text/css text/xml text/javascript - application/json application/javascript application/xml+rss - application/rss+xml font/truetype font/opentype - application/vnd.ms-fontobject image/svg+xml; - - # Security headers - add_header X-Frame-Options "SAMEORIGIN" always; - add_header X-Content-Type-Options "nosniff" always; - add_header X-XSS-Protection "1; mode=block" always; - add_header Referrer-Policy "no-referrer-when-downgrade" always; - - # Content Security Policy - # Using 'self' for connect-src and frame-src since nginx proxies all backend - # traffic through the same origin — no need for localhost references - add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self'; frame-src 'self'; object-src 'none';" always; - - # Permissions Policy (formerly Feature-Policy) - add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always; - - # Backend API - location ^~ /api/ { - proxy_pass http://${BACKEND_HOST}:${BACKEND_PORT}; - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection 'upgrade'; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_read_timeout 300s; - proxy_connect_timeout 75s; - } - - # Game proxy/media passthrough (through backend) - location ^~ /proxy/ { - proxy_pass http://${BACKEND_HOST}:${BACKEND_PORT}; - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection 'upgrade'; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_read_timeout 300s; - proxy_connect_timeout 75s; - } - - # Game content proxy - routes to backend (game service is integrated) - location ^~ /game-proxy/ { - proxy_pass http://${BACKEND_HOST}:${BACKEND_PORT}; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_read_timeout 300s; - proxy_connect_timeout 75s; - } - - # Game ZIP content - routes to backend (game service is integrated) - location ^~ /game-zip/ { - proxy_pass http://${BACKEND_HOST}:${BACKEND_PORT}; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_read_timeout 300s; - proxy_connect_timeout 75s; - } - - # SPA routing - serve index.html for all routes - location / { - try_files $uri $uri/ /index.html; - } - - # Cache static assets with versioned filenames - # NOTE: Must repeat security headers — nginx add_header in location blocks - # replaces ALL parent-level add_header directives - location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { - expires 1y; - add_header Cache-Control "public, immutable" always; - add_header X-Frame-Options "SAMEORIGIN" always; - add_header X-Content-Type-Options "nosniff" always; - add_header X-XSS-Protection "1; mode=block" always; - add_header Referrer-Policy "no-referrer-when-downgrade" always; - add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self'; frame-src 'self'; object-src 'none';" always; - add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always; - } - - # Don't cache index.html to ensure updates are seen - # NOTE: Must repeat security headers — see comment above - location = /index.html { - add_header Cache-Control "no-cache, no-store, must-revalidate" always; - add_header Pragma "no-cache" always; - add_header Expires "0" always; - add_header X-Frame-Options "SAMEORIGIN" always; - add_header X-Content-Type-Options "nosniff" always; - add_header X-XSS-Protection "1; mode=block" always; - add_header Referrer-Policy "no-referrer-when-downgrade" always; - add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self'; frame-src 'self'; object-src 'none';" always; - add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always; - } - - # Error handling - error_page 404 /index.html; - error_page 500 502 503 504 /50x.html; - location = /50x.html { - root /usr/share/nginx/html; - } -} diff --git a/frontend/.nginx/nginx.conf b/frontend/.nginx/nginx.conf deleted file mode 100644 index fda47cb..0000000 --- a/frontend/.nginx/nginx.conf +++ /dev/null @@ -1,51 +0,0 @@ -# Main nginx configuration for Flashpoint Web Frontend -# Optimized for Docker container running as non-root user - -# Don't use 'user' directive when running as non-root -# worker_processes auto is fine for most cases -worker_processes auto; - -# Log to stdout/stderr for Docker (no file permission issues) -error_log /dev/stderr warn; - -# PID file location (must be writable by non-root user) -pid /tmp/nginx.pid; - -events { - worker_connections 1024; - use epoll; - multi_accept on; -} - -http { - # Hide nginx version in headers and error pages - server_tokens off; - - # Include mime types - include /etc/nginx/mime.types; - default_type application/octet-stream; - - # Logging to stdout for Docker - log_format main '$remote_addr - $remote_user [$time_local] "$request" ' - '$status $body_bytes_sent "$http_referer" ' - '"$http_user_agent" "$http_x_forwarded_for"'; - - access_log /dev/stdout main; - - # Performance optimizations - sendfile on; - tcp_nopush on; - tcp_nodelay on; - keepalive_timeout 65; - types_hash_max_size 2048; - - # Temp directories (must be writable by non-root user) - client_body_temp_path /tmp/client_temp; - proxy_temp_path /tmp/proxy_temp; - fastcgi_temp_path /tmp/fastcgi_temp; - uwsgi_temp_path /tmp/uwsgi_temp; - scgi_temp_path /tmp/scgi_temp; - - # Include server configurations - include /etc/nginx/conf.d/*.conf; -} diff --git a/frontend/Dockerfile b/frontend/Dockerfile deleted file mode 100644 index 002294d..0000000 --- a/frontend/Dockerfile +++ /dev/null @@ -1,72 +0,0 @@ -# Production Dockerfile for Flashpoint Web Frontend - -# ============================================================================= -# Stage 1: Builder - Build for production -# ============================================================================= -FROM node:20.18.2-alpine AS builder - -WORKDIR /app - -# Copy workspace root package files -COPY package.json package-lock.json ./ - -# Copy frontend package files -COPY frontend/package.json ./frontend/ - -# Install dependencies for frontend workspace -# Use npm ci with frozen lockfile for faster, reproducible builds -RUN npm ci --workspace=frontend --include-workspace-root --prefer-offline --no-audit - -# Copy frontend source code -COPY frontend ./frontend/ - -# Build application (run from frontend directory) -WORKDIR /app/frontend -RUN npm run build - -# ============================================================================= -# Stage 2: Production - Nginx serving static files -# ============================================================================= -FROM nginx:1.27-alpine - -# Metadata labels -LABEL org.opencontainers.image.title="Flashpoint Web Frontend" -LABEL org.opencontainers.image.description="React web UI for Flashpoint Archive" -LABEL org.opencontainers.image.version="1.0.0" -LABEL org.opencontainers.image.source="https://github.com/darkraise/flashpoint-web" - -# Install runtime dependencies -# - gettext: provides envsubst for nginx config templating -RUN apk add --no-cache gettext - -# Copy built files from builder -COPY --from=builder /app/frontend/dist /usr/share/nginx/html - -# Copy nginx configuration from .nginx/ folder -# Main config: logs to stdout/stderr, uses /tmp for temp files (no permission issues) -COPY frontend/.nginx/nginx.conf /etc/nginx/nginx.conf -COPY frontend/.nginx/default.conf.template /etc/nginx/templates/default.conf.template - -# Copy entrypoint script -COPY frontend/docker-entrypoint.sh /docker-entrypoint.sh - -# Default backend host (can be overridden at runtime) -ENV BACKEND_HOST=backend \ - BACKEND_PORT=3100 - -# Set up permissions for nginx user (already exists in nginx:alpine) -RUN chown -R nginx:nginx /usr/share/nginx/html && \ - chown -R nginx:nginx /etc/nginx/conf.d && \ - chmod +x /docker-entrypoint.sh - -# Expose port (8080 for non-root user) -EXPOSE 8080 - -# Health check is configured in docker-compose.yml for flexibility. -# When running standalone (docker run), add --health-cmd manually. - -# Run as non-root user (nginx user from base image) -USER nginx - -# Production command using entrypoint script -ENTRYPOINT ["/docker-entrypoint.sh"] diff --git a/frontend/docker-entrypoint.sh b/frontend/docker-entrypoint.sh deleted file mode 100644 index da1c2a0..0000000 --- a/frontend/docker-entrypoint.sh +++ /dev/null @@ -1,34 +0,0 @@ -#!/bin/sh -set -e - -# ============================================================================= -# Docker Entrypoint for Flashpoint Web Frontend -# ============================================================================= -# This script handles: -# 1. Nginx configuration templating -# 2. Application startup -# ============================================================================= - -echo "Flashpoint Web Frontend - Starting..." - -# ============================================================================= -# Nginx Configuration Templating -# ============================================================================= -# Process nginx config template with environment variables - -# Set defaults for environment variables (envsubst doesn't support :-default syntax) -export BACKEND_HOST="${BACKEND_HOST:-backend}" -export BACKEND_PORT="${BACKEND_PORT:-3100}" - -echo "Processing nginx configuration..." -envsubst '${BACKEND_HOST} ${BACKEND_PORT}' < /etc/nginx/templates/default.conf.template > /etc/nginx/conf.d/default.conf - -# Display configuration summary -echo "" -echo "Configuration Summary:" -echo " Backend: ${BACKEND_HOST}:${BACKEND_PORT}" -echo "" - -# Start nginx -echo "Starting Nginx..." -exec nginx -g "daemon off;"