diff --git a/.env.example b/.env.example index 16a61c2444..a7be8ba1be 100644 --- a/.env.example +++ b/.env.example @@ -6,7 +6,6 @@ # │ Reference: docs/ENVIRONMENT.md for full details and usage scenarios. │ # └─────────────────────────────────────────────────────────────────────────────┘ - # ═══════════════════════════════════════════════════════════════════════════════ # 1. REQUIRED SECRETS — Must be set before first run! # ═══════════════════════════════════════════════════════════════════════════════ @@ -62,7 +61,6 @@ DISABLE_SQLITE_AUTO_BACKUP=false # Default: redis://localhost:6379 (or redis://redis:6379 in Docker) REDIS_URL=redis://localhost:6379 - # ═══════════════════════════════════════════════════════════════════════════════ # 3. NETWORK & PORTS # ═══════════════════════════════════════════════════════════════════════════════ @@ -159,6 +157,11 @@ OMNIROUTE_USE_TURBOPACK=1 # Values: production | development | Default: production NODE_ENV=production +# Container runtime — controls startup script behavior (permissions, advice). +# Values: docker | podman | Default: docker +# Set to "podman" when running under rootless Podman so the entrypoint +# gives the correct fix instructions (podman unshare chown vs sudo chown). +CONTAINER_HOST=docker # ═══════════════════════════════════════════════════════════════════════════════ # 4. SECURITY & AUTHENTICATION @@ -238,7 +241,6 @@ ALLOW_API_KEY_REVEAL=false # When unset, OmniRoute uses the per-feature defaults. Set to "false"/"0" to disable. # OUTBOUND_SSRF_GUARD_ENABLED=true - # ═══════════════════════════════════════════════════════════════════════════════ # 5. INPUT SANITIZATION & PII PROTECTION (FASE-01) # ═══════════════════════════════════════════════════════════════════════════════ @@ -271,7 +273,6 @@ ALLOW_API_KEY_REVEAL=false # PII_RESPONSE_SANITIZATION=false # PII_RESPONSE_SANITIZATION_MODE=redact # redact = mask PII | warn = log only | block = drop response - # ═══════════════════════════════════════════════════════════════════════════════ # 6. TOOL & ROUTING POLICIES # ═══════════════════════════════════════════════════════════════════════════════ @@ -291,7 +292,6 @@ ALLOW_API_KEY_REVEAL=false # Default: 5000 | Minimum: 1000 # OMNIROUTE_PAYLOAD_RULES_RELOAD_MS=5000 - # ═══════════════════════════════════════════════════════════════════════════════ # 7. URLS & CLOUD SYNC # ═══════════════════════════════════════════════════════════════════════════════ @@ -365,7 +365,6 @@ NEXT_PUBLIC_CLOUD_URL= #OMNIROUTE_OPENCODE_QUOTA_URL=https://opencode.ai/zen/go/v1/quota #OMNIROUTE_OPENCODE_GO_QUOTA_URL=https://api.z.ai/api/monitor/usage/quota/limit - # ═══════════════════════════════════════════════════════════════════════════════ # 8. OUTBOUND PROXY (Upstream Provider Calls) # ═══════════════════════════════════════════════════════════════════════════════ @@ -393,7 +392,6 @@ NEXT_PUBLIC_ENABLE_SOCKS5_PROXY=true # Used by: open-sse/services/claudeTurnstileSolver.ts # OMNIROUTE_TURNSTILE_IGNORE_TLS_ERRORS=false - # ═══════════════════════════════════════════════════════════════════════════════ # 9. CLI TOOL INTEGRATION # ═══════════════════════════════════════════════════════════════════════════════ @@ -423,7 +421,6 @@ NEXT_PUBLIC_ENABLE_SOCKS5_PROXY=true # CLI_QODER_BIN=qoder # CLI_QWEN_BIN=qwen - # ═══════════════════════════════════════════════════════════════════════════════ # 10. INTERNAL AGENT & MCP INTEGRATIONS # ═══════════════════════════════════════════════════════════════════════════════ @@ -554,7 +551,6 @@ PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES=70 # Default: ~/.gemini/antigravity-cli/antigravity-oauth-token #AGY_TOKEN_FILE= - # ═══════════════════════════════════════════════════════════════════════════════ # 11. OAUTH PROVIDER CREDENTIALS # ═══════════════════════════════════════════════════════════════════════════════ @@ -701,7 +697,6 @@ GITHUB_OAUTH_CLIENT_ID=Iv1.b507a08c87ecfe98 # CLI_USER_ID= # legacy alias for OMNIROUTE_USER_ID # SERVER_URL= # legacy alias for OMNIROUTE_SERVER - # ═══════════════════════════════════════════════════════════════════════════════ # 12. PROVIDER USER-AGENT OVERRIDES # ═══════════════════════════════════════════════════════════════════════════════ @@ -737,7 +732,6 @@ GEMINI_CLI_USER_AGENT="google-api-nodejs-client/10.3.0" # CODEX_USER_AGENT string. Used by: open-sse/config/codexClient.ts. # CODEX_CLIENT_VERSION=0.132.0 - # ═══════════════════════════════════════════════════════════════════════════════ # 13. CLI FINGERPRINT COMPATIBILITY (Anti-Detection) # ═══════════════════════════════════════════════════════════════════════════════ @@ -766,7 +760,6 @@ GEMINI_CLI_USER_AGENT="google-api-nodejs-client/10.3.0" #KIMI_CLI_VERSION=1.36.0 #KIMI_CODING_DEVICE_ID= - # ═══════════════════════════════════════════════════════════════════════════════ # 14. API KEY PROVIDERS # ═══════════════════════════════════════════════════════════════════════════════ @@ -789,7 +782,6 @@ GEMINI_CLI_USER_AGENT="google-api-nodejs-client/10.3.0" # OpenAI/Mistral/Together/Fireworks/NVIDIA configured via Dashboard → Providers # also work for embeddings. - # ═══════════════════════════════════════════════════════════════════════════════ # 15. TIMEOUT SETTINGS # ═══════════════════════════════════════════════════════════════════════════════ @@ -868,7 +860,6 @@ GEMINI_CLI_USER_AGENT="google-api-nodejs-client/10.3.0" # Default: 30000 (30 seconds) # SHUTDOWN_TIMEOUT_MS=30000 - # ═══════════════════════════════════════════════════════════════════════════════ # 16. LOGGING # ═══════════════════════════════════════════════════════════════════════════════ @@ -942,7 +933,6 @@ APP_LOG_TO_FILE=true # Default: 100000 # PROXY_LOGS_TABLE_MAX_ROWS=100000 - # ═══════════════════════════════════════════════════════════════════════════════ # 17. MEMORY OPTIMIZATION (Low-RAM / Docker) # ═══════════════════════════════════════════════════════════════════════════════ @@ -1022,7 +1012,6 @@ APP_LOG_TO_FILE=true # Used by: open-sse/utils/usageTracking.ts # USAGE_TOKEN_BUFFER=100 - # ═══════════════════════════════════════════════════════════════════════════════ # 18. PRICING SYNC # ═══════════════════════════════════════════════════════════════════════════════ @@ -1038,7 +1027,6 @@ APP_LOG_TO_FILE=true # Comma-separated data sources. Default: litellm # PRICING_SYNC_SOURCES=litellm - # ═══════════════════════════════════════════════════════════════════════════════ # 19. MODEL SYNC (Dev) # ═══════════════════════════════════════════════════════════════════════════════ @@ -1047,7 +1035,6 @@ APP_LOG_TO_FILE=true # Default: 86400 (24 hours) # MODELS_DEV_SYNC_INTERVAL=86400 - # ═══════════════════════════════════════════════════════════════════════════════ # 20. PROVIDER-SPECIFIC SETTINGS # ═══════════════════════════════════════════════════════════════════════════════ @@ -1128,7 +1115,6 @@ APP_LOG_TO_FILE=true # Used by: open-sse/config/providerRegistry.ts — allows Docker service names. # LOCAL_HOSTNAMES=omlx,mlx-audio - # ═══════════════════════════════════════════════════════════════════════════════ # 21. PROXY HEALTH # ═══════════════════════════════════════════════════════════════════════════════ @@ -1154,7 +1140,6 @@ APP_LOG_TO_FILE=true # Used by: src/lib/tokenHealthCheck.ts. Default: 3000. # HEALTHCHECK_STAGGER_MS=3000 - # ═══════════════════════════════════════════════════════════════════════════════ # 22. DEBUGGING # ═══════════════════════════════════════════════════════════════════════════════ @@ -1214,7 +1199,6 @@ APP_LOG_TO_FILE=true # Enable E2E test mode — relaxes auth and enables test harness hooks. # NEXT_PUBLIC_OMNIROUTE_E2E_MODE=true - # ═══════════════════════════════════════════════════════════════════════════════ # 23. GITHUB INTEGRATION (Issue Reporting) # ═══════════════════════════════════════════════════════════════════════════════ @@ -1232,7 +1216,6 @@ APP_LOG_TO_FILE=true # GITHUB_ISSUES_TOKEN when unset. # GITHUB_TOKEN= - # ═══════════════════════════════════════════════════════════════════════════════ # 24. PROVIDER QUOTAS, TUNNELS & SANDBOXED SKILLS # ═══════════════════════════════════════════════════════════════════════════════ @@ -1337,7 +1320,6 @@ APP_LOG_TO_FILE=true # SKILLS_SANDBOX_NETWORK_ENABLED=0 # SKILLS_ALLOWED_SANDBOX_IMAGES= - # ═══════════════════════════════════════════════════════════════════════════════ # 25. TEST & E2E # ═══════════════════════════════════════════════════════════════════════════════ @@ -1448,8 +1430,8 @@ INSPECTOR_MASK_SECRETS=true INSPECTOR_LLM_HOSTS_EXTRA= INSPECTOR_INTERNAL_INGEST_TOKEN= # Quota Sharing (Group B — planos 16+22) -QUOTA_STORE_DRIVER=sqlite # sqlite | redis -# QUOTA_STORE_REDIS_URL= # ex.: redis://localhost:6379 (apenas quando driver=redis) -# QUOTA_SATURATION_THRESHOLD=0.5 # 0..1; >= threshold ativa modo strict (sem empréstimo) -# QUOTA_SOFT_DEPRIORITIZE_FACTOR=0.7 # 0..1; multiplicador do score quando soft policy ativa -# QUOTA_CONSUMPTION_RETENTION_DAYS=14 # GC de buckets quota_consumption.updated_at antigos +QUOTA_STORE_DRIVER=sqlite # sqlite | redis +# QUOTA_STORE_REDIS_URL= # ex.: redis://localhost:6379 (apenas quando driver=redis) +# QUOTA_SATURATION_THRESHOLD=0.5 # 0..1; >= threshold ativa modo strict (sem empréstimo) +# QUOTA_SOFT_DEPRIORITIZE_FACTOR=0.7 # 0..1; multiplicador do score quando soft policy ativa +# QUOTA_CONSUMPTION_RETENTION_DAYS=14 # GC de buckets quota_consumption.updated_at antigos diff --git a/README.md b/README.md index ab938f7545..5bfa7ec184 100644 --- a/README.md +++ b/README.md @@ -540,6 +540,22 @@ devbox run npm run dev 📖 [Docker Guide](docs/guides/DOCKER_GUIDE.md) — Compose profiles, Caddy HTTPS, Cloudflare tunnels. +**🦭 Podman** + +```bash +# 1. Build the image +podman build --target runner-base -t omniroute:base . + +# 2. Fix data directory permissions for rootless Podman +mkdir -p data && podman unshare chown 1000:1000 ./data + +# 3. Set runtime in .env, then run (see contrib/podman/ for Quadlet) +echo "CONTAINER_HOST=podman" >> .env +podman compose --profile base up -d +``` + +📖 [Podman Guide](contrib/podman/README.md) — Quadlet setup, podman-compose, Quadlet. +
@@ -779,6 +795,7 @@ Compression: aggressive (~50%) → double your free quota · Cost: $0/mo | Document | Description | | ---------------------------------------------------- | -------------------------------------------------------------- | | [Docker Guide](docs/guides/DOCKER_GUIDE.md) | Docker run, Compose profiles, Caddy HTTPS, tunnels, image tags | +| [Podman Guide](contrib/podman/README.md) | Quadlet systemd integration, podman-compose, SELinux | | [VM Deployment](docs/ops/VM_DEPLOYMENT_GUIDE.md) | Complete guide: VM + nginx + Cloudflare setup | | [Fly.io Deployment](docs/ops/FLY_IO_DEPLOYMENT_GUIDE.md) | Deploy to Fly.io with persistent storage | | [Termux Guide](docs/guides/TERMUX_GUIDE.md) | Run OmniRoute on Android via Termux | diff --git a/contrib/podman/README.md b/contrib/podman/README.md new file mode 100644 index 0000000000..7ebc6fc9b0 --- /dev/null +++ b/contrib/podman/README.md @@ -0,0 +1,128 @@ +# Podman Deployment + +Run OmniRoute with Podman via **Quadlet** (systemd integration) or **podman compose**. + +--- + +## Option A: Quadlet (recommended) + +### 1. Build the image + +```bash +cd /path/to/omniroute +podman build --target runner-base -t omniroute:base . +# For web-cookie providers (gemini-web, claude-web, claude-turnstile): +podman build --target runner-web -t omniroute:web . +# For CLI tool support: +podman build --target runner-cli -t omniroute:cli . +``` + +### 2. Copy Quadlet files to the systemd directory + +```bash +mkdir -p ~/.config/containers/systemd/omniroute +cp contrib/podman/*.container ~/.config/containers/systemd/omniroute/ +cp contrib/podman/*.network ~/.config/containers/systemd/omniroute/ +cp contrib/podman/*.volume ~/.config/containers/systemd/omniroute/ +``` + +### 3. Mount the project .env for secrets + +Edit `~/.config/containers/systemd/omniroute/omniroute.container` and +uncomment/replace the `EnvironmentFile` line with the absolute path to +your project `.env`: + +``` +EnvironmentFile=/home/USER/code/docker/OmniRoute/.env +``` + +Make sure `CONTAINER_HOST=podman` is set in that `.env`. + +Alternatively, edit the env vars directly in the `.container` file. + +### 4. Reload systemd and start + +```bash +systemctl --user daemon-reload +systemctl --user start omniroute-redis +systemctl --user start omniroute +``` + +### 5. Verify + +```bash +systemctl --user status omniroute +curl http://localhost:20128/v1/models +``` + +To follow logs: + +```bash +journalctl --user -u omniroute -f +``` + +### 6. Enable on boot + +```bash +systemctl --user enable omniroute-redis +systemctl --user enable omniroute +``` + +--- + +## Option B: podman compose + +The project's `docker-compose.yml` now works with both Docker and Podman. +Just set `CONTAINER_HOST=podman` in `.env` before starting. + +### 1. Prepare the data directory + +Rootless Podman maps container UIDs into a subordinate range. The +`node` user (UID 1000) inside the container maps to a different UID +on the host, so it cannot write to `./data` owned by your host user. + +Fix the ownership **before** starting: + +```bash +mkdir -p data +podman unshare chown 1000:1000 ./data +``` + +### 2. Set the runtime in `.env` + +Make sure `.env` contains: + +```env +CONTAINER_HOST=podman +``` + +### 3. Start + +```bash +podman compose --profile base up -d +``` + +### Profiles + +Same profiles as `docker compose`: + +| Profile | Command | +| ------------------------------ | ---------------------------------------------------- | +| `base` (no CLIs) | `podman compose --profile base up -d` | +| `web` (+Chromium/Playwright) | `podman compose --profile web up -d` | +| `cli` (+CLI tools) | `podman compose --profile cli up -d` | +| `host` (host-mounted binaries) | `podman compose --profile host up -d` | +| `cliproxyapi` (sidecar) | `podman compose --profile cliproxyapi up -d` | + +--- + +## How it works + +The `docker-compose.yml` uses fully-qualified image names +(`docker.io/library/redis:7-alpine`) and flat variable expansions so it +works with both Docker and Podman without a separate compose file. + +The entrypoint script (`check-permissions.sh`) reads `CONTAINER_HOST` +from `.env` to give the correct fix instructions: +- **docker**: `sudo chown -R ... ./data` +- **podman**: `podman unshare chown 1000:1000 ./data` diff --git a/contrib/podman/omniroute-redis.container b/contrib/podman/omniroute-redis.container new file mode 100644 index 0000000000..326ac92559 --- /dev/null +++ b/contrib/podman/omniroute-redis.container @@ -0,0 +1,26 @@ +[Unit] +Description=OmniRoute Redis (rate-limiter backend) +After=network-online.target +Wants=network-online.target + +[Container] +Image=docker.io/redis:7-alpine +ContainerName=omniroute-redis + +Network=omniroute.network +NetworkAlias=redis + +Volume=omniroute-redis.volume:/data + +Exec=redis-server --save 60 1 --loglevel warning + +HealthCmd=/bin/sh -c "redis-cli ping | grep -q PONG" +HealthInterval=10s +HealthRetries=3 +HealthTimeout=5s + +[Service] +Restart=always + +[Install] +WantedBy=default.target diff --git a/contrib/podman/omniroute-redis.volume b/contrib/podman/omniroute-redis.volume new file mode 100644 index 0000000000..e14c448891 --- /dev/null +++ b/contrib/podman/omniroute-redis.volume @@ -0,0 +1,3 @@ +# Persistent Redis data +[Volume] +Label=omniroute diff --git a/contrib/podman/omniroute.container b/contrib/podman/omniroute.container new file mode 100644 index 0000000000..846e681a27 --- /dev/null +++ b/contrib/podman/omniroute.container @@ -0,0 +1,50 @@ +[Unit] +Description=OmniRoute — Unified AI Proxy +After=network-online.target +Wants=network-online.target +Requires=omniroute-redis.service +After=omniroute-redis.service + +[Container] +# Use this image if your building locally +Image=localhost/omniroute:base +# Use this image if you are pulling from Docker Hub +# Image=diegosouzapw/omniroute:base + +ContainerName=omniroute + +Network=omniroute.network +PublishPort=20128:20128 +PublishPort=20129:20129 + +Volume=omniroute.volume:/app/data:Z + +# Use a bind mount if you need direct host access to the data: +# Volume=/home/USER/code/docker/OmniRoute/data:/app/data:Z + +Environment=DATA_DIR=/app/data +Environment=PORT=20128 +Environment=DASHBOARD_PORT=20128 +Environment=API_PORT=20129 +Environment=API_HOST=0.0.0.0 +Environment=REDIS_URL=redis://redis:6379 +Environment=JWT_SECRET=change-me-to-a-random-base64-string +Environment=API_KEY_SECRET=change-me-to-a-random-base64-string +Environment=INITIAL_PASSWORD=change-me-to-a-random-hex-string +Environment=NODE_ENV=production +Environment=REQUIRE_API_KEY=true + +# Load additional secrets (API keys, OAuth creds) from the project .env: +# EnvironmentFile=%h/code/docker/OmniRoute/.env + +HealthCmd=node /app/healthcheck.mjs +HealthInterval=30s +HealthRetries=3 +HealthTimeout=5s +HealthStartPeriod=15s + +[Service] +Restart=always + +[Install] +WantedBy=default.target diff --git a/contrib/podman/omniroute.network b/contrib/podman/omniroute.network new file mode 100644 index 0000000000..4f618e9943 --- /dev/null +++ b/contrib/podman/omniroute.network @@ -0,0 +1,2 @@ +[Network] +Label=omniroute diff --git a/contrib/podman/omniroute.volume b/contrib/podman/omniroute.volume new file mode 100644 index 0000000000..d0d3ae8cbc --- /dev/null +++ b/contrib/podman/omniroute.volume @@ -0,0 +1,3 @@ +# OmniRoute persistent data (SQLite DB, logs, backups) +[Volume] +Label=omniroute diff --git a/docker-compose.yml b/docker-compose.yml index 8a249e335f..e30f4c0d2a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -27,7 +27,7 @@ x-common: &common environment: - DATA_DIR=/app/data # Must match the volume mount below - PORT=${PORT:-20128} - - DASHBOARD_PORT=${DASHBOARD_PORT:-${PORT:-20128}} + - DASHBOARD_PORT=${DASHBOARD_PORT:-20128} - API_PORT=${API_PORT:-20129} - API_HOST=${API_HOST:-0.0.0.0} - REDIS_URL=${REDIS_URL:-redis://redis:6379} @@ -43,7 +43,7 @@ x-common: &common services: # ── Redis (Rate Limiter Backend) ────────────────────────────────── redis: - image: redis:7-alpine + image: docker.io/library/redis:7-alpine container_name: omniroute-redis restart: unless-stopped ports: @@ -66,7 +66,7 @@ services: target: runner-base image: omniroute:base ports: - - "${DASHBOARD_PORT:-${PORT:-20128}}:${DASHBOARD_PORT:-${PORT:-20128}}" + - "${DASHBOARD_PORT:-20128}:${DASHBOARD_PORT:-20128}" - "${API_PORT:-20129}:${API_PORT:-20129}" profiles: - base @@ -83,7 +83,7 @@ services: target: runner-web image: omniroute:web ports: - - "${DASHBOARD_PORT:-${PORT:-20128}}:${DASHBOARD_PORT:-${PORT:-20128}}" + - "${DASHBOARD_PORT:-20128}:${DASHBOARD_PORT:-20128}" - "${API_PORT:-20129}:${API_PORT:-20129}" profiles: - web @@ -97,7 +97,7 @@ services: target: runner-cli image: omniroute:cli ports: - - "${DASHBOARD_PORT:-${PORT:-20128}}:${DASHBOARD_PORT:-${PORT:-20128}}" + - "${DASHBOARD_PORT:-20128}:${DASHBOARD_PORT:-20128}" - "${API_PORT:-20129}:${API_PORT:-20129}" volumes: - ./data:/app/data @@ -116,12 +116,12 @@ services: target: runner-base image: omniroute:base ports: - - "${DASHBOARD_PORT:-${PORT:-20128}}:${DASHBOARD_PORT:-${PORT:-20128}}" + - "${DASHBOARD_PORT:-20128}:${DASHBOARD_PORT:-20128}" - "${API_PORT:-20129}:${API_PORT:-20129}" environment: - DATA_DIR=/app/data - PORT=${PORT:-20128} - - DASHBOARD_PORT=${DASHBOARD_PORT:-${PORT:-20128}} + - DASHBOARD_PORT=${DASHBOARD_PORT:-20128} - API_PORT=${API_PORT:-20129} - API_HOST=${API_HOST:-0.0.0.0} - CLI_MODE=host diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index c3be99fc53..fd103553fe 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -877,6 +877,7 @@ Provider quota endpoints, network tunnels (Tailscale, Ngrok, MITM debug proxy), | `DB_BACKUP_MAX_FILES` | `20` | `src/lib/db/backup.ts` | Maximum SQLite backup files retained on disk. | | `DB_BACKUP_RETENTION_DAYS` | `0` | `src/lib/db/backup.ts` | Maximum age (days) of retained backups. `0` disables age-based pruning. | | `OMNIROUTE_TLS_PROXY_URL` | _(unset)_ | `open-sse/services/chatgptTlsClient.ts` | Override the TLS sidecar URL for tests. Production should leave unset. | +| `CONTAINER_HOST` | `docker` | `scripts/check-permissions.sh` | Container runtime hint for the entrypoint permission check. Set to `podman` under rootless Podman so the fix instructions use `podman unshare chown` instead of `sudo chown`. | | `QUOTA_STORE_DRIVER` | `sqlite` | `src/lib/quota/storeFactory.ts` | Quota-share consumption store backend: `sqlite` (default) or `redis`. | | `QUOTA_STORE_REDIS_URL` | _(unset)_ | `src/lib/quota/storeFactory.ts` | Redis connection string used when `QUOTA_STORE_DRIVER=redis` (e.g. `redis://localhost:6379`). | | `QUOTA_SATURATION_THRESHOLD` | `0.5` | `src/lib/quota/enforce.ts` | Pool saturation ratio (0..1); at/above it the pool enters strict mode (no borrowing). | diff --git a/next.config.mjs b/next.config.mjs index ce4252d2bf..1dc08a6dcf 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -104,6 +104,10 @@ const nextConfig = { serverActions: { bodySizeLimit: process.env.OMNIROUTE_SERVER_ACTIONS_BODY_LIMIT || "50mb", }, + // Next.js proxy (middleware) has a default 10MB body clone limit. File + // uploads (OpenAI-compatible /v1/files) routinely exceed this. Match the + // 512 MB server-side cap; tune via env if needed. + proxyClientMaxBodySize: process.env.NEXT_PROXY_BODY_LIMIT || "512mb", }, outputFileTracingRoot: projectRoot, outputFileTracingIncludes: { diff --git a/scripts/check-permissions.sh b/scripts/check-permissions.sh index a58d046351..0c25116673 100755 --- a/scripts/check-permissions.sh +++ b/scripts/check-permissions.sh @@ -8,11 +8,18 @@ if [ -n "$OMNIROUTE_MEMORY_MB" ]; then export NODE_OPTIONS="${NODE_OPTIONS:-} --max-old-space-size=${OMNIROUTE_MEMORY_MB}" fi -if [ -d "/app/data" ] && [ ! -w "/app/data" ]; then - echo "WARNING: /app/data is not writable by the current user (UID $(id -u))." - echo "Run this on the Docker host to fix:" - echo " sudo chown -R $(id -u):$(id -g) /app/data" - echo " chmod -R u+rwX ./data" +DATA_PATH="${DATA_DIR:-/app/data}" +if [ -d "$DATA_PATH" ] && [ ! -w "$DATA_PATH" ]; then + echo "WARNING: $DATA_PATH is not writable by the current user (UID $(id -u))." + if [ "${CONTAINER_HOST:-}" = "podman" ]; then + echo "Rootless Podman maps container UIDs into a subordinate range." + echo "Run this on the host to fix (using the host-side bind-mount path):" + echo " podman unshare chown -R $(id -u):$(id -g) " + else + echo "Run this on the Docker host to fix (using the host-side bind-mount path):" + echo " sudo chown -R $(id -u):$(id -g) " + echo " chmod -R u+rwX " + fi fi exec "$@" diff --git a/src/shared/middleware/bodySizeGuard.ts b/src/shared/middleware/bodySizeGuard.ts index d9ef26fce3..eefd2b23d2 100644 --- a/src/shared/middleware/bodySizeGuard.ts +++ b/src/shared/middleware/bodySizeGuard.ts @@ -26,6 +26,9 @@ export const MAX_BODY_BYTES_IMPORT = 100 * 1024 * 1024; /** Larger limit for audio transcription uploads: 100 MB */ export const MAX_BODY_BYTES_AUDIO = 100 * 1024 * 1024; +/** Larger limit for file uploads: 500 MB */ +export const MAX_BODY_BYTES_FILE = 500 * 1024 * 1024; + /** Configured limit — reads from env or falls back to 10 MB */ export const MAX_BODY_BYTES = parseRequestBodyLimitBytes(process.env.MAX_BODY_SIZE_BYTES); @@ -34,6 +37,7 @@ type BodySizeRule = { prefix: string; limit: number }; const ROUTE_LIMITS: BodySizeRule[] = [ { prefix: "/api/db-backups/import", limit: MAX_BODY_BYTES_IMPORT }, { prefix: "/api/v1/audio/transcriptions", limit: MAX_BODY_BYTES_AUDIO }, + { prefix: "/api/v1/files", limit: MAX_BODY_BYTES_FILE }, ]; export function getDefaultRequestBodyLimitMb(): number { @@ -62,7 +66,7 @@ export function checkBodySize(request: Request, limit: number = MAX_BODY_BYTES): const contentLength = request.headers.get("content-length"); if (contentLength) { - const bytes = parseInt(contentLength, 10); + const bytes = Number.parseInt(contentLength, 10); if (!Number.isNaN(bytes) && bytes > limit) { return new Response( JSON.stringify({ diff --git a/tests/unit/body-size-guard.test.ts b/tests/unit/body-size-guard.test.ts index 2f5b967ea2..fd877c7223 100644 --- a/tests/unit/body-size-guard.test.ts +++ b/tests/unit/body-size-guard.test.ts @@ -2,6 +2,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import { MAX_BODY_BYTES_AUDIO, + MAX_BODY_BYTES_FILE, getBodySizeLimit, checkBodySize, } from "../../src/shared/middleware/bodySizeGuard.ts"; @@ -40,3 +41,39 @@ test("checkBodySize reports the configured request limit in 413 responses", asyn assert.equal(body.error.code, "PAYLOAD_TOO_LARGE"); assert.match(body.error.message, /100 MB/); }); + +test("/api/v1/files route has 512 MB dedicated limit floor", () => { + const limit = getBodySizeLimit("/api/v1/files", { maxBodySizeMb: 1 }); + assert.equal(limit, MAX_BODY_BYTES_FILE); +}); + +test("/api/v1/files route guard allows 500 MB file upload", () => { + const thirtyMb = 500 * 1024 * 1024; + const request = new Request("http://localhost/api/v1/files", { + method: "POST", + headers: { "content-length": String(thirtyMb) }, + }); + assert.equal(checkBodySize(request, getBodySizeLimit("/api/v1/files", { maxBodySizeMb: 10 })), null); +}); + +test("/api/v1/files route guard rejects >512 MB file upload", async () => { + const tooBig = 600 * 1024 * 1024; + const request = new Request("http://localhost/api/v1/files", { + method: "POST", + headers: { "content-length": String(tooBig) }, + }); + const response = checkBodySize(request, getBodySizeLimit("/api/v1/files", { maxBodySizeMb: 10 })); + assert.ok(response); + assert.equal(response.status, 413); + const body = await response.json(); + assert.equal(body.error.code, "PAYLOAD_TOO_LARGE"); +}); + +test("/api/v1/files route guard allows 15 MB (10 MB+ real-world scenario)", () => { + const fifteenMb = 15 * 1024 * 1024; + const request = new Request("http://localhost/api/v1/files", { + method: "POST", + headers: { "content-length": String(fifteenMb) }, + }); + assert.equal(checkBodySize(request, getBodySizeLimit("/api/v1/files")), null); +});