mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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
This commit is contained in:
@@ -5,8 +5,17 @@ import { drizzle } from "drizzle-orm/better-sqlite3";
|
|||||||
import { env } from "../config.js";
|
import { env } from "../config.js";
|
||||||
import * as schema from "./schema.js";
|
import * as schema from "./schema.js";
|
||||||
|
|
||||||
// Ensure data directory exists
|
try {
|
||||||
mkdirSync(dirname(env.DB_PATH), { recursive: true });
|
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);
|
const sqlite: DatabaseType = new Database(env.DB_PATH);
|
||||||
|
|
||||||
@@ -17,6 +26,8 @@ sqlite.pragma("busy_timeout = 10000");
|
|||||||
sqlite.pragma("journal_mode = WAL");
|
sqlite.pragma("journal_mode = WAL");
|
||||||
sqlite.pragma("synchronous = NORMAL");
|
sqlite.pragma("synchronous = NORMAL");
|
||||||
sqlite.pragma("foreign_keys = ON");
|
sqlite.pragma("foreign_keys = ON");
|
||||||
|
sqlite.pragma("wal_autocheckpoint = 1000");
|
||||||
|
sqlite.pragma("journal_size_limit = 67108864");
|
||||||
|
|
||||||
export const db = drizzle(sqlite, { schema });
|
export const db = drizzle(sqlite, { schema });
|
||||||
export { schema, sqlite };
|
export { schema, sqlite };
|
||||||
|
|||||||
@@ -44,8 +44,17 @@ export function shouldRunStartupCleanup(): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function startCleanupCron(): { stop: () => void } {
|
export function startCleanupCron(): { stop: () => void } {
|
||||||
// Ensure workspace directory exists
|
try {
|
||||||
mkdirSync(env.WORKSPACE_PATH, { recursive: true });
|
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;
|
const intervalMs = env.CLEANUP_INTERVAL_MINUTES * 60 * 1000;
|
||||||
|
|
||||||
|
|||||||
@@ -41,9 +41,20 @@ export function getManifestPath(): string {
|
|||||||
|
|
||||||
export function ensureAiDirs(): void {
|
export function ensureAiDirs(): void {
|
||||||
if (!isDockerEnvironment()) return;
|
if (!isDockerEnvironment()) return;
|
||||||
mkdirSync(join(AI_DIR, "venv"), { recursive: true });
|
try {
|
||||||
mkdirSync(MODELS_DIR, { recursive: true });
|
mkdirSync(join(AI_DIR, "venv"), { recursive: true });
|
||||||
mkdirSync(join(AI_DIR, "pip-cache"), { 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 ────────────────────────────────────────────────────
|
// ── Docker detection ────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -115,7 +115,14 @@ let thumbDirReady = false;
|
|||||||
|
|
||||||
async function ensureThumbDir(): Promise<void> {
|
async function ensureThumbDir(): Promise<void> {
|
||||||
if (thumbDirReady) return;
|
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;
|
thumbDirReady = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -61,8 +61,15 @@ async function checkWorkspaceCapacity(workspaceRoot: string): Promise<void> {
|
|||||||
export async function createWorkspace(jobId: string): Promise<string> {
|
export async function createWorkspace(jobId: string): Promise<string> {
|
||||||
await checkWorkspaceCapacity(env.WORKSPACE_PATH);
|
await checkWorkspaceCapacity(env.WORKSPACE_PATH);
|
||||||
const root = getWorkspacePath(jobId);
|
const root = getWorkspacePath(jobId);
|
||||||
await mkdir(join(root, "input"), { recursive: true });
|
try {
|
||||||
await mkdir(join(root, "output"), { recursive: true });
|
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;
|
return root;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export async function registerStatic(app: FastifyInstance) {
|
|||||||
root: webDistPath,
|
root: webDistPath,
|
||||||
prefix: "/",
|
prefix: "/",
|
||||||
wildcard: false,
|
wildcard: false,
|
||||||
|
decorateReply: !app.hasReplyDecorator("sendFile"),
|
||||||
});
|
});
|
||||||
|
|
||||||
// SPA fallback — serve index.html for all non-API routes
|
// SPA fallback — serve index.html for all non-API routes
|
||||||
|
|||||||
@@ -128,7 +128,10 @@ export async function fileRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
|
|
||||||
return reply
|
return reply
|
||||||
.header("Content-Type", contentType)
|
.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);
|
.send(buffer);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -159,8 +159,7 @@ export function registerOptimizeForWeb(app: FastifyInstance) {
|
|||||||
reply.header("Content-Type", result.contentType);
|
reply.header("Content-Type", result.contentType);
|
||||||
reply.header("X-Original-Size", String(fileBuffer.length));
|
reply.header("X-Original-Size", String(fileBuffer.length));
|
||||||
reply.header("X-Processed-Size", String(result.buffer.length));
|
reply.header("X-Processed-Size", String(result.buffer.length));
|
||||||
const safeFilename = encodeURIComponent(result.filename).replace(/[^ -~]/g, "");
|
reply.header("X-Output-Filename", encodeURIComponent(result.filename));
|
||||||
reply.header("X-Output-Filename", safeFilename);
|
|
||||||
return reply.send(result.buffer);
|
return reply.send(result.buffer);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : "Preview processing failed";
|
const message = err instanceof Error ? err.message : "Preview processing failed";
|
||||||
|
|||||||
@@ -388,7 +388,7 @@ export async function userFileRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
.header("Content-Type", file.mimeType)
|
.header("Content-Type", file.mimeType)
|
||||||
.header(
|
.header(
|
||||||
"Content-Disposition",
|
"Content-Disposition",
|
||||||
`attachment; filename="${encodeURIComponent(file.originalName)}"`,
|
`attachment; filename="${encodeURIComponent(file.originalName)}"; filename*=UTF-8''${encodeURIComponent(file.originalName)}`,
|
||||||
)
|
)
|
||||||
.send(stream);
|
.send(stream);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -75,8 +75,11 @@ if [ "$(id -u)" = "0" ]; then
|
|||||||
fi
|
fi
|
||||||
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).
|
# 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 || \
|
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
|
echo "WARNING: Could not fix volume permissions. Use named volumes (not Windows bind mounts) to avoid this. See docs for details." >&2
|
||||||
|
|
||||||
|
|||||||
@@ -153,7 +153,20 @@ function startDispatcher(): ChildProcess | null {
|
|||||||
env: buildMinimalEnv(),
|
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 = "";
|
let stderrBuffer = "";
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ describe("registerStatic", () => {
|
|||||||
const app = {
|
const app = {
|
||||||
register: vi.fn().mockResolvedValue(undefined),
|
register: vi.fn().mockResolvedValue(undefined),
|
||||||
setNotFoundHandler: vi.fn(),
|
setNotFoundHandler: vi.fn(),
|
||||||
|
hasReplyDecorator: vi.fn().mockReturnValue(false),
|
||||||
log: { warn: vi.fn() },
|
log: { warn: vi.fn() },
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -46,6 +47,7 @@ describe("registerStatic", () => {
|
|||||||
root: expect.stringContaining("web/dist"),
|
root: expect.stringContaining("web/dist"),
|
||||||
prefix: "/",
|
prefix: "/",
|
||||||
wildcard: false,
|
wildcard: false,
|
||||||
|
decorateReply: true,
|
||||||
});
|
});
|
||||||
expect(app.setNotFoundHandler).toHaveBeenCalled();
|
expect(app.setNotFoundHandler).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -58,6 +60,7 @@ describe("registerStatic", () => {
|
|||||||
setNotFoundHandler: vi.fn((handler: typeof notFoundHandler) => {
|
setNotFoundHandler: vi.fn((handler: typeof notFoundHandler) => {
|
||||||
notFoundHandler = handler;
|
notFoundHandler = handler;
|
||||||
}),
|
}),
|
||||||
|
hasReplyDecorator: vi.fn().mockReturnValue(false),
|
||||||
log: { warn: vi.fn() },
|
log: { warn: vi.fn() },
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -77,6 +80,7 @@ describe("registerStatic", () => {
|
|||||||
setNotFoundHandler: vi.fn((handler: typeof notFoundHandler) => {
|
setNotFoundHandler: vi.fn((handler: typeof notFoundHandler) => {
|
||||||
notFoundHandler = handler;
|
notFoundHandler = handler;
|
||||||
}),
|
}),
|
||||||
|
hasReplyDecorator: vi.fn().mockReturnValue(false),
|
||||||
log: { warn: vi.fn() },
|
log: { warn: vi.fn() },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user