Backend hardening: structured logging, timeouts, memory leak fixes, compression, XSS fix
Some checks failed
Deploy to Production / Deploy to Server (push) Failing after 20s
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:
parent
4833edf44c
commit
9541ae1826
20 changed files with 319 additions and 74 deletions
|
|
@ -1,4 +1,3 @@
|
||||||
version: "3.8"
|
|
||||||
services:
|
services:
|
||||||
docfast:
|
docfast:
|
||||||
build: .
|
build: .
|
||||||
|
|
|
||||||
167
package-lock.json
generated
167
package-lock.json
generated
|
|
@ -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",
|
||||||
|
|
|
||||||
11
package.json
11
package.json
|
|
@ -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"
|
||||||
}
|
}
|
||||||
24
src/index.ts
24
src/index.ts
|
|
@ -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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -113,3 +113,6 @@ export function getConcurrencyStats() {
|
||||||
maxQueue: MAX_QUEUE_SIZE
|
maxQueue: MAX_QUEUE_SIZE
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Proactive cleanup every 60s
|
||||||
|
setInterval(cleanupExpiredEntries, 60_000);
|
||||||
|
|
@ -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"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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." });
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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." });
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
10
src/services/logger.ts
Normal 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;
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue