Backend hardening: structured logging, timeouts, memory leak fixes, compression, XSS fix
Some checks failed
Deploy to Production / Deploy to Server (push) Failing after 20s

- Add pino structured logging with request IDs (X-Request-Id header)
- Add 30s timeout to acquirePage() and renderPdf/renderUrlPdf
- Add verification cache cleanup (every 15min) and rate limit cleanup (every 60s)
- Read version from package.json in health endpoint
- Add compression middleware
- Escape currency in templates (XSS fix)
- Add static asset caching (1h maxAge)
- Remove deprecated docker-compose version field
- Replace all console.log/error with pino logger
This commit is contained in:
OpenClaw 2026-02-16 08:27:42 +00:00
parent 4833edf44c
commit 9541ae1826
20 changed files with 319 additions and 74 deletions

View file

@ -1,4 +1,3 @@
version: "3.8"
services: services:
docfast: docfast:
build: . build: .

167
package-lock.json generated
View file

@ -8,6 +8,7 @@
"name": "docfast-api", "name": "docfast-api",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"compression": "^1.8.1",
"express": "^4.21.0", "express": "^4.21.0",
"express-rate-limit": "^7.5.0", "express-rate-limit": "^7.5.0",
"helmet": "^8.0.0", "helmet": "^8.0.0",
@ -15,11 +16,13 @@
"nanoid": "^5.0.0", "nanoid": "^5.0.0",
"nodemailer": "^8.0.1", "nodemailer": "^8.0.1",
"pg": "^8.13.0", "pg": "^8.13.0",
"pino": "^10.3.1",
"puppeteer": "^24.0.0", "puppeteer": "^24.0.0",
"stripe": "^20.3.1", "stripe": "^20.3.1",
"swagger-ui-dist": "^5.31.0" "swagger-ui-dist": "^5.31.0"
}, },
"devDependencies": { "devDependencies": {
"@types/compression": "^1.8.1",
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/node": "^22.0.0", "@types/node": "^22.0.0",
"@types/nodemailer": "^7.0.9", "@types/nodemailer": "^7.0.9",
@ -501,6 +504,11 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@pinojs/redact": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="
},
"node_modules/@puppeteer/browsers": { "node_modules/@puppeteer/browsers": {
"version": "2.12.1", "version": "2.12.1",
"resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.12.1.tgz", "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.12.1.tgz",
@ -929,6 +937,16 @@
"assertion-error": "^2.0.1" "assertion-error": "^2.0.1"
} }
}, },
"node_modules/@types/compression": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/@types/compression/-/compression-1.8.1.tgz",
"integrity": "sha512-kCFuWS0ebDbmxs0AXYn6e2r2nrGAb5KwQhknjSPSPgJcGd8+HVSILlUyFhGqML2gk39HcG7D1ydW9/qpYkN00Q==",
"dev": true,
"dependencies": {
"@types/express": "*",
"@types/node": "*"
}
},
"node_modules/@types/connect": { "node_modules/@types/connect": {
"version": "3.4.38", "version": "3.4.38",
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
@ -1256,6 +1274,14 @@
"node": ">=4" "node": ">=4"
} }
}, },
"node_modules/atomic-sleep": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
"integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==",
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/b4a": { "node_modules/b4a": {
"version": "1.7.4", "version": "1.7.4",
"resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.4.tgz", "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.4.tgz",
@ -1532,6 +1558,42 @@
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/compressible": {
"version": "2.0.18",
"resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz",
"integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==",
"dependencies": {
"mime-db": ">= 1.43.0 < 2"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/compression": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz",
"integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==",
"dependencies": {
"bytes": "3.1.2",
"compressible": "~2.0.18",
"debug": "2.6.9",
"negotiator": "~0.6.4",
"on-headers": "~1.1.0",
"safe-buffer": "5.2.1",
"vary": "~1.1.2"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/compression/node_modules/negotiator": {
"version": "0.6.4",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz",
"integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/content-disposition": { "node_modules/content-disposition": {
"version": "0.5.4", "version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
@ -2624,6 +2686,14 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/on-exit-leak-free": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
"integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/on-finished": { "node_modules/on-finished": {
"version": "2.4.1", "version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
@ -2636,6 +2706,14 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/on-headers": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz",
"integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/once": { "node_modules/once": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
@ -2876,6 +2954,40 @@
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
} }
}, },
"node_modules/pino": {
"version": "10.3.1",
"resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz",
"integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==",
"dependencies": {
"@pinojs/redact": "^0.4.0",
"atomic-sleep": "^1.0.0",
"on-exit-leak-free": "^2.1.0",
"pino-abstract-transport": "^3.0.0",
"pino-std-serializers": "^7.0.0",
"process-warning": "^5.0.0",
"quick-format-unescaped": "^4.0.3",
"real-require": "^0.2.0",
"safe-stable-stringify": "^2.3.1",
"sonic-boom": "^4.0.1",
"thread-stream": "^4.0.0"
},
"bin": {
"pino": "bin.js"
}
},
"node_modules/pino-abstract-transport": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz",
"integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==",
"dependencies": {
"split2": "^4.0.0"
}
},
"node_modules/pino-std-serializers": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz",
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw=="
},
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.6", "version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
@ -2963,6 +3075,21 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/process-warning": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz",
"integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
]
},
"node_modules/progress": { "node_modules/progress": {
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
@ -3120,6 +3247,11 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/quick-format-unescaped": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="
},
"node_modules/range-parser": { "node_modules/range-parser": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
@ -3144,6 +3276,14 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/real-require": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz",
"integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==",
"engines": {
"node": ">= 12.13.0"
}
},
"node_modules/require-directory": { "node_modules/require-directory": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
@ -3237,6 +3377,14 @@
], ],
"license": "MIT" "license": "MIT"
}, },
"node_modules/safe-stable-stringify": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
"integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==",
"engines": {
"node": ">=10"
}
},
"node_modules/safer-buffer": { "node_modules/safer-buffer": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
@ -3446,6 +3594,14 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/sonic-boom": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz",
"integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==",
"dependencies": {
"atomic-sleep": "^1.0.0"
}
},
"node_modules/source-map": { "node_modules/source-map": {
"version": "0.6.1", "version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
@ -3614,6 +3770,17 @@
"b4a": "^1.6.4" "b4a": "^1.6.4"
} }
}, },
"node_modules/thread-stream": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz",
"integrity": "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==",
"dependencies": {
"real-require": "^0.2.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/tinybench": { "node_modules/tinybench": {
"version": "2.9.0", "version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",

View file

@ -10,25 +10,28 @@
"test": "vitest run" "test": "vitest run"
}, },
"dependencies": { "dependencies": {
"compression": "^1.8.1",
"express": "^4.21.0", "express": "^4.21.0",
"express-rate-limit": "^7.5.0", "express-rate-limit": "^7.5.0",
"helmet": "^8.0.0", "helmet": "^8.0.0",
"marked": "^15.0.0", "marked": "^15.0.0",
"nanoid": "^5.0.0", "nanoid": "^5.0.0",
"nodemailer": "^8.0.1", "nodemailer": "^8.0.1",
"pg": "^8.13.0",
"pino": "^10.3.1",
"puppeteer": "^24.0.0", "puppeteer": "^24.0.0",
"stripe": "^20.3.1", "stripe": "^20.3.1",
"swagger-ui-dist": "^5.31.0", "swagger-ui-dist": "^5.31.0"
"pg": "^8.13.0"
}, },
"devDependencies": { "devDependencies": {
"@types/compression": "^1.8.1",
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/node": "^22.0.0", "@types/node": "^22.0.0",
"@types/nodemailer": "^7.0.9", "@types/nodemailer": "^7.0.9",
"@types/pg": "^8.11.0",
"tsx": "^4.19.0", "tsx": "^4.19.0",
"typescript": "^5.7.0", "typescript": "^5.7.0",
"vitest": "^3.0.0", "vitest": "^3.0.0"
"@types/pg": "^8.11.0"
}, },
"type": "module" "type": "module"
} }

View file

@ -1,4 +1,7 @@
import express from "express"; import express from "express";
import { randomUUID } from "crypto";
import compression from "compression";
import logger from "./services/logger.js";
import helmet from "helmet"; import helmet from "helmet";
import path from "path"; import path from "path";
import { fileURLToPath } from "url"; import { fileURLToPath } from "url";
@ -24,6 +27,17 @@ const PORT = parseInt(process.env.PORT || "3100", 10);
app.use(helmet({ crossOriginResourcePolicy: { policy: "cross-origin" } })); app.use(helmet({ crossOriginResourcePolicy: { policy: "cross-origin" } }));
// Request ID middleware
app.use((req, res, next) => {
const requestId = (req.headers["x-request-id"] as string) || randomUUID();
(req as any).requestId = requestId;
res.setHeader("X-Request-Id", requestId);
next();
});
// Compression
app.use(compression());
// Differentiated CORS middleware // Differentiated CORS middleware
app.use((req, res, next) => { app.use((req, res, next) => {
const isAuthBillingRoute = req.path.startsWith('/v1/signup') || const isAuthBillingRoute = req.path.startsWith('/v1/signup') ||
@ -145,7 +159,7 @@ ${apiKey ? `
// Landing page // Landing page
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
app.use(express.static(path.join(__dirname, "../public"))); app.use(express.static(path.join(__dirname, "../public"), { maxAge: "1h", etag: true }));
// Docs page (clean URL) // Docs page (clean URL)
app.get("/docs", (_req, res) => { app.get("/docs", (_req, res) => {
@ -179,11 +193,11 @@ async function start() {
await loadUsageData(); await loadUsageData();
await initBrowser(); await initBrowser();
console.log(`Loaded ${getAllKeys().length} API keys`); logger.info(`Loaded ${getAllKeys().length} API keys`);
app.listen(PORT, () => console.log(`DocFast API running on :${PORT}`)); app.listen(PORT, () => logger.info(`DocFast API running on :${PORT}`));
const shutdown = async () => { const shutdown = async () => {
console.log("Shutting down..."); logger.info("Shutting down...");
await closeBrowser(); await closeBrowser();
process.exit(0); process.exit(0);
}; };
@ -192,7 +206,7 @@ async function start() {
} }
start().catch((err) => { start().catch((err) => {
console.error("Failed to start:", err); logger.error({ err }, "Failed to start");
process.exit(1); process.exit(1);
}); });

View file

@ -113,3 +113,6 @@ export function getConcurrencyStats() {
maxQueue: MAX_QUEUE_SIZE maxQueue: MAX_QUEUE_SIZE
}; };
} }
// Proactive cleanup every 60s
setInterval(cleanupExpiredEntries, 60_000);

View file

@ -1,4 +1,5 @@
import { isProKey } from "../services/keys.js"; import { isProKey } from "../services/keys.js";
import logger from "../services/logger.js";
import pool from "../services/db.js"; import pool from "../services/db.js";
const FREE_TIER_LIMIT = 100; const FREE_TIER_LIMIT = 100;
@ -18,9 +19,9 @@ export async function loadUsageData(): Promise<void> {
for (const row of result.rows) { for (const row of result.rows) {
usage.set(row.key, { count: row.count, monthKey: row.month_key }); usage.set(row.key, { count: row.count, monthKey: row.month_key });
} }
console.log(`Loaded usage data for ${usage.size} keys from PostgreSQL`); logger.info(`Loaded usage data for ${usage.size} keys from PostgreSQL`);
} catch (error) { } catch (error) {
console.log("No existing usage data found, starting fresh"); logger.info("No existing usage data found, starting fresh");
usage = new Map(); usage = new Map();
} }
} }
@ -33,7 +34,7 @@ async function saveUsageEntry(key: string, record: { count: number; monthKey: st
[key, record.count, record.monthKey] [key, record.count, record.monthKey]
); );
} catch (error) { } catch (error) {
console.error("Failed to save usage data:", error); logger.error({ err: error }, "Failed to save usage data");
} }
} }
@ -68,10 +69,10 @@ function trackUsage(key: string, monthKey: string): void {
if (!record || record.monthKey !== monthKey) { if (!record || record.monthKey !== monthKey) {
const newRecord = { count: 1, monthKey }; const newRecord = { count: 1, monthKey };
usage.set(key, newRecord); usage.set(key, newRecord);
saveUsageEntry(key, newRecord).catch(console.error); saveUsageEntry(key, newRecord).catch((err) => logger.error({ err }, "Failed to save usage entry"));
} else { } else {
record.count++; record.count++;
saveUsageEntry(key, record).catch(console.error); saveUsageEntry(key, record).catch((err) => logger.error({ err }, "Failed to save usage entry"));
} }
} }

View file

@ -1,6 +1,7 @@
import { Router, Request, Response } from "express"; import { Router, Request, Response } from "express";
import Stripe from "stripe"; import Stripe from "stripe";
import { createProKey, revokeByCustomer } from "../services/keys.js"; import { createProKey, revokeByCustomer } from "../services/keys.js";
import logger from "../services/logger.js";
function escapeHtml(s: string): string { function escapeHtml(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;"); return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
@ -33,7 +34,7 @@ router.post("/checkout", async (_req: Request, res: Response) => {
res.json({ url: session.url }); res.json({ url: session.url });
} catch (err: any) { } catch (err: any) {
console.error("Checkout error:", err.message); logger.error({ err }, "Checkout error");
res.status(500).json({ error: "Failed to create checkout session" }); res.status(500).json({ error: "Failed to create checkout session" });
} }
}); });
@ -79,7 +80,7 @@ a { color: #4f9; }
<p><a href="/docs">View API docs </a></p> <p><a href="/docs">View API docs </a></p>
</div></body></html>`); </div></body></html>`);
} catch (err: any) { } catch (err: any) {
console.error("Success page error:", err.message); logger.error({ err }, "Success page error");
res.status(500).json({ error: "Failed to retrieve session" }); res.status(500).json({ error: "Failed to retrieve session" });
} }
}); });
@ -97,7 +98,7 @@ router.post("/webhook", async (req: Request, res: Response) => {
try { try {
event = JSON.parse(typeof req.body === "string" ? req.body : req.body.toString()) as Stripe.Event; event = JSON.parse(typeof req.body === "string" ? req.body : req.body.toString()) as Stripe.Event;
} catch (err: any) { } catch (err: any) {
console.error("Failed to parse webhook body:", err.message); logger.error({ err }, "Failed to parse webhook body");
res.status(400).json({ error: "Invalid payload" }); res.status(400).json({ error: "Invalid payload" });
return; return;
} }
@ -108,7 +109,7 @@ router.post("/webhook", async (req: Request, res: Response) => {
try { try {
event = getStripe().webhooks.constructEvent(req.body, sig, webhookSecret); event = getStripe().webhooks.constructEvent(req.body, sig, webhookSecret);
} catch (err: any) { } catch (err: any) {
console.error("Webhook signature verification failed:", err.message); logger.error({ err }, "Webhook signature verification failed");
res.status(400).json({ error: "Invalid signature" }); res.status(400).json({ error: "Invalid signature" });
return; return;
} }
@ -133,11 +134,11 @@ router.post("/webhook", async (req: Request, res: Response) => {
return productId === DOCFAST_PRODUCT_ID; return productId === DOCFAST_PRODUCT_ID;
}); });
if (!hasDocfastProduct) { if (!hasDocfastProduct) {
console.log(`Ignoring event for different product (session: ${session.id})`); logger.info({ sessionId: session.id }, "Ignoring event for different product");
break; break;
} }
} catch (err: any) { } catch (err: any) {
console.error(`Failed to retrieve session line_items: ${err.message}, skipping`); logger.error({ err, sessionId: session.id }, "Failed to retrieve session line_items");
break; break;
} }
@ -147,14 +148,14 @@ router.post("/webhook", async (req: Request, res: Response) => {
} }
const keyInfo = await createProKey(email, customerId); const keyInfo = await createProKey(email, customerId);
console.log(`checkout.session.completed: provisioned pro key for ${email} (customer: ${customerId}, key: ${keyInfo.key.slice(0, 12)}...)`); logger.info({ email, customerId }, "checkout.session.completed: provisioned pro key");
break; break;
} }
case "customer.subscription.deleted": { case "customer.subscription.deleted": {
const sub = event.data.object as Stripe.Subscription; const sub = event.data.object as Stripe.Subscription;
const customerId = sub.customer as string; const customerId = sub.customer as string;
await revokeByCustomer(customerId); await revokeByCustomer(customerId);
console.log(`Subscription cancelled for ${customerId}, key revoked`); logger.info({ customerId }, "Subscription cancelled, key revoked");
break; break;
} }
default: default:

View file

@ -2,6 +2,7 @@ import { Router, Request, Response } from "express";
import { renderPdf, renderUrlPdf, getPoolStats } from "../services/browser.js"; import { renderPdf, renderUrlPdf, getPoolStats } from "../services/browser.js";
import { markdownToHtml, wrapHtml } from "../services/markdown.js"; import { markdownToHtml, wrapHtml } from "../services/markdown.js";
import dns from "node:dns/promises"; import dns from "node:dns/promises";
import logger from "../services/logger.js";
import net from "node:net"; import net from "node:net";
function isPrivateIP(ip: string): boolean { function isPrivateIP(ip: string): boolean {
@ -74,7 +75,7 @@ convertRouter.post("/html", async (req: Request & { acquirePdfSlot?: () => Promi
res.setHeader("Content-Disposition", `inline; filename="${filename}"`); res.setHeader("Content-Disposition", `inline; filename="${filename}"`);
res.send(pdf); res.send(pdf);
} catch (err: any) { } catch (err: any) {
console.error("Convert HTML error:", err); logger.error({ err }, "Convert HTML error");
if (err.message === "QUEUE_FULL") { if (err.message === "QUEUE_FULL") {
res.status(429).json({ error: "Server busy - too many concurrent PDF generations. Please try again in a few seconds." }); res.status(429).json({ error: "Server busy - too many concurrent PDF generations. Please try again in a few seconds." });
return; return;
@ -118,7 +119,7 @@ convertRouter.post("/markdown", async (req: Request & { acquirePdfSlot?: () => P
res.setHeader("Content-Disposition", `inline; filename="${filename}"`); res.setHeader("Content-Disposition", `inline; filename="${filename}"`);
res.send(pdf); res.send(pdf);
} catch (err: any) { } catch (err: any) {
console.error("Convert MD error:", err); logger.error({ err }, "Convert MD error");
if (err.message === "QUEUE_FULL") { if (err.message === "QUEUE_FULL") {
res.status(429).json({ error: "Server busy - too many concurrent PDF generations. Please try again in a few seconds." }); res.status(429).json({ error: "Server busy - too many concurrent PDF generations. Please try again in a few seconds." });
return; return;
@ -186,7 +187,7 @@ convertRouter.post("/url", async (req: Request & { acquirePdfSlot?: () => Promis
res.setHeader("Content-Disposition", `inline; filename="${filename}"`); res.setHeader("Content-Disposition", `inline; filename="${filename}"`);
res.send(pdf); res.send(pdf);
} catch (err: any) { } catch (err: any) {
console.error("Convert URL error:", err); logger.error({ err }, "Convert URL error");
if (err.message === "QUEUE_FULL") { if (err.message === "QUEUE_FULL") {
res.status(429).json({ error: "Server busy - too many concurrent PDF generations. Please try again in a few seconds." }); res.status(429).json({ error: "Server busy - too many concurrent PDF generations. Please try again in a few seconds." });
return; return;

View file

@ -4,6 +4,7 @@ import rateLimit from "express-rate-limit";
import { createPendingVerification, verifyCode } from "../services/verification.js"; import { createPendingVerification, verifyCode } from "../services/verification.js";
import { sendVerificationEmail } from "../services/email.js"; import { sendVerificationEmail } from "../services/email.js";
import { getAllKeys, updateKeyEmail } from "../services/keys.js"; import { getAllKeys, updateKeyEmail } from "../services/keys.js";
import logger from "../services/logger.js";
const router = Router(); const router = Router();
@ -46,7 +47,7 @@ router.post("/", changeLimiter, async (req: Request, res: Response) => {
const pending = await createPendingVerification(cleanEmail); const pending = await createPendingVerification(cleanEmail);
sendVerificationEmail(cleanEmail, (pending as any).code).catch((err: Error) => { sendVerificationEmail(cleanEmail, (pending as any).code).catch((err: Error) => {
console.error(`Failed to send email change verification to ${cleanEmail}:`, err); logger.error({ err, email: cleanEmail }, "Failed to send email change verification");
}); });
res.json({ status: "verification_sent", message: "Verification code sent to your new email address." }); res.json({ status: "verification_sent", message: "Verification code sent to your new email address." });

View file

@ -1,7 +1,11 @@
import { Router } from "express"; import { Router } from "express";
import { createRequire } from "module";
import { getPoolStats } from "../services/browser.js"; import { getPoolStats } from "../services/browser.js";
import { pool } from "../services/db.js"; import { pool } from "../services/db.js";
const require = createRequire(import.meta.url);
const { version: APP_VERSION } = require("../../package.json");
export const healthRouter = Router(); export const healthRouter = Router();
healthRouter.get("/", async (_req, res) => { healthRouter.get("/", async (_req, res) => {
@ -38,7 +42,7 @@ healthRouter.get("/", async (_req, res) => {
const response = { const response = {
status: overallStatus, status: overallStatus,
version: "0.2.1", version: APP_VERSION,
database: databaseStatus, database: databaseStatus,
pool: { pool: {
size: poolStats.poolSize, size: poolStats.poolSize,

View file

@ -3,6 +3,7 @@ import rateLimit from "express-rate-limit";
import { createPendingVerification, verifyCode } from "../services/verification.js"; import { createPendingVerification, verifyCode } from "../services/verification.js";
import { sendVerificationEmail } from "../services/email.js"; import { sendVerificationEmail } from "../services/email.js";
import { getAllKeys } from "../services/keys.js"; import { getAllKeys } from "../services/keys.js";
import logger from "../services/logger.js";
const router = Router(); const router = Router();
@ -34,7 +35,7 @@ router.post("/", recoverLimiter, async (req: Request, res: Response) => {
const pending = await createPendingVerification(cleanEmail); const pending = await createPendingVerification(cleanEmail);
sendVerificationEmail(cleanEmail, pending.code).catch(err => { sendVerificationEmail(cleanEmail, pending.code).catch(err => {
console.error(`Failed to send recovery email to ${cleanEmail}:`, err); logger.error({ err, email: cleanEmail }, "Failed to send recovery email");
}); });
res.json({ status: "recovery_sent", message: "If an account exists for this email, a verification code has been sent." }); res.json({ status: "recovery_sent", message: "If an account exists for this email, a verification code has been sent." });

View file

@ -3,6 +3,7 @@ import rateLimit from "express-rate-limit";
import { createFreeKey } from "../services/keys.js"; import { createFreeKey } from "../services/keys.js";
import { createVerification, createPendingVerification, verifyCode, isEmailVerified, getVerifiedApiKey } from "../services/verification.js"; import { createVerification, createPendingVerification, verifyCode, isEmailVerified, getVerifiedApiKey } from "../services/verification.js";
import { sendVerificationEmail } from "../services/email.js"; import { sendVerificationEmail } from "../services/email.js";
import logger from "../services/logger.js";
const router = Router(); const router = Router();
@ -53,7 +54,7 @@ router.post("/free", rejectDuplicateEmail, signupLimiter, async (req: Request, r
const pending = await createPendingVerification(cleanEmail); const pending = await createPendingVerification(cleanEmail);
sendVerificationEmail(cleanEmail, pending.code).catch(err => { sendVerificationEmail(cleanEmail, pending.code).catch(err => {
console.error(`Failed to send verification email to ${cleanEmail}:`, err); logger.error({ err, email: cleanEmail }, "Failed to send verification email");
}); });
res.json({ res.json({

View file

@ -1,5 +1,6 @@
import { Router, Request, Response } from "express"; import { Router, Request, Response } from "express";
import { renderPdf } from "../services/browser.js"; import { renderPdf } from "../services/browser.js";
import logger from "../services/logger.js";
import { templates, renderTemplate } from "../services/templates.js"; import { templates, renderTemplate } from "../services/templates.js";
export const templatesRouter = Router(); export const templatesRouter = Router();
@ -37,7 +38,7 @@ templatesRouter.post("/:id/render", async (req: Request, res: Response) => {
res.setHeader("Content-Disposition", `inline; filename="${filename}"`); res.setHeader("Content-Disposition", `inline; filename="${filename}"`);
res.send(pdf); res.send(pdf);
} catch (err: any) { } catch (err: any) {
console.error("Template render error:", err); logger.error({ err }, "Template render error");
res.status(500).json({ error: "Template rendering failed", detail: err.message }); res.status(500).json({ error: "Template rendering failed", detail: err.message });
} }
}); });

View file

@ -1,4 +1,5 @@
import puppeteer, { Browser, Page } from "puppeteer"; import puppeteer, { Browser, Page } from "puppeteer";
import logger from "./logger.js";
const BROWSER_COUNT = parseInt(process.env.BROWSER_COUNT || "2", 10); const BROWSER_COUNT = parseInt(process.env.BROWSER_COUNT || "2", 10);
const PAGES_PER_BROWSER = parseInt(process.env.PAGES_PER_BROWSER || "8", 10); const PAGES_PER_BROWSER = parseInt(process.env.PAGES_PER_BROWSER || "8", 10);
@ -90,9 +91,19 @@ async function acquirePage(): Promise<{ page: Page; instance: BrowserInstance }>
return { page, instance: inst }; return { page, instance: inst };
} }
// All pages busy, queue // All pages busy, queue with 30s timeout
return new Promise((resolve) => { return new Promise((resolve, reject) => {
waitingQueue.push({ resolve }); const timer = setTimeout(() => {
const idx = waitingQueue.findIndex((w) => w.resolve === resolve);
if (idx >= 0) waitingQueue.splice(idx, 1);
reject(new Error("QUEUE_FULL"));
}, 30_000);
waitingQueue.push({
resolve: (v) => {
clearTimeout(timer);
resolve(v);
},
});
}); });
} }
@ -125,7 +136,7 @@ function releasePage(page: Page, inst: BrowserInstance): void {
async function scheduleRestart(inst: BrowserInstance): Promise<void> { async function scheduleRestart(inst: BrowserInstance): Promise<void> {
if (inst.restarting) return; if (inst.restarting) return;
inst.restarting = true; inst.restarting = true;
console.log(`Scheduling browser ${inst.id} restart (pdfs=${inst.pdfCount}, uptime=${Math.round((Date.now() - inst.lastRestartTime) / 1000)}s)`); logger.info(`Scheduling browser ${inst.id} restart (pdfs=${inst.pdfCount}, uptime=${Math.round((Date.now() - inst.lastRestartTime) / 1000)}s)`);
const drainCheck = () => new Promise<void>((resolve) => { const drainCheck = () => new Promise<void>((resolve) => {
const check = () => { const check = () => {
@ -159,7 +170,7 @@ async function scheduleRestart(inst: BrowserInstance): Promise<void> {
inst.pdfCount = 0; inst.pdfCount = 0;
inst.lastRestartTime = Date.now(); inst.lastRestartTime = Date.now();
inst.restarting = false; inst.restarting = false;
console.log(`Browser ${inst.id} restarted successfully`); logger.info(`Browser ${inst.id} restarted successfully`);
while (waitingQueue.length > 0 && inst.availablePages.length > 0) { while (waitingQueue.length > 0 && inst.availablePages.length > 0) {
const waiter = waitingQueue.shift(); const waiter = waitingQueue.shift();
@ -193,7 +204,7 @@ export async function initBrowser(): Promise<void> {
const inst = await launchInstance(i); const inst = await launchInstance(i);
instances.push(inst); instances.push(inst);
} }
console.log(`Browser pool ready (${BROWSER_COUNT} browsers × ${PAGES_PER_BROWSER} pages = ${BROWSER_COUNT * PAGES_PER_BROWSER} total)`); logger.info(`Browser pool ready (${BROWSER_COUNT} browsers × ${PAGES_PER_BROWSER} pages = ${BROWSER_COUNT * PAGES_PER_BROWSER} total)`);
} }
export async function closeBrowser(): Promise<void> { export async function closeBrowser(): Promise<void> {
@ -221,9 +232,10 @@ export async function renderPdf(
): Promise<Buffer> { ): Promise<Buffer> {
const { page, instance } = await acquirePage(); const { page, instance } = await acquirePage();
try { try {
const result = await Promise.race([
(async () => {
await page.setContent(html, { waitUntil: "domcontentloaded", timeout: 15_000 }); await page.setContent(html, { waitUntil: "domcontentloaded", timeout: 15_000 });
await page.addStyleTag({ content: "* { margin: 0; padding: 0; } body { margin: 0; }" }); await page.addStyleTag({ content: "* { margin: 0; padding: 0; } body { margin: 0; }" });
const pdf = await page.pdf({ const pdf = await page.pdf({
format: (options.format as any) || "A4", format: (options.format as any) || "A4",
landscape: options.landscape || false, landscape: options.landscape || false,
@ -233,8 +245,13 @@ export async function renderPdf(
footerTemplate: options.footerTemplate, footerTemplate: options.footerTemplate,
displayHeaderFooter: options.displayHeaderFooter || false, displayHeaderFooter: options.displayHeaderFooter || false,
}); });
return Buffer.from(pdf); return Buffer.from(pdf);
})(),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error("PDF_TIMEOUT")), 30_000)
),
]);
return result;
} finally { } finally {
releasePage(page, instance); releasePage(page, instance);
} }
@ -252,19 +269,25 @@ export async function renderUrlPdf(
): Promise<Buffer> { ): Promise<Buffer> {
const { page, instance } = await acquirePage(); const { page, instance } = await acquirePage();
try { try {
const result = await Promise.race([
(async () => {
await page.goto(url, { await page.goto(url, {
waitUntil: (options.waitUntil as any) || "networkidle0", waitUntil: (options.waitUntil as any) || "networkidle0",
timeout: 30_000, timeout: 30_000,
}); });
const pdf = await page.pdf({ const pdf = await page.pdf({
format: (options.format as any) || "A4", format: (options.format as any) || "A4",
landscape: options.landscape || false, landscape: options.landscape || false,
printBackground: options.printBackground !== false, printBackground: options.printBackground !== false,
margin: options.margin || { top: "0", right: "0", bottom: "0", left: "0" }, margin: options.margin || { top: "0", right: "0", bottom: "0", left: "0" },
}); });
return Buffer.from(pdf); return Buffer.from(pdf);
})(),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error("PDF_TIMEOUT")), 30_000)
),
]);
return result;
} finally { } finally {
releasePage(page, instance); releasePage(page, instance);
} }

View file

@ -1,5 +1,6 @@
import pg from "pg"; import pg from "pg";
import logger from "./logger.js";
const { Pool } = pg; const { Pool } = pg;
const pool = new Pool({ const pool = new Pool({
@ -13,7 +14,7 @@ const pool = new Pool({
}); });
pool.on("error", (err) => { pool.on("error", (err) => {
console.error("Unexpected PostgreSQL pool error:", err); logger.error({ err }, "Unexpected PostgreSQL pool error");
}); });
export async function initDatabase(): Promise<void> { export async function initDatabase(): Promise<void> {
@ -55,7 +56,7 @@ export async function initDatabase(): Promise<void> {
month_key TEXT NOT NULL month_key TEXT NOT NULL
); );
`); `);
console.log("PostgreSQL tables initialized"); logger.info("PostgreSQL tables initialized");
} finally { } finally {
client.release(); client.release();
} }

View file

@ -1,4 +1,5 @@
import nodemailer from "nodemailer"; import nodemailer from "nodemailer";
import logger from "./logger.js";
const transporter = nodemailer.createTransport({ const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST || "host.docker.internal", host: process.env.SMTP_HOST || "host.docker.internal",
@ -18,10 +19,10 @@ export async function sendVerificationEmail(email: string, code: string): Promis
subject: "DocFast - Verify your email", subject: "DocFast - Verify your email",
text: `Your DocFast verification code is: ${code}\n\nThis code expires in 15 minutes.\n\nIf you didn't request this, ignore this email.`, text: `Your DocFast verification code is: ${code}\n\nThis code expires in 15 minutes.\n\nIf you didn't request this, ignore this email.`,
}); });
console.log(`📧 Verification email sent to ${email}: ${info.messageId}`); logger.info({ email, messageId: info.messageId }, "Verification email sent");
return true; return true;
} catch (err) { } catch (err) {
console.error(`📧 Failed to send verification email to ${email}:`, err); logger.error({ err, email }, "Failed to send verification email");
return false; return false;
} }
} }

View file

@ -1,4 +1,5 @@
import { randomBytes } from "crypto"; import { randomBytes } from "crypto";
import logger from "./logger.js";
import pool from "./db.js"; import pool from "./db.js";
export interface ApiKey { export interface ApiKey {
@ -25,7 +26,7 @@ export async function loadKeys(): Promise<void> {
stripeCustomerId: r.stripe_customer_id || undefined, stripeCustomerId: r.stripe_customer_id || undefined,
})); }));
} catch (err) { } catch (err) {
console.error("Failed to load keys from PostgreSQL:", err); logger.error({ err }, "Failed to load keys from PostgreSQL");
keysCache = []; keysCache = [];
} }

10
src/services/logger.ts Normal file
View file

@ -0,0 +1,10 @@
import pino from "pino";
const logger = pino({
level: process.env.LOG_LEVEL || "info",
...(process.env.NODE_ENV !== "production" && {
transport: { target: "pino/file", options: { destination: 1 } },
}),
});
export default logger;

View file

@ -47,7 +47,7 @@ function esc(s: string): string {
} }
function renderInvoice(d: any): string { function renderInvoice(d: any): string {
const cur = d.currency || "€"; const cur = esc(d.currency || "€");
const items = d.items || []; const items = d.items || [];
let subtotal = 0; let subtotal = 0;
let totalTax = 0; let totalTax = 0;
@ -133,7 +133,7 @@ function renderInvoice(d: any): string {
} }
function renderReceipt(d: any): string { function renderReceipt(d: any): string {
const cur = d.currency || "€"; const cur = esc(d.currency || "€");
const items = d.items || []; const items = d.items || [];
let total = 0; let total = 0;

View file

@ -1,4 +1,5 @@
import { randomBytes, randomInt } from "crypto"; import { randomBytes, randomInt } from "crypto";
import logger from "./logger.js";
import pool from "./db.js"; import pool from "./db.js";
export interface Verification { export interface Verification {
@ -63,6 +64,17 @@ export async function loadVerifications(): Promise<void> {
createdAt: r.created_at instanceof Date ? r.created_at.toISOString() : r.created_at, createdAt: r.created_at instanceof Date ? r.created_at.toISOString() : r.created_at,
verifiedAt: r.verified_at ? (r.verified_at instanceof Date ? r.verified_at.toISOString() : r.verified_at) : null, verifiedAt: r.verified_at ? (r.verified_at instanceof Date ? r.verified_at.toISOString() : r.verified_at) : null,
})); }));
// Cleanup expired entries every 15 minutes
setInterval(() => {
const cutoff = Date.now() - 24 * 60 * 60 * 1000;
const before = verificationsCache.length;
verificationsCache = verificationsCache.filter(
(v) => v.verifiedAt || new Date(v.createdAt).getTime() > cutoff
);
const removed = before - verificationsCache.length;
if (removed > 0) logger.info({ removed }, "Cleaned expired verification cache entries");
}, 15 * 60 * 1000);
} }
function verifyTokenSync(token: string): { status: "ok"; verification: Verification } | { status: "invalid" | "expired" | "already_verified"; verification?: Verification } { function verifyTokenSync(token: string): { status: "ok"; verification: Verification } | { status: "invalid" | "expired" | "already_verified"; verification?: Verification } {
@ -73,7 +85,7 @@ function verifyTokenSync(token: string): { status: "ok"; verification: Verificat
if (age > TOKEN_EXPIRY_MS) return { status: "expired" }; if (age > TOKEN_EXPIRY_MS) return { status: "expired" };
v.verifiedAt = new Date().toISOString(); v.verifiedAt = new Date().toISOString();
// Update DB async // Update DB async
pool.query("UPDATE verifications SET verified_at = $1 WHERE token = $2", [v.verifiedAt, token]).catch(console.error); pool.query("UPDATE verifications SET verified_at = $1 WHERE token = $2", [v.verifiedAt, token]).catch((err) => logger.error({ err }, "Failed to update verification"));
return { status: "ok", verification: v }; return { status: "ok", verification: v };
} }