diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..dce0570e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,36 @@ +# Dependencies and build artifacts +node_modules +dist +.git +.gitignore + +# IDE and OS +.vscode +.idea +*.log +.DS_Store +Thumbs.db + +# Env and secrets (mount at runtime) +.env +.env.local +.env.*.local + +# Tauri build outputs (not needed for Docker image) +src-tauri/target + +# Tests and dev +e2e +tests +*.spec.ts +*.test.mjs +playwright +playwright.config.* + +# Docs and meta (optional: remove if you want them in image) +docs +*.md +!README.md + +# Desktop packaging +scripts/desktop-package.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 69e2a2ba..71015472 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ All notable changes to World Monitor are documented here. +## [Unreleased] + +### Added + +- **Self-hosted Docker image**: Single container runs the full dashboard (SPA + 45+ API handlers). `Dockerfile` and `docker-compose.yml` with optional env file for API keys. Sidecar supports `LOCAL_API_MODE=standalone` to serve static `dist/` and listen on `0.0.0.0` for in-container use. README updated with Option 4 (Self-hosted Docker) and roadmap item marked done. +- **Static serving security**: Standalone mode uses `path.relative()` to enforce in-directory resolution and prevent path traversal when serving files from `dist/`. + ## [2.3.4] - 2026-02-16 ### Fixed diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..e5516b35 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,35 @@ +# World Monitor — self-hosted Docker image +# Serves the built SPA + local API (45+ handlers). Optional API keys via env. +# See README "Self-hosting with Docker" and .env.example. + +FROM node:20-alpine AS builder + +WORKDIR /app + +COPY package.json package-lock.json* ./ +RUN npm ci --omit=dev + +COPY . . +ENV VITE_VARIANT=full +RUN npm run build:full + +# Runner stage: same Node, no devDependencies +FROM node:20-alpine + +WORKDIR /app + +COPY package.json package-lock.json* ./ +RUN npm ci --omit=dev + +COPY api ./api +COPY src-tauri/sidecar ./src-tauri/sidecar +COPY --from=builder /app/dist ./dist + +ENV NODE_ENV=production +ENV LOCAL_API_MODE=standalone +ENV LOCAL_API_PORT=3000 +ENV LOCAL_API_CLOUD_FALLBACK=true + +EXPOSE 3000 + +CMD ["node", "src-tauri/sidecar/local-api-server.mjs"] diff --git a/README.md b/README.md index 79fb7b91..f8594baa 100644 --- a/README.md +++ b/README.md @@ -904,6 +904,36 @@ npm run dev # Vite dev server on http://localhost:5173 This runs the frontend without the API layer. Panels that require server-side proxying will show "No data available". The interactive map, static data layers (bases, cables, pipelines), and browser-side ML models still work. +### Option 4: Self-hosted Docker + +Run the full dashboard (SPA + 45+ API handlers) in a single container. The image serves the built frontend and the same Node-based API layer used by the desktop app, with optional cloud fallback when handlers fail. + +```bash +# Build and run (no API keys required; panels without keys stay empty or use cloud fallback) +docker build -t worldmonitor . +docker run -p 3000:3000 worldmonitor +``` + +Open [http://localhost:3000](http://localhost:3000). To enable AI, markets, or other features, pass env vars (see `.env.example`): + +```bash +docker run -p 3000:3000 \ + -e GROQ_API_KEY=your_groq_key \ + -e UPSTASH_REDIS_REST_URL=... \ + -e UPSTASH_REDIS_REST_TOKEN=... \ + worldmonitor +``` + +Or use a compose file and an env file: + +```bash +cp .env.example .env +# Edit .env with your keys (optional) +docker compose up -d +``` + +The container listens on `0.0.0.0:3000`. With `LOCAL_API_CLOUD_FALLBACK=true` (default), any handler that errors or is missing will transparently use the cloud API (worldmonitor.app). + ### Platform Notes | Platform | Status | Notes | @@ -911,8 +941,8 @@ This runs the frontend without the API layer. Panels that require server-side pr | **Vercel** | Full support | Recommended deployment target | | **Linux x86_64** | Works with `vercel dev` | Full local development | | **macOS** | Works with `vercel dev` | Full local development | -| **Raspberry Pi / ARM** | Partial | `vercel dev` edge runtime emulation may not work on ARM. Use Option 1 (deploy to Vercel) or Option 3 (static frontend) instead | -| **Docker** | Planned | See [Roadmap](#roadmap) | +| **Raspberry Pi / ARM** | Partial | `vercel dev` edge runtime emulation may not work on ARM. Use Option 1 (deploy to Vercel), Option 3 (static frontend), or Option 4 (Docker on ARM if Node image supports it) | +| **Docker** | Supported | Single image: SPA + API. See [Option 4](#option-4-self-hosted-docker) above. | ### Railway Relay (Optional) @@ -1009,7 +1039,7 @@ Desktop release details, signing hooks, variant outputs, and clean-machine valid - [x] Entity index with cross-source correlation and confidence scoring - [ ] Mobile-optimized views - [ ] Push notifications for critical alerts -- [ ] Self-hosted Docker image +- [x] Self-hosted Docker image See [full roadmap](./docs/DOCUMENTATION.md#roadmap). diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..5e40268d --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,16 @@ +# World Monitor — self-hosted stack +# Usage: docker compose up -d +# Optional: cp .env.example .env, add API keys, then uncomment env_file below. + +services: + app: + build: . + ports: + - "3000:3000" + # env_file: [ .env ] + environment: + NODE_ENV: production + LOCAL_API_MODE: standalone + LOCAL_API_PORT: 3000 + LOCAL_API_CLOUD_FALLBACK: "true" + restart: unless-stopped diff --git a/src-tauri/sidecar/local-api-server.mjs b/src-tauri/sidecar/local-api-server.mjs index 8a868208..87fe2adf 100644 --- a/src-tauri/sidecar/local-api-server.mjs +++ b/src-tauri/sidecar/local-api-server.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node import http, { createServer } from 'node:http'; import https from 'node:https'; -import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { existsSync, readFileSync, writeFileSync, createReadStream, statSync } from 'node:fs'; import { readdir } from 'node:fs/promises'; import { gzipSync } from 'node:zlib'; import path from 'node:path'; @@ -312,6 +312,8 @@ function resolveConfig(options = {}) { const mode = String(options.mode ?? process.env.LOCAL_API_MODE ?? 'desktop-sidecar'); const cloudFallback = String(options.cloudFallback ?? process.env.LOCAL_API_CLOUD_FALLBACK ?? '') === 'true'; const logger = options.logger ?? console; + const distDir = options.distDir ?? (mode === 'standalone' ? path.join(resourceDir, 'dist') : null); + const host = options.host ?? (mode === 'standalone' ? '0.0.0.0' : '127.0.0.1'); return { port, @@ -321,6 +323,8 @@ function resolveConfig(options = {}) { mode, cloudFallback, logger, + distDir: distDir && existsSync(distDir) ? distDir : null, + host, }; } @@ -833,10 +837,61 @@ export async function createLocalApiServer(options = {}) { loadVerboseState(context.resourceDir); const routes = await buildRouteTable(context.apiDir); + const MIME_TYPES = { + '.html': 'text/html; charset=utf-8', + '.js': 'application/javascript; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.json': 'application/json', + '.ico': 'image/x-icon', + '.png': 'image/png', + '.svg': 'image/svg+xml', + '.woff2': 'font/woff2', + '.woff': 'font/woff', + '.map': 'application/json', + }; + + function serveStatic(requestUrl, res, req) { + if (!context.distDir || (req.method !== 'GET' && req.method !== 'HEAD')) return false; + const safePath = requestUrl.pathname.replace(/^\/+/, '').replace(/\/+/g, '/'); + if (safePath.includes('..') || path.isAbsolute(safePath)) return false; + const filePath = path.join(context.distDir, safePath || 'index.html'); + const resolved = path.resolve(filePath); + const rel = path.relative(context.distDir, resolved); + if (rel.startsWith('..') || path.isAbsolute(rel)) return false; + let target = resolved; + if (!existsSync(target)) { + if (!safePath || safePath.endsWith('/')) { + target = path.join(resolved, 'index.html'); + } else { + target = path.join(context.distDir, 'index.html'); + } + if (!existsSync(target)) return false; + } + const stat = statSync(target); + if (stat.isDirectory()) { + target = path.join(target, 'index.html'); + if (!existsSync(target)) return false; + } + const ext = path.extname(target); + const contentType = MIME_TYPES[ext] || 'application/octet-stream'; + const stat2 = statSync(target); + const headers = { 'content-type': contentType, ...makeCorsHeaders(req) }; + if (req.method === 'HEAD') { + headers['content-length'] = String(stat2.size); + res.writeHead(200, headers); + res.end(); + } else { + res.writeHead(200, headers); + createReadStream(target).pipe(res); + } + return true; + } + const server = createServer(async (req, res) => { const requestUrl = new URL(req.url || '/', `http://127.0.0.1:${context.port}`); if (!requestUrl.pathname.startsWith('/api/')) { + if (serveStatic(requestUrl, res, req)) return; res.writeHead(404, { 'content-type': 'application/json', ...makeCorsHeaders(req) }); res.end(JSON.stringify({ error: 'Not found' })); return; @@ -913,12 +968,13 @@ export async function createLocalApiServer(options = {}) { server.once('listening', onListening); server.once('error', onError); - server.listen(context.port, '127.0.0.1'); + server.listen(context.port, context.host); }); const address = server.address(); const boundPort = typeof address === 'object' && address?.port ? address.port : context.port; - context.logger.log(`[local-api] listening on http://127.0.0.1:${boundPort} (apiDir=${context.apiDir}, routes=${routes.length}, cloudFallback=${context.cloudFallback})`); + const bindHost = context.host === '0.0.0.0' ? '0.0.0.0' : '127.0.0.1'; + context.logger.log(`[local-api] listening on http://${bindHost}:${boundPort} (apiDir=${context.apiDir}, routes=${routes.length}, cloudFallback=${context.cloudFallback}${context.distDir ? ', static=' + context.distDir : ''})`); return { port: boundPort }; }, async close() {