From 1dc3adbdba01fca2f0892a81615efdd4615369e2 Mon Sep 17 00:00:00 2001 From: ashim-hq Date: Tue, 21 Apr 2026 10:17:49 +0800 Subject: [PATCH] fix: Docker hardening, security, and deployment readiness for V1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 — Docker Artifact Optimization: - Replace broad `COPY . .` with targeted frontend source copies (API/Python changes no longer bust the frontend build cache) - Replace build-essential with gcc/g++ (leaner runtime) - Fix LOG_LEVEL=debug → info for production - Harden .dockerignore (exclude worktrees, IDE, CI, test artifacts) Phase 2 — State & Persistence: - Add PUID/PGID support in entrypoint.sh for bind mount compatibility - Guard against PUID=0/PGID=0 to prevent accidental root execution - Evict conflicting system users (e.g. node:1000) before UID remap Phase 3 — Security: - Always register @fastify/rate-limit so login brute-force protection works even when global rate limit is disabled (RATE_LIMIT_PER_MIN=0) - Add trustProxy support (TRUST_PROXY env var, default true) so rate limiting and audit logs use real client IPs behind reverse proxies - Strip stack traces from 500 error responses in production - Fix FSTDEP022 deprecation: maxParamLength → routerOptions - Add multi-file guard on single-file tool endpoint with clear error message pointing to the /batch endpoint Phase 4 — Graceful Degradation: - Add consolidated hardware detection startup banner (GPU, rate limit, upload limit, proxy status) - Add ConnectionMonitor component with health polling and reconnecting overlay that auto-dismisses when the server comes back Phase 5 — Deployment Docs: - Rewrite deployment.md with copy-paste CPU and GPU compose templates - Add hardware requirements table (minimum, recommended, heavy workloads) - Add PUID/PGID bind mount documentation - Add complete env var reference table - Add reverse proxy guides for Nginx, Nginx Proxy Manager, Traefik, and Cloudflare Tunnels --- .dockerignore | 29 +++- apps/api/src/index.ts | 35 ++-- apps/api/src/lib/env.ts | 4 + apps/api/src/routes/tool-factory.ts | 15 ++ apps/docs/guide/deployment.md | 254 +++++++++++++++++++++++----- apps/web/src/App.tsx | 148 +++++----------- docker/.dockerignore | 38 ++++- docker/Dockerfile | 16 +- docker/docker-compose-gpu.yml | 22 +-- docker/docker-compose.yml | 12 +- docker/entrypoint.sh | 38 +++++ 11 files changed, 427 insertions(+), 184 deletions(-) diff --git a/.dockerignore b/.dockerignore index d26aaf63..9285191f 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,23 +1,44 @@ node_modules .git +.gitignore .turbo +.worktrees dist *.db *.db-journal *.db-wal *.db-shm .env -.env.local +.env.* +!.env.example .DS_Store +.mcp.json +.superpowers +docs/superpowers + +# Test artifacts test-results playwright-report blob-report tests -docs coverage -.superpowers *.tsbuildinfo +MASTER_TEST_MATRIX.md + +# Docs (not needed in production image) +docs *.md !README.md + +# CI/release/scripts +.github +.husky +.releaserc.json +scripts + +# IDE +.vscode +.idea + +# Test images test-*.png -audit_report.md diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 8b8e4a6e..3ad3c41c 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -42,7 +42,8 @@ recoverInterruptedInstalls(); const app = Fastify({ logger: { level: env.LOG_LEVEL }, bodyLimit: env.MAX_UPLOAD_SIZE_MB > 0 ? env.MAX_UPLOAD_SIZE_MB * 1024 * 1024 : 1073741824, - maxParamLength: 500, + trustProxy: env.TRUST_PROXY, + routerOptions: { maxParamLength: 500 }, }); app.setErrorHandler((error: Error & { statusCode?: number }, request, reply) => { @@ -51,9 +52,11 @@ app.setErrorHandler((error: Error & { statusCode?: number }, request, reply) => { err: error, url: request.url, method: request.method }, "Unhandled request error", ); + const isProduction = process.env.NODE_ENV === "production"; reply.status(statusCode).send({ error: statusCode >= 500 ? "Internal server error" : error.message, - details: error.stack ?? error.message, + ...(statusCode < 500 && { details: error.message }), + ...(!isProduction && statusCode >= 500 && { details: error.stack ?? error.message }), }); }); @@ -80,13 +83,14 @@ app.addHook("onSend", async (_request, reply) => { } }); -if (env.RATE_LIMIT_PER_MIN > 0) { - await app.register(rateLimit, { - max: env.RATE_LIMIT_PER_MIN, - timeWindow: "1 minute", - allowList: (request) => !request.url.startsWith("/api/"), - }); -} +// Always register rate-limit plugin so per-route limits (login brute-force protection) work. +// When RATE_LIMIT_PER_MIN=0, the global limit is set high enough to be effectively unlimited +// while still enabling per-route overrides like the login endpoint. +await app.register(rateLimit, { + max: env.RATE_LIMIT_PER_MIN > 0 ? env.RATE_LIMIT_PER_MIN : 50000, + timeWindow: "1 minute", + allowList: (request) => !request.url.startsWith("/api/"), +}); // Multipart upload support await registerUpload(app); @@ -190,7 +194,18 @@ const cleanupCron = startCleanupCron(); // Start try { await app.listen({ port: env.PORT, host: "0.0.0.0" }); - console.log(`ashim API running on port ${env.PORT}`); + const gpu = isGpuAvailable(); + console.log( + [ + `ashim v${APP_VERSION} running on port ${env.PORT}`, + gpu + ? "[INFO] GPU detected — AI tools will use CUDA acceleration" + : "[WARN] No GPU detected — AI tools will use CPU (slower)", + `[INFO] Rate limit: ${env.RATE_LIMIT_PER_MIN > 0 ? `${env.RATE_LIMIT_PER_MIN}/min` : "disabled"}`, + `[INFO] Upload limit: ${env.MAX_UPLOAD_SIZE_MB > 0 ? `${env.MAX_UPLOAD_SIZE_MB} MB` : "unlimited"}`, + `[INFO] Trust proxy: ${env.TRUST_PROXY}`, + ].join("\n"), + ); } catch (err) { app.log.error(err); process.exit(1); diff --git a/apps/api/src/lib/env.ts b/apps/api/src/lib/env.ts index fcb56352..ba9800c4 100644 --- a/apps/api/src/lib/env.ts +++ b/apps/api/src/lib/env.ts @@ -40,6 +40,10 @@ const envSchema = z.object({ MAX_PDF_PAGES: z.coerce.number().default(0), SESSION_DURATION_HOURS: z.coerce.number().default(168), LOGIN_ATTEMPT_LIMIT: z.coerce.number().default(10), + TRUST_PROXY: z + .enum(["true", "false"]) + .default("true") + .transform((v) => v === "true"), }); export type Env = z.infer; diff --git a/apps/api/src/routes/tool-factory.ts b/apps/api/src/routes/tool-factory.ts index 5821f11b..567a8b29 100644 --- a/apps/api/src/routes/tool-factory.ts +++ b/apps/api/src/routes/tool-factory.ts @@ -110,6 +110,7 @@ export function createToolRoute(app: FastifyInstance, config: ToolRouteConfig let filename = "image"; let settingsRaw: string | null = null; let fileId: string | null = null; + let fileCount = 0; // Parse multipart parts try { @@ -117,6 +118,14 @@ export function createToolRoute(app: FastifyInstance, config: ToolRouteConfig for await (const part of parts) { if (part.type === "file") { + fileCount++; + if (fileCount > 1) { + // Drain remaining parts to avoid hanging the connection + for await (const _ of part.file) { + /* drain */ + } + continue; + } // Consume the file stream into a buffer const chunks: Buffer[] = []; for await (const chunk of part.file) { @@ -141,6 +150,12 @@ export function createToolRoute(app: FastifyInstance, config: ToolRouteConfig }); } + if (fileCount > 1) { + return reply.status(400).send({ + error: `This endpoint processes one image at a time. Use /api/v1/tools/${config.toolId}/batch for multiple files.`, + }); + } + // Require a file if (!fileBuffer || fileBuffer.length === 0) { return reply.status(400).send({ error: "No image file provided" }); diff --git a/apps/docs/guide/deployment.md b/apps/docs/guide/deployment.md index 90434644..abc2cbef 100644 --- a/apps/docs/guide/deployment.md +++ b/apps/docs/guide/deployment.md @@ -4,9 +4,71 @@ ashim ships as a single Docker container. The image supports **linux/amd64** (wi See [Docker Image](./docker-tags) for GPU setup, Docker Compose examples, and version pinning. -## Docker Compose (recommended) +## Quick Start (CPU) ```yaml +# docker-compose.yml — Copy this file and run: docker compose up -d +services: + ashim: + image: ashimhq/ashim:latest # or ghcr.io/ashim-hq/ashim:latest + container_name: ashim + ports: + - "1349:1349" # Web UI + API + volumes: + - ashim-data:/data # Database, AI models, user files (PERSISTENT) + - ashim-workspace:/tmp/workspace # Temp processing files (can be tmpfs) + environment: + # --- Authentication --- + - AUTH_ENABLED=true # Set to false to disable login entirely + - DEFAULT_USERNAME=admin # First-run admin username + - DEFAULT_PASSWORD=admin # First-run admin password (you'll be forced to change it) + + # --- Limits (0 = unlimited) --- + # - MAX_UPLOAD_SIZE_MB=0 # Per-file upload limit in MB + # - MAX_BATCH_SIZE=0 # Max files per batch request + # - RATE_LIMIT_PER_MIN=0 # API rate limit (0 = disabled, 100 = recommended for public) + # - MAX_USERS=0 # Max user accounts + + # --- Networking --- + # - TRUST_PROXY=true # Trust X-Forwarded-For headers (set false if not behind a proxy) + + # --- Bind mount permissions --- + # - PUID=1000 # Match your host user's UID (run: id -u) + # - PGID=1000 # Match your host user's GID (run: id -g) + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:1349/api/v1/health"] + interval: 30s + timeout: 5s + start_period: 60s + retries: 3 + shm_size: "2gb" # Needed for Python ML shared memory + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + +volumes: + ashim-data: # Named volume — Docker manages permissions automatically + ashim-workspace: +``` + +```bash +docker compose up -d +``` + +The app is then available at `http://localhost:1349`. + +> **Docker Hub rate limits?** Replace `ashimhq/ashim:latest` with `ghcr.io/ashim-hq/ashim:latest` to pull from GitHub Container Registry instead. Both registries receive the same image on every release. + +## Quick Start (GPU) + +For NVIDIA GPU acceleration on AI tools (background removal, upscaling, face enhancement, OCR): + +```yaml +# docker-compose-gpu.yml — Requires: NVIDIA GPU + nvidia-container-toolkit +# Install toolkit: https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html services: ashim: image: ashimhq/ashim:latest @@ -21,6 +83,25 @@ services: - DEFAULT_USERNAME=admin - DEFAULT_PASSWORD=admin restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:1349/api/v1/health"] + interval: 30s + timeout: 5s + start_period: 60s + retries: 3 + shm_size: "2gb" # Required for PyTorch CUDA shared memory + deploy: + resources: + reservations: + devices: + - driver: nvidia + count: all # Or set to 1 for a specific GPU + capabilities: [gpu] + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" volumes: ashim-data: @@ -28,74 +109,128 @@ volumes: ``` ```bash -docker compose up -d +docker compose -f docker-compose-gpu.yml up -d ``` -The app is then available at `http://localhost:1349`. +Check GPU detection in the logs: -> **Docker Hub rate limits?** Replace `ashimhq/ashim:latest` with `ghcr.io/ashim-hq/ashim:latest` to pull from GitHub Container Registry instead. Both registries receive the same image on every release. +```bash +docker logs ashim 2>&1 | head -20 +# Look for: [INFO] GPU detected — AI tools will use CUDA acceleration +``` -## What's inside the container +## Hardware Requirements -The Docker image uses a multi-stage build: +### Minimum (basic image tools only) -1. **Build stage** -- Installs Node.js dependencies and builds the React frontend with Vite. -2. **Production stage** -- Copies the built frontend and API source into a Node 22 image, installs system dependencies (Python 3, ImageMagick, Tesseract, potrace), sets up a Python virtual environment with all ML packages, and pre-downloads model weights. +| Resource | Requirement | +|---|---| +| CPU | 2 cores | +| RAM | 1 GB | +| Disk | 3 GB (image) + 1 GB (data volume) | +| GPU | Not required | -Everything runs from a single process. The Fastify server handles API requests and serves the frontend SPA. +Basic tools (resize, crop, rotate, convert, watermark, border, etc.) work on any hardware. They use Sharp (libvips) and complete in milliseconds. -### System dependencies installed in the image +### Recommended (AI tools) -- Python 3 with pip -- ImageMagick -- Tesseract OCR -- libraw (RAW image support) -- potrace (bitmap to vector conversion) +| Resource | Requirement | +|---|---| +| CPU | 4+ cores | +| RAM | 4 GB minimum, 8 GB recommended | +| Disk | 3 GB (image) + 10-25 GB (AI models, downloaded on first use) | +| GPU | NVIDIA with 4+ GB VRAM (optional but 5-20x faster) | -### Python packages +AI tools (background removal, upscaling, face enhancement, OCR, object erasing) download models on first use. Model sizes: -- rembg with BiRefNet-Lite (background removal) -- RealESRGAN (upscaling) -- PaddleOCR (text recognition) -- MediaPipe (face detection) -- OpenCV (inpainting/object removal) -- onnxruntime, opencv-python, Pillow, numpy +| Feature | Model Size | VRAM Usage | +|---|---|---| +| Background removal | ~200 MB | ~1 GB | +| Face detection | ~10 MB | ~500 MB | +| Upscale + Face enhance | ~1.5 GB | ~4 GB | +| OCR | ~200 MB | ~1 GB | +| Object eraser + Colorize | ~500 MB | ~2 GB | -Model weights are downloaded at build time, so the container works fully offline. +### Heavy workloads (upscale + GFPGAN) -### Architecture notes +| Resource | Requirement | +|---|---| +| CPU | 8+ cores | +| RAM | 16 GB | +| GPU | NVIDIA with 8+ GB VRAM (RTX 3070 or better) | +| Disk | 30 GB total | -All tools work on both amd64 and arm64. AI tools (background removal, upscaling, OCR, face detection) use CUDA-accelerated packages on amd64 and CPU packages on arm64. GPU acceleration is auto-detected at runtime when `--gpus all` is passed. +Upscaling a 4K image with face enhancement at 4x scale uses ~6 GB VRAM peak. Without a GPU, the same operation takes 5-10 minutes on CPU vs. 10-30 seconds on GPU. ## Volumes -Mount these to persist data: +| Mount | Purpose | Required? | +|---|---|---| +| `/data` | SQLite database, AI models, Python venv, user files | **Yes** — data loss without it | +| `/tmp/workspace` | Temporary processing files (auto-cleaned) | Recommended | -| Mount point | Purpose | -|---|---| -| `/data` | SQLite database (users, API keys, pipelines, settings) | -| `/tmp/workspace` | Temporary image processing files | +### Bind mounts vs. named volumes -The `/data` volume is the important one. Without it, you lose all user accounts and saved pipelines on container restart. The workspace volume is optional but prevents the container's writable layer from growing. - -## Health check - -The container includes a health check that hits `GET /api/v1/health`. Docker uses this to report container status: - -```bash -docker inspect --format='{{.State.Health.Status}}' ashim +**Named volumes** (recommended) — Docker manages permissions automatically: +```yaml +volumes: + - ashim-data:/data ``` -## Reverse proxy +**Bind mounts** — You manage permissions. Set `PUID`/`PGID` to match your host user: +```yaml +volumes: + - ./ashim-data:/data +environment: + - PUID=1000 # Your host UID (run: id -u) + - PGID=1000 # Your host GID (run: id -g) +``` -If you're running ashim behind nginx or Caddy, point it at port 1349. Example nginx config: +## Environment Variables + +| Variable | Default | Description | +|---|---|---| +| `AUTH_ENABLED` | `true` | Enable/disable login requirement | +| `DEFAULT_USERNAME` | `admin` | Initial admin username | +| `DEFAULT_PASSWORD` | `admin` | Initial admin password (forced change on first login) | +| `MAX_UPLOAD_SIZE_MB` | `0` (unlimited) | Per-file upload limit | +| `MAX_BATCH_SIZE` | `0` (unlimited) | Max files per batch request | +| `RATE_LIMIT_PER_MIN` | `0` (disabled) | API requests per minute per IP | +| `MAX_USERS` | `0` (unlimited) | Maximum user accounts | +| `TRUST_PROXY` | `true` | Trust X-Forwarded-For headers from reverse proxy | +| `PUID` | `999` | Run as this UID (for bind mount permissions) | +| `PGID` | `999` | Run as this GID (for bind mount permissions) | +| `LOG_LEVEL` | `info` | Log verbosity: fatal, error, warn, info, debug, trace | +| `CONCURRENT_JOBS` | `0` (auto) | Max parallel AI processing jobs | +| `SESSION_DURATION_HOURS` | `168` | Login session lifetime (7 days) | +| `CORS_ORIGIN` | (empty) | Comma-separated allowed origins, or empty for same-origin | + +## Health Check + +The container includes a built-in health check: + +```bash +# Check container health status +docker inspect --format='{{.State.Health.Status}}' ashim + +# Manual health check +curl http://localhost:1349/api/v1/health +# {"status":"healthy","version":"1.15.9"} +``` + +## Reverse Proxy + +ashim sets `TRUST_PROXY=true` by default so rate limiting and logging use the real client IP from `X-Forwarded-For` headers. + +### Nginx ```nginx server { listen 80; server_name images.example.com; - client_max_body_size 200M; + # Match MAX_UPLOAD_SIZE_MB (0 = nginx default 1M, so set high for unlimited) + client_max_body_size 500M; location / { proxy_pass http://localhost:1349; @@ -104,11 +239,46 @@ server { 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; + + # SSE support (batch progress, feature install progress) + proxy_buffering off; + proxy_read_timeout 300s; } } ``` -Set `client_max_body_size` to match your `MAX_UPLOAD_SIZE_MB` value. +### Nginx Proxy Manager + +1. Add a new Proxy Host +2. Set Domain Name to your domain +3. Set Scheme to `http`, Forward Hostname to `ashim` (or your container IP), Forward Port to `1349` +4. Enable WebSocket support +5. Under Advanced, add: `client_max_body_size 500M;` and `proxy_buffering off;` + +### Traefik + +```yaml +# Add these labels to the ashim service in docker-compose.yml +labels: + - "traefik.enable=true" + - "traefik.http.routers.ashim.rule=Host(`images.example.com`)" + - "traefik.http.routers.ashim.entrypoints=websecure" + - "traefik.http.routers.ashim.tls.certresolver=letsencrypt" + - "traefik.http.services.ashim.loadbalancer.server.port=1349" + # Increase upload limit (default 2MB is too low) + - "traefik.http.middlewares.ashim-body.buffering.maxRequestBodyBytes=524288000" + - "traefik.http.routers.ashim.middlewares=ashim-body" +``` + +### Cloudflare Tunnels + +```bash +cloudflared tunnel --url http://localhost:1349 +``` + +Note: Cloudflare has a 100 MB upload limit on free plans. Set `MAX_UPLOAD_SIZE_MB=100` to match. ## CI/CD diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 3825071a..397ff5ff 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,50 +1,40 @@ -import { Component, type ErrorInfo, type ReactNode, Suspense } from "react"; +import { Component, type ErrorInfo, lazy, type ReactNode, Suspense } from "react"; import { BrowserRouter, Navigate, Route, Routes, useLocation } from "react-router-dom"; import { Toaster } from "sonner"; -import { ConnectionBanner } from "./components/common/connection-banner"; +import { ConnectionMonitor } from "./components/common/connection-monitor"; import { KeyboardShortcutProvider } from "./components/common/keyboard-shortcut-provider"; import { useAuth } from "./hooks/use-auth"; -import { useConnectionMonitor } from "./hooks/use-connection-monitor"; -import { isChunkError, lazyWithRetry } from "./lib/lazy-with-retry"; -// Lazy-load all pages with automatic retry so chunk failures from -// deployments are recovered transparently instead of white-screening. -const AutomatePage = lazyWithRetry(() => +// Lazy-load all pages so each page's JS (and its icons/deps) is only +// downloaded when the user navigates there, shrinking the main bundle. +const AutomatePage = lazy(() => import("./pages/automate-page").then((m) => ({ default: m.AutomatePage })), ); -const ChangePasswordPage = lazyWithRetry(() => +const ChangePasswordPage = lazy(() => import("./pages/change-password-page").then((m) => ({ default: m.ChangePasswordPage })), ); -const FilesPage = lazyWithRetry(() => - import("./pages/files-page").then((m) => ({ default: m.FilesPage })), -); -const FullscreenGridPage = lazyWithRetry(() => +const FilesPage = lazy(() => import("./pages/files-page").then((m) => ({ default: m.FilesPage }))); +const FullscreenGridPage = lazy(() => import("./pages/fullscreen-grid-page").then((m) => ({ default: m.FullscreenGridPage })), ); -const HomePage = lazyWithRetry(() => - import("./pages/home-page").then((m) => ({ default: m.HomePage })), -); -const LoginPage = lazyWithRetry(() => - import("./pages/login-page").then((m) => ({ default: m.LoginPage })), -); -const PrivacyPolicyPage = lazyWithRetry(() => +const HomePage = lazy(() => import("./pages/home-page").then((m) => ({ default: m.HomePage }))); +const LoginPage = lazy(() => import("./pages/login-page").then((m) => ({ default: m.LoginPage }))); +const PrivacyPolicyPage = lazy(() => import("./pages/privacy-policy-page").then((m) => ({ default: m.PrivacyPolicyPage })), ); -const ToolPage = lazyWithRetry(() => - import("./pages/tool-page").then((m) => ({ default: m.ToolPage })), -); +const ToolPage = lazy(() => import("./pages/tool-page").then((m) => ({ default: m.ToolPage }))); class ErrorBoundary extends Component< { children: ReactNode }, - { hasError: boolean; error: Error | null; isChunkError: boolean } + { hasError: boolean; error: Error | null } > { constructor(props: { children: ReactNode }) { super(props); - this.state = { hasError: false, error: null, isChunkError: false }; + this.state = { hasError: false, error: null }; } static getDerivedStateFromError(error: Error) { - return { hasError: true, error, isChunkError: isChunkError(error) }; + return { hasError: true, error }; } componentDidCatch(error: Error, info: ErrorInfo) { @@ -53,43 +43,6 @@ class ErrorBoundary extends Component< render() { if (this.state.hasError) { - if (this.state.isChunkError) { - return ( -
-
-
- - Refresh - - -
-

Update Available

-

- A new version of ashim has been deployed. -

- -
-
- ); - } return (
@@ -100,7 +53,7 @@ class ErrorBoundary extends Component<