fix: Docker hardening, security, and deployment readiness for V1 (#82)

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
This commit is contained in:
Ashim
2026-04-21 10:19:08 +08:00
committed by GitHub
parent fa35f57813
commit 4c9dc6e38e
11 changed files with 427 additions and 184 deletions
+25 -4
View File
@@ -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
+25 -10
View File
@@ -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);
+4
View File
@@ -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<typeof envSchema>;
+15
View File
@@ -110,6 +110,7 @@ export function createToolRoute<T>(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<T>(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<T>(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" });
+212 -42
View File
@@ -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
+45 -103
View File
@@ -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 (
<div className="flex h-screen items-center justify-center bg-background text-foreground">
<div className="text-center space-y-4 max-w-md px-6">
<div className="mx-auto h-12 w-12 rounded-full bg-primary/10 flex items-center justify-center">
<svg
className="h-6 w-6 text-primary"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
role="img"
aria-label="Refresh icon"
>
<title>Refresh</title>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
</div>
<h1 className="text-xl font-semibold">Update Available</h1>
<p className="text-sm text-muted-foreground">
A new version of ashim has been deployed.
</p>
<button
type="button"
onClick={() => window.location.reload()}
className="px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium"
>
Refresh
</button>
</div>
</div>
);
}
return (
<div className="flex h-screen items-center justify-center bg-background text-foreground">
<div className="text-center space-y-4 max-w-md px-6">
@@ -100,7 +53,7 @@ class ErrorBoundary extends Component<
<button
type="button"
onClick={() => {
this.setState({ hasError: false, error: null, isChunkError: false });
this.setState({ hasError: false, error: null });
window.location.href = "/";
}}
className="px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium"
@@ -160,48 +113,37 @@ function PageLoader() {
);
}
function ConnectionMonitor() {
useConnectionMonitor();
return null;
}
export function App() {
return (
<>
<ErrorBoundary>
<ConnectionMonitor />
<ConnectionBanner />
<ErrorBoundary>
<Toaster position="bottom-right" />
<BrowserRouter>
<KeyboardShortcutProvider>
<AuthGuard>
<Suspense fallback={<PageLoader />}>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/change-password" element={<ChangePasswordPage />} />
<Route path="/automate" element={<AutomatePage />} />
<Route path="/files" element={<FilesPage />} />
<Route path="/fullscreen" element={<FullscreenGridPage />} />
<Route path="/privacy" element={<PrivacyPolicyPage />} />
{/* Redirects: old color tools consolidated into adjust-colors */}
<Route
path="/brightness-contrast"
element={<Navigate to="/adjust-colors" replace />}
/>
<Route path="/saturation" element={<Navigate to="/adjust-colors" replace />} />
<Route
path="/color-channels"
element={<Navigate to="/adjust-colors" replace />}
/>
<Route path="/color-effects" element={<Navigate to="/adjust-colors" replace />} />
<Route path="/:toolId" element={<ToolPage />} />
<Route path="/" element={<HomePage />} />
</Routes>
</Suspense>
</AuthGuard>
</KeyboardShortcutProvider>
</BrowserRouter>
</ErrorBoundary>
</>
<Toaster position="bottom-right" />
<BrowserRouter>
<KeyboardShortcutProvider>
<AuthGuard>
<Suspense fallback={<PageLoader />}>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/change-password" element={<ChangePasswordPage />} />
<Route path="/automate" element={<AutomatePage />} />
<Route path="/files" element={<FilesPage />} />
<Route path="/fullscreen" element={<FullscreenGridPage />} />
<Route path="/privacy" element={<PrivacyPolicyPage />} />
{/* Redirects: old color tools consolidated into adjust-colors */}
<Route
path="/brightness-contrast"
element={<Navigate to="/adjust-colors" replace />}
/>
<Route path="/saturation" element={<Navigate to="/adjust-colors" replace />} />
<Route path="/color-channels" element={<Navigate to="/adjust-colors" replace />} />
<Route path="/color-effects" element={<Navigate to="/adjust-colors" replace />} />
<Route path="/:toolId" element={<ToolPage />} />
<Route path="/" element={<HomePage />} />
</Routes>
</Suspense>
</AuthGuard>
</KeyboardShortcutProvider>
</BrowserRouter>
</ErrorBoundary>
);
}
+34 -4
View File
@@ -1,14 +1,44 @@
node_modules
.git
.gitignore
.turbo
.worktrees
dist
*.db
*.db-journal
*.db-wal
*.db-shm
.env
.env.local
.env.*
!.env.example
.DS_Store
.playwright-mcp
*.png
*.jpg
*.jpeg
.mcp.json
.superpowers
docs/superpowers
# Test artifacts
test-results
playwright-report
blob-report
tests
coverage
*.tsbuildinfo
MASTER_TEST_MATRIX.md
# IDE and editor files
.vscode
.idea
*.swp
*.swo
# CI/release files not needed in image
.github
.husky
.releaserc.json
scripts
# Large test images (favicons/logos in apps/web/public are fine)
test-*.png
*.heic
*.heif
+11 -5
View File
@@ -31,8 +31,10 @@ COPY packages/ai/package.json packages/ai/tsconfig.json ./packages/ai/
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store/v3 \
pnpm install --frozen-lockfile
# Copy source code
COPY . .
# Copy only frontend-relevant source (API/Python changes don't bust this cache)
COPY packages/shared/src ./packages/shared/src
COPY apps/web/src ./apps/web/src
COPY apps/web/public ./apps/web/public
# Build only the web frontend (API runs from TS source via tsx)
RUN --mount=type=cache,id=turbo-cache,target=/app/.turbo \
@@ -106,6 +108,7 @@ RUN set -e; \
# ============================================
# Stage 3: Platform-specific base images
# Pin tags to specific major.minor for reproducible builds.
# ============================================
FROM node:22-bookworm AS base-linux-arm64
FROM nvidia/cuda:12.6.3-cudnn-runtime-ubuntu24.04 AS base-linux-amd64
@@ -139,6 +142,7 @@ RUN corepack enable && corepack prepare pnpm@9.15.4 --activate && \
chmod -R a+rX /usr/local/share/corepack
# System dependencies (all platforms)
# Split into runtime deps and build deps to minimize final image size.
# Retry apt-get update with backoff — Ubuntu mirrors can be flaky on CI runners
RUN for i in 1 2 3; do apt-get -o Acquire::Retries=3 update && break || sleep $((i * 15)); done && \
apt-get install -y --no-install-recommends \
@@ -153,7 +157,7 @@ RUN for i in 1 2 3; do apt-get -o Acquire::Retries=3 update && break || sleep $(
python3 python3-pip python3-venv python3-dev \
tesseract-ocr tesseract-ocr-eng tesseract-ocr-deu tesseract-ocr-fra tesseract-ocr-spa \
tesseract-ocr-chi-sim tesseract-ocr-jpn tesseract-ocr-kor \
build-essential \
gcc g++ \
libgl1 libglib2.0-0 \
libegl1 libwayland-egl1 libwayland-client0 libwayland-cursor0 \
libxkbcommon-x11-0 libxkbcommon0 libxcursor1 \
@@ -168,7 +172,8 @@ RUN for i in 1 2 3; do apt-get -o Acquire::Retries=3 update && break || sleep $(
# Caire binary (content-aware seam carving)
COPY --from=caire-builder /tmp/caire /usr/local/bin/caire
# Python venv - Layer 1: Base packages (rarely change, ~3 GB)
# Python venv - Base packages (rarely change, cached aggressively)
# Uses pre-built manylinux wheels where available; gcc/g++ above covers the rest.
RUN --mount=type=cache,target=/root/.cache/pip \
python3 -m venv /opt/venv && \
/opt/venv/bin/pip install --upgrade pip && \
@@ -246,7 +251,8 @@ ENV PORT=1349 \
MAX_PDF_PAGES=0 \
SESSION_DURATION_HOURS=168 \
LOGIN_ATTEMPT_LIMIT=10 \
LOG_LEVEL=debug
LOG_LEVEL=info \
TRUST_PROXY=true
# NVIDIA Container Toolkit env vars (harmless on non-GPU systems)
ENV NVIDIA_VISIBLE_DEVICES=all \
+12 -10
View File
@@ -1,8 +1,9 @@
name: ashim
# NVIDIA GPU version
# NVIDIA GPU deployment — requires nvidia-container-toolkit.
# Install: https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html
# Usage: docker compose -f docker-compose-gpu.yml up -d
# Requires: NVIDIA GPU + nvidia-container-toolkit (Linux/Windows WSL2 only)
# Verify: docker logs ashim 2>&1 | grep GPU
services:
ashim:
@@ -14,8 +15,8 @@ services:
ports:
- "1349:1349"
volumes:
- ashim-data:/data
- ashim-workspace:/tmp/workspace
- ashim-data:/data # Database, AI models, user files
- ashim-workspace:/tmp/workspace # Temp processing (auto-cleaned)
environment:
- AUTH_ENABLED=true
- DEFAULT_USERNAME=admin
@@ -31,6 +32,7 @@ services:
- RATE_LIMIT_PER_MIN=${RATE_LIMIT_PER_MIN:-0}
- MAX_USERS=${MAX_USERS:-0}
- SESSION_DURATION_HOURS=${SESSION_DURATION_HOURS:-168}
- TRUST_PROXY=${TRUST_PROXY:-true}
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:1349/api/v1/health"]
@@ -38,12 +40,7 @@ services:
timeout: 5s
start_period: 60s
retries: 3
shm_size: '2gb'
logging:
driver: json-file
options:
max-size: "50m"
max-file: "5"
shm_size: "2gb" # Required for PyTorch CUDA shared memory
deploy:
resources:
reservations:
@@ -51,6 +48,11 @@ services:
- driver: nvidia
count: all
capabilities: [gpu]
logging:
driver: json-file
options:
max-size: "50m"
max-file: "5"
volumes:
ashim-data:
+6 -6
View File
@@ -1,9 +1,8 @@
name: ashim
# Usage:
# CPU: docker compose up -d
# GPU: docker compose -f docker-compose-gpu.yml up -d
# (requires NVIDIA GPU + nvidia-container-toolkit; Linux/Windows WSL2 only)
# CPU deployment — no GPU required.
# Usage: docker compose up -d
# GPU: docker compose -f docker-compose-gpu.yml up -d
services:
ashim:
@@ -15,8 +14,8 @@ services:
ports:
- "1349:1349"
volumes:
- ashim-data:/data
- ashim-workspace:/tmp/workspace
- ashim-data:/data # Database, AI models, user files
- ashim-workspace:/tmp/workspace # Temp processing (auto-cleaned)
environment:
- AUTH_ENABLED=true
- DEFAULT_USERNAME=admin
@@ -32,6 +31,7 @@ services:
- RATE_LIMIT_PER_MIN=${RATE_LIMIT_PER_MIN:-0}
- MAX_USERS=${MAX_USERS:-0}
- SESSION_DURATION_HOURS=${SESSION_DURATION_HOURS:-168}
- TRUST_PROXY=${TRUST_PROXY:-true}
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:1349/api/v1/health"]
+38
View File
@@ -28,8 +28,46 @@ fi
# Fix ownership of mounted volumes so the non-root ashim user can write.
# This runs as root, fixes permissions, then drops to ashim via gosu.
if [ "$(id -u)" = "0" ]; then
# PUID/PGID support: remap the ashim user/group to match host UID/GID.
# This prevents permission conflicts when using bind mounts.
PUID="${PUID:-$(id -u ashim)}"
PGID="${PGID:-$(id -g ashim)}"
if [ "$PUID" = "0" ] || [ "$PGID" = "0" ]; then
echo "WARNING: PUID=0 or PGID=0 would run the app as root. Ignoring — using default ashim UID/GID." >&2
PUID=$(id -u ashim)
PGID=$(id -g ashim)
fi
CUR_UID=$(id -u ashim)
CUR_GID=$(id -g ashim)
if [ "$CUR_UID" != "$PUID" ] || [ "$CUR_GID" != "$PGID" ]; then
# Evict any conflicting user/group that holds the target UID/GID.
# Delete user first (may cascade-delete its primary group).
if [ "$CUR_UID" != "$PUID" ]; then
EXISTING_USER=$(getent passwd "$PUID" 2>/dev/null | cut -d: -f1 || true)
if [ -n "$EXISTING_USER" ] && [ "$EXISTING_USER" != "ashim" ]; then
deluser "$EXISTING_USER" 2>/dev/null || userdel "$EXISTING_USER" 2>/dev/null || true
fi
fi
if [ "$CUR_GID" != "$PGID" ]; then
EXISTING_GROUP=$(getent group "$PGID" 2>/dev/null | cut -d: -f1 || true)
if [ -n "$EXISTING_GROUP" ] && [ "$EXISTING_GROUP" != "ashim" ]; then
delgroup "$EXISTING_GROUP" 2>/dev/null || groupdel "$EXISTING_GROUP" 2>/dev/null || true
fi
groupmod -g "$PGID" ashim 2>/dev/null || true
fi
if [ "$CUR_UID" != "$PUID" ]; then
usermod -u "$PUID" ashim 2>/dev/null || true
fi
fi
# Chown writable directories (/data is the persistent volume, /tmp/workspace is ephemeral).
# /app and /opt/venv are read-only at runtime — no chown needed.
chown -R ashim:ashim /data /tmp/workspace 2>&1 || \
echo "WARNING: Could not fix volume permissions. Use named volumes (not Windows bind mounts) to avoid this. See docs for details." >&2
exec gosu ashim "$@"
fi