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 -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" });