From 66e503730d7c884889a7584552c2aad414e53ed9 Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Sat, 6 Jun 2026 16:05:59 +0800 Subject: [PATCH] fix: resolve 6 production Sentry errors - Prevent @fastify/static double-registration crash via decorateReply guard - Fix non-ASCII filename header encoding (X-Output-Filename + RFC 5987 Content-Disposition) - Add EACCES error handling to all startup mkdir calls with actionable messages - Add WAL autocheckpoint and journal size limit to prevent unbounded SQLite growth - Fix Python sidecar EPIPE handling to reject pending requests and trigger restart - Ensure Docker entrypoint creates all subdirectories before chown --- apps/api/src/db/index.ts | 15 +++++++++++++-- apps/api/src/lib/cleanup.ts | 13 +++++++++++-- apps/api/src/lib/feature-status.ts | 17 ++++++++++++++--- apps/api/src/lib/file-storage.ts | 9 ++++++++- apps/api/src/lib/workspace.ts | 11 +++++++++-- apps/api/src/plugins/static.ts | 1 + apps/api/src/routes/files.ts | 5 ++++- apps/api/src/routes/tools/optimize-for-web.ts | 3 +-- apps/api/src/routes/user-files.ts | 2 +- docker/entrypoint.sh | 5 ++++- packages/ai/src/bridge.ts | 15 ++++++++++++++- tests/unit/api/static-upload.test.ts | 4 ++++ 12 files changed, 84 insertions(+), 16 deletions(-) diff --git a/apps/api/src/db/index.ts b/apps/api/src/db/index.ts index 977b37fd..651faa9e 100644 --- a/apps/api/src/db/index.ts +++ b/apps/api/src/db/index.ts @@ -5,8 +5,17 @@ import { drizzle } from "drizzle-orm/better-sqlite3"; import { env } from "../config.js"; import * as schema from "./schema.js"; -// Ensure data directory exists -mkdirSync(dirname(env.DB_PATH), { recursive: true }); +try { + mkdirSync(dirname(env.DB_PATH), { recursive: true }); +} catch (err: unknown) { + const code = (err as NodeJS.ErrnoException).code; + if (code === "EACCES") { + console.error( + `FATAL: Cannot write to data directory "${dirname(env.DB_PATH)}". Check volume permissions (PUID/PGID).`, + ); + } + throw err; +} const sqlite: DatabaseType = new Database(env.DB_PATH); @@ -17,6 +26,8 @@ sqlite.pragma("busy_timeout = 10000"); sqlite.pragma("journal_mode = WAL"); sqlite.pragma("synchronous = NORMAL"); sqlite.pragma("foreign_keys = ON"); +sqlite.pragma("wal_autocheckpoint = 1000"); +sqlite.pragma("journal_size_limit = 67108864"); export const db = drizzle(sqlite, { schema }); export { schema, sqlite }; diff --git a/apps/api/src/lib/cleanup.ts b/apps/api/src/lib/cleanup.ts index 0638525e..20dd0245 100644 --- a/apps/api/src/lib/cleanup.ts +++ b/apps/api/src/lib/cleanup.ts @@ -44,8 +44,17 @@ export function shouldRunStartupCleanup(): boolean { } export function startCleanupCron(): { stop: () => void } { - // Ensure workspace directory exists - mkdirSync(env.WORKSPACE_PATH, { recursive: true }); + try { + mkdirSync(env.WORKSPACE_PATH, { recursive: true }); + } catch (err: unknown) { + const code = (err as NodeJS.ErrnoException).code; + if (code === "EACCES") { + console.error( + `WARNING: Cannot create workspace directory "${env.WORKSPACE_PATH}". Check volume permissions (PUID/PGID).`, + ); + } + throw err; + } const intervalMs = env.CLEANUP_INTERVAL_MINUTES * 60 * 1000; diff --git a/apps/api/src/lib/feature-status.ts b/apps/api/src/lib/feature-status.ts index e0fa95ec..bdc32e7c 100644 --- a/apps/api/src/lib/feature-status.ts +++ b/apps/api/src/lib/feature-status.ts @@ -41,9 +41,20 @@ export function getManifestPath(): string { export function ensureAiDirs(): void { if (!isDockerEnvironment()) return; - mkdirSync(join(AI_DIR, "venv"), { recursive: true }); - mkdirSync(MODELS_DIR, { recursive: true }); - mkdirSync(join(AI_DIR, "pip-cache"), { recursive: true }); + try { + mkdirSync(join(AI_DIR, "venv"), { recursive: true }); + mkdirSync(MODELS_DIR, { recursive: true }); + mkdirSync(join(AI_DIR, "pip-cache"), { recursive: true }); + } catch (err: unknown) { + const code = (err as NodeJS.ErrnoException).code; + if (code === "EACCES") { + console.error( + `WARNING: Cannot create AI directories under "${AI_DIR}". AI features will be unavailable. Check volume permissions (PUID/PGID).`, + ); + return; + } + throw err; + } } // ── Docker detection ──────────────────────────────────────────────────── diff --git a/apps/api/src/lib/file-storage.ts b/apps/api/src/lib/file-storage.ts index 3aeec195..87b6fbb4 100644 --- a/apps/api/src/lib/file-storage.ts +++ b/apps/api/src/lib/file-storage.ts @@ -115,7 +115,14 @@ let thumbDirReady = false; async function ensureThumbDir(): Promise { if (thumbDirReady) return; - await mkdir(join(env.FILES_STORAGE_PATH, THUMB_DIR), { recursive: true }); + try { + await mkdir(join(env.FILES_STORAGE_PATH, THUMB_DIR), { recursive: true }); + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException).code === "EACCES") { + throw Object.assign(new Error("Storage directory is not writable"), { statusCode: 503 }); + } + throw err; + } thumbDirReady = true; } diff --git a/apps/api/src/lib/workspace.ts b/apps/api/src/lib/workspace.ts index a3dc6e27..d01dbe03 100644 --- a/apps/api/src/lib/workspace.ts +++ b/apps/api/src/lib/workspace.ts @@ -61,8 +61,15 @@ async function checkWorkspaceCapacity(workspaceRoot: string): Promise { export async function createWorkspace(jobId: string): Promise { await checkWorkspaceCapacity(env.WORKSPACE_PATH); const root = getWorkspacePath(jobId); - await mkdir(join(root, "input"), { recursive: true }); - await mkdir(join(root, "output"), { recursive: true }); + try { + await mkdir(join(root, "input"), { recursive: true }); + await mkdir(join(root, "output"), { recursive: true }); + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException).code === "EACCES") { + throw Object.assign(new Error("Workspace directory is not writable"), { statusCode: 503 }); + } + throw err; + } return root; } diff --git a/apps/api/src/plugins/static.ts b/apps/api/src/plugins/static.ts index 042fb6a5..beb5107f 100644 --- a/apps/api/src/plugins/static.ts +++ b/apps/api/src/plugins/static.ts @@ -18,6 +18,7 @@ export async function registerStatic(app: FastifyInstance) { root: webDistPath, prefix: "/", wildcard: false, + decorateReply: !app.hasReplyDecorator("sendFile"), }); // SPA fallback — serve index.html for all non-API routes diff --git a/apps/api/src/routes/files.ts b/apps/api/src/routes/files.ts index c0693b81..b851b045 100644 --- a/apps/api/src/routes/files.ts +++ b/apps/api/src/routes/files.ts @@ -128,7 +128,10 @@ export async function fileRoutes(app: FastifyInstance): Promise { return reply .header("Content-Type", contentType) - .header("Content-Disposition", `attachment; filename="${encodeURIComponent(filename)}"`) + .header( + "Content-Disposition", + `attachment; filename="${encodeURIComponent(filename)}"; filename*=UTF-8''${encodeURIComponent(filename)}`, + ) .send(buffer); }, ); diff --git a/apps/api/src/routes/tools/optimize-for-web.ts b/apps/api/src/routes/tools/optimize-for-web.ts index 653ad645..ce871035 100644 --- a/apps/api/src/routes/tools/optimize-for-web.ts +++ b/apps/api/src/routes/tools/optimize-for-web.ts @@ -159,8 +159,7 @@ export function registerOptimizeForWeb(app: FastifyInstance) { reply.header("Content-Type", result.contentType); reply.header("X-Original-Size", String(fileBuffer.length)); reply.header("X-Processed-Size", String(result.buffer.length)); - const safeFilename = encodeURIComponent(result.filename).replace(/[^ -~]/g, ""); - reply.header("X-Output-Filename", safeFilename); + reply.header("X-Output-Filename", encodeURIComponent(result.filename)); return reply.send(result.buffer); } catch (err) { const message = err instanceof Error ? err.message : "Preview processing failed"; diff --git a/apps/api/src/routes/user-files.ts b/apps/api/src/routes/user-files.ts index 1597ed62..6d874512 100644 --- a/apps/api/src/routes/user-files.ts +++ b/apps/api/src/routes/user-files.ts @@ -388,7 +388,7 @@ export async function userFileRoutes(app: FastifyInstance): Promise { .header("Content-Type", file.mimeType) .header( "Content-Disposition", - `attachment; filename="${encodeURIComponent(file.originalName)}"`, + `attachment; filename="${encodeURIComponent(file.originalName)}"; filename*=UTF-8''${encodeURIComponent(file.originalName)}`, ) .send(stream); }, diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index c58d484d..1639df33 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -75,8 +75,11 @@ if [ "$(id -u)" = "0" ]; then fi fi + # Ensure all writable subdirectories exist before chown + mkdir -p /data/files /data/ai/models /data/ai/pip-cache /data/ai/venv /tmp/workspace + # Chown writable directories (/data is the persistent volume, /tmp/workspace is ephemeral). - # /app and /opt/venv are read-only at runtime — no chown needed. + # /app and /opt/venv are read-only at runtime -- no chown needed. chown -R snapotter:snapotter /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 diff --git a/packages/ai/src/bridge.ts b/packages/ai/src/bridge.ts index 9e47e5f1..cbfda5ee 100644 --- a/packages/ai/src/bridge.ts +++ b/packages/ai/src/bridge.ts @@ -153,7 +153,20 @@ function startDispatcher(): ChildProcess | null { env: buildMinimalEnv(), }); - child.stdin?.on("error", () => {}); + child.stdin?.on("error", (err: NodeJS.ErrnoException) => { + if (err.code === "EPIPE" || err.code === "ERR_STREAM_DESTROYED") { + console.error( + `[bridge] Dispatcher stdin pipe broken (${err.code}), rejecting pending requests`, + ); + for (const [id, req] of pendingRequests.entries()) { + req.reject(new Error("Python dispatcher stdin closed unexpectedly")); + pendingRequests.delete(id); + } + recordCrash(); + dispatcher = null; + dispatcherReady = false; + } + }); let stderrBuffer = ""; diff --git a/tests/unit/api/static-upload.test.ts b/tests/unit/api/static-upload.test.ts index 1967a1ae..1f40825c 100644 --- a/tests/unit/api/static-upload.test.ts +++ b/tests/unit/api/static-upload.test.ts @@ -38,6 +38,7 @@ describe("registerStatic", () => { const app = { register: vi.fn().mockResolvedValue(undefined), setNotFoundHandler: vi.fn(), + hasReplyDecorator: vi.fn().mockReturnValue(false), log: { warn: vi.fn() }, }; @@ -46,6 +47,7 @@ describe("registerStatic", () => { root: expect.stringContaining("web/dist"), prefix: "/", wildcard: false, + decorateReply: true, }); expect(app.setNotFoundHandler).toHaveBeenCalled(); }); @@ -58,6 +60,7 @@ describe("registerStatic", () => { setNotFoundHandler: vi.fn((handler: typeof notFoundHandler) => { notFoundHandler = handler; }), + hasReplyDecorator: vi.fn().mockReturnValue(false), log: { warn: vi.fn() }, }; @@ -77,6 +80,7 @@ describe("registerStatic", () => { setNotFoundHandler: vi.fn((handler: typeof notFoundHandler) => { notFoundHandler = handler; }), + hasReplyDecorator: vi.fn().mockReturnValue(false), log: { warn: vi.fn() }, };