mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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:
+25
-10
@@ -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);
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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" });
|
||||
|
||||
Reference in New Issue
Block a user