From 792e2d9142890f4049ef026b355ea551006ca054 Mon Sep 17 00:00:00 2001 From: DocFast Bot Date: Fri, 20 Feb 2026 07:54:37 +0000 Subject: [PATCH 001/169] v0.4.1: Code-driven OpenAPI docs via swagger-jsdoc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add swagger-jsdoc dependency for auto-generating OpenAPI spec from JSDoc - Add JSDoc @openapi annotations to all route handlers - Create scripts/generate-openapi.mjs build step - OpenAPI spec now auto-generated from code — no manual JSON editing - All 13 endpoints documented with full parameters - New demo endpoints documented, signup marked as deprecated - Updated info description: demo-first, no free tier references - Dockerfile updated to run openapi generation during build - Build script updated: npm run build generates spec before compile --- Dockerfile | 1 + package-lock.json | 294 +++++++- package.json | 8 +- public/openapi.json | 1214 ++++++++++++++++++++++++++-------- scripts/generate-openapi.mjs | 123 ++++ src/openapi-extra.yaml | 63 ++ src/routes/billing.ts | 30 +- src/routes/convert.ts | 153 ++++- src/routes/demo.ts | 100 ++- src/routes/health.ts | 50 ++ src/routes/recover.ts | 86 +++ src/routes/templates.ts | 114 +++- 12 files changed, 1931 insertions(+), 305 deletions(-) create mode 100644 scripts/generate-openapi.mjs create mode 100644 src/openapi-extra.yaml diff --git a/Dockerfile b/Dockerfile index 16c8ef5..19c6b2f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -24,6 +24,7 @@ RUN npx tsc RUN npm prune --omit=dev COPY scripts/ scripts/ COPY public/ public/ +RUN node scripts/generate-openapi.mjs RUN node scripts/build-html.cjs RUN rm -f public/swagger-ui && ln -s /app/node_modules/swagger-ui-dist public/swagger-ui diff --git a/package-lock.json b/package-lock.json index f9dad1c..3a19ea0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "docfast-api", - "version": "0.2.1", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "docfast-api", - "version": "0.2.1", + "version": "0.4.0", "dependencies": { "compression": "^1.8.1", "express": "^4.21.0", @@ -19,6 +19,7 @@ "pino": "^10.3.1", "puppeteer": "^24.0.0", "stripe": "^20.3.1", + "swagger-jsdoc": "^6.2.8", "swagger-ui-dist": "^5.31.0" }, "devDependencies": { @@ -33,6 +34,50 @@ "vitest": "^3.0.0" } }, + "node_modules/@apidevtools/json-schema-ref-parser": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-9.1.2.tgz", + "integrity": "sha512-r1w81DpR+KyRWd3f+rk6TNqMgedmAxZP5v5KWlXQWlgMUUtyEJch0DKEci1SorPMiSeM8XPl7MZ3miJ60JIpQg==", + "license": "MIT", + "dependencies": { + "@jsdevtools/ono": "^7.1.3", + "@types/json-schema": "^7.0.6", + "call-me-maybe": "^1.0.1", + "js-yaml": "^4.1.0" + } + }, + "node_modules/@apidevtools/openapi-schemas": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@apidevtools/openapi-schemas/-/openapi-schemas-2.1.0.tgz", + "integrity": "sha512-Zc1AlqrJlX3SlpupFGpiLi2EbteyP7fXmUOGup6/DnkRgjP9bgMM/ag+n91rsv0U1Gpz0H3VILA/o3bW7Ua6BQ==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/@apidevtools/swagger-methods": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@apidevtools/swagger-methods/-/swagger-methods-3.0.2.tgz", + "integrity": "sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg==", + "license": "MIT" + }, + "node_modules/@apidevtools/swagger-parser": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/@apidevtools/swagger-parser/-/swagger-parser-10.0.3.tgz", + "integrity": "sha512-sNiLY51vZOmSPFZA5TF35KZ2HbgYklQnTSDnkghamzLb3EkNtcQnrBQEj5AOCxHpTtXpqMCRM1CrmV2rG6nw4g==", + "license": "MIT", + "dependencies": { + "@apidevtools/json-schema-ref-parser": "^9.0.6", + "@apidevtools/openapi-schemas": "^2.0.4", + "@apidevtools/swagger-methods": "^3.0.2", + "@jsdevtools/ono": "^7.1.3", + "call-me-maybe": "^1.0.1", + "z-schema": "^5.0.1" + }, + "peerDependencies": { + "openapi-types": ">=7" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -548,6 +593,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@jsdevtools/ono": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", + "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==", + "license": "MIT" + }, "node_modules/@pinojs/redact": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", @@ -1047,6 +1098,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "22.19.11", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.11.tgz", @@ -1353,6 +1410,12 @@ } } }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, "node_modules/bare-events": { "version": "2.8.2", "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", @@ -1477,6 +1540,16 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, "node_modules/buffer-crc32": { "version": "0.2.13", "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", @@ -1541,6 +1614,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/call-me-maybe": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.2.tgz", + "integrity": "sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==", + "license": "MIT" + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -1665,6 +1744,12 @@ "node": ">= 0.6" } }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "license": "MIT" + }, "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -1794,6 +1879,18 @@ "integrity": "sha512-MJfAEA1UfVhSs7fbSQOG4czavUp1ajfg6prlAN0+cmfa2zNjaIbvq8VneP7do1WAQQIvgNJWSMeP6UyI90gIlQ==", "license": "BSD-3-Clause" }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -2213,6 +2310,12 @@ "node": ">= 0.6" } }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2348,6 +2451,27 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/glob": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", + "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -2513,6 +2637,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -2582,6 +2717,26 @@ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", "license": "MIT" }, + "node_modules/lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", + "deprecated": "This package is deprecated. Use the optional chaining (?.) operator instead.", + "license": "MIT" + }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" + }, + "node_modules/lodash.mergewith": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz", + "integrity": "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==", + "license": "MIT" + }, "node_modules/loupe": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", @@ -2689,6 +2844,18 @@ "node": ">= 0.6" } }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/mitt": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", @@ -2794,6 +2961,13 @@ "wrappy": "1" } }, + "node_modules/openapi-types": { + "version": "12.1.3", + "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz", + "integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==", + "license": "MIT", + "peer": true + }, "node_modules/pac-proxy-agent": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz", @@ -2888,6 +3062,15 @@ "node": ">= 0.8" } }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/path-to-regexp": { "version": "0.1.12", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", @@ -3810,6 +3993,56 @@ } } }, + "node_modules/swagger-jsdoc": { + "version": "6.2.8", + "resolved": "https://registry.npmjs.org/swagger-jsdoc/-/swagger-jsdoc-6.2.8.tgz", + "integrity": "sha512-VPvil1+JRpmJ55CgAtn8DIcpBs0bL5L3q5bVQvF4tAW/k/9JYSj7dCpaYCAv5rufe0vcCbBRQXGvzpkWjvLklQ==", + "license": "MIT", + "dependencies": { + "commander": "6.2.0", + "doctrine": "3.0.0", + "glob": "7.1.6", + "lodash.mergewith": "^4.6.2", + "swagger-parser": "^10.0.3", + "yaml": "2.0.0-1" + }, + "bin": { + "swagger-jsdoc": "bin/swagger-jsdoc.js" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/swagger-jsdoc/node_modules/commander": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.0.tgz", + "integrity": "sha512-zP4jEKbe8SHzKJYQmq8Y9gYjtO/POJLgIdKgV7B9qNmABVFVc+ctqSX6iXh4mCpJfRBOabiZ2YKPg8ciDw6C+Q==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/swagger-jsdoc/node_modules/yaml": { + "version": "2.0.0-1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.0.0-1.tgz", + "integrity": "sha512-W7h5dEhywMKenDJh2iX/LABkbFnBxasD27oyXWDS/feDsxiw0dD5ncXdYXgkvAsXIY2MpW/ZKkr9IU30DBdMNQ==", + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, + "node_modules/swagger-parser": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/swagger-parser/-/swagger-parser-10.0.3.tgz", + "integrity": "sha512-nF7oMeL4KypldrQhac8RyHerJeGPD1p2xDh900GPvc+Nk7nWP6jX2FcC7WmkinMoAmoO774+AFXcWsW8gMWEIg==", + "license": "MIT", + "dependencies": { + "@apidevtools/swagger-parser": "10.0.3" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/swagger-ui-dist": { "version": "5.31.0", "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.31.0.tgz", @@ -4036,6 +4269,15 @@ "node": ">= 0.4.0" } }, + "node_modules/validator": { + "version": "13.15.26", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.26.tgz", + "integrity": "sha512-spH26xU080ydGggxRyR1Yhcbgx+j3y5jbNXk/8L+iRvdIEQ4uTRH2Sgf2dokud6Q4oAtsbNvJ1Ft+9xmm6IZcA==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -4351,6 +4593,24 @@ "node": ">=10" } }, + "node_modules/yaml": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", + "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true, + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", @@ -4388,6 +4648,36 @@ "fd-slicer": "~1.1.0" } }, + "node_modules/z-schema": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/z-schema/-/z-schema-5.0.5.tgz", + "integrity": "sha512-D7eujBWkLa3p2sIpJA0d1pr7es+a7m0vFAnZLlCEKq/Ij2k0MLi9Br2UPxoxdYystm5K1yeBGzub0FlYUEWj2Q==", + "license": "MIT", + "dependencies": { + "lodash.get": "^4.4.2", + "lodash.isequal": "^4.5.0", + "validator": "^13.7.0" + }, + "bin": { + "z-schema": "bin/z-schema" + }, + "engines": { + "node": ">=8.0.0" + }, + "optionalDependencies": { + "commander": "^9.4.1" + } + }, + "node_modules/z-schema/node_modules/commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": "^12.20.0 || >=14" + } + }, "node_modules/zod": { "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", diff --git a/package.json b/package.json index c806717..1844068 100644 --- a/package.json +++ b/package.json @@ -5,10 +5,11 @@ "main": "dist/index.js", "scripts": { "build:pages": "node scripts/build-pages.js && npx terser public/app.js -o public/app.min.js --compress --mangle", - "build": "npm run build:pages && tsc", + "build": "node scripts/generate-openapi.mjs && npm run build:pages && tsc", "start": "node dist/index.js", "dev": "tsx src/index.ts", - "test": "vitest run" + "test": "vitest run", + "generate-openapi": "node scripts/generate-openapi.mjs" }, "dependencies": { "compression": "^1.8.1", @@ -22,6 +23,7 @@ "pino": "^10.3.1", "puppeteer": "^24.0.0", "stripe": "^20.3.1", + "swagger-jsdoc": "^6.2.8", "swagger-ui-dist": "^5.31.0" }, "devDependencies": { @@ -36,4 +38,4 @@ "vitest": "^3.0.0" }, "type": "module" -} +} \ No newline at end of file diff --git a/public/openapi.json b/public/openapi.json index a8098fa..2192f5d 100644 --- a/public/openapi.json +++ b/public/openapi.json @@ -3,56 +3,186 @@ "info": { "title": "DocFast API", "version": "1.0.0", - "description": "Convert HTML, Markdown, and URLs to pixel-perfect PDFs. Built-in invoice & receipt templates.\n\n## Authentication\nAll conversion and template endpoints require an API key via `Authorization: Bearer ` or `X-API-Key: ` header.\n\n## Rate Limits\n- Free tier: 100 PDFs/month, 10 req/min\n- Pro tier: 5,000 PDFs/month, 30 req/min\n\n## Getting Started\n1. Sign up at [docfast.dev](https://docfast.dev) or via `POST /v1/signup/free`\n2. Verify your email with the 6-digit code\n3. Use your API key to convert documents", - "contact": { "name": "DocFast", "url": "https://docfast.dev", "email": "support@docfast.dev" } + "description": "Convert HTML, Markdown, and URLs to pixel-perfect PDFs. Built-in invoice & receipt templates.\n\n## Authentication\nAll conversion and template endpoints require an API key via `Authorization: Bearer ` or `X-API-Key: ` header.\n\n## Demo Endpoints\nTry the API without signing up! Demo endpoints are public (no API key needed) but rate-limited to 5 requests/hour per IP and produce watermarked PDFs.\n\n## Rate Limits\n- Demo: 5 PDFs/hour per IP (watermarked)\n- Pro tier: 5,000 PDFs/month, 30 req/min\n\n## Getting Started\n1. Try the demo at `POST /v1/demo/html` — no signup needed\n2. Subscribe to Pro at [docfast.dev](https://docfast.dev/#pricing) for clean PDFs\n3. Use your API key to convert documents", + "contact": { + "name": "DocFast", + "url": "https://docfast.dev", + "email": "support@docfast.dev" + } }, - "servers": [{ "url": "https://docfast.dev", "description": "Production" }], + "servers": [ + { + "url": "https://docfast.dev", + "description": "Production" + } + ], "tags": [ - { "name": "Conversion", "description": "Convert HTML, Markdown, or URLs to PDF" }, - { "name": "Templates", "description": "Built-in document templates" }, - { "name": "Account", "description": "Signup, key recovery, and email management" }, - { "name": "Billing", "description": "Stripe-powered subscription management" }, - { "name": "System", "description": "Health checks and usage stats" } + { + "name": "Demo", + "description": "Try the API without signing up — watermarked PDFs, rate-limited" + }, + { + "name": "Conversion", + "description": "Convert HTML, Markdown, or URLs to PDF (requires API key)" + }, + { + "name": "Templates", + "description": "Built-in document templates" + }, + { + "name": "Account", + "description": "Key recovery and email management" + }, + { + "name": "Billing", + "description": "Stripe-powered subscription management" + }, + { + "name": "System", + "description": "Health checks and usage stats" + } ], "components": { "securitySchemes": { - "BearerAuth": { "type": "http", "scheme": "bearer", "description": "API key as Bearer token" }, - "ApiKeyHeader": { "type": "apiKey", "in": "header", "name": "X-API-Key", "description": "API key via X-API-Key header" } + "BearerAuth": { + "type": "http", + "scheme": "bearer", + "description": "API key as Bearer token" + }, + "ApiKeyHeader": { + "type": "apiKey", + "in": "header", + "name": "X-API-Key", + "description": "API key via X-API-Key header" + } }, "schemas": { "PdfOptions": { "type": "object", "properties": { - "format": { "type": "string", "enum": ["A4", "Letter", "Legal", "A3", "A5", "Tabloid"], "default": "A4", "description": "Page size" }, - "landscape": { "type": "boolean", "default": false, "description": "Landscape orientation" }, + "format": { + "type": "string", + "enum": [ + "A4", + "Letter", + "Legal", + "A3", + "A5", + "Tabloid" + ], + "default": "A4", + "description": "Page size" + }, + "landscape": { + "type": "boolean", + "default": false, + "description": "Landscape orientation" + }, "margin": { "type": "object", "properties": { - "top": { "type": "string", "example": "20mm" }, - "bottom": { "type": "string", "example": "20mm" }, - "left": { "type": "string", "example": "15mm" }, - "right": { "type": "string", "example": "15mm" } - } + "top": { + "type": "string", + "description": "Top margin (e.g. \"10mm\", \"1in\")", + "default": "0" + }, + "right": { + "type": "string", + "description": "Right margin", + "default": "0" + }, + "bottom": { + "type": "string", + "description": "Bottom margin", + "default": "0" + }, + "left": { + "type": "string", + "description": "Left margin", + "default": "0" + } + }, + "description": "Page margins" }, - "printBackground": { "type": "boolean", "default": true, "description": "Print background graphics" }, - "filename": { "type": "string", "default": "document.pdf", "description": "Suggested filename for the PDF" } + "printBackground": { + "type": "boolean", + "default": true, + "description": "Print background colors and images" + }, + "filename": { + "type": "string", + "description": "Custom filename for Content-Disposition header", + "default": "document.pdf" + } } }, "Error": { "type": "object", "properties": { - "error": { "type": "string", "description": "Error message" } - } + "error": { + "type": "string", + "description": "Error message" + } + }, + "required": [ + "error" + ] } } }, "paths": { + "/v1/billing/checkout": { + "post": { + "tags": [ + "Billing" + ], + "summary": "Create a Stripe checkout session", + "description": "Creates a Stripe Checkout session for a Pro subscription (€9/month).\nReturns a URL to redirect the user to Stripe's hosted payment page.\nRate limited to 3 requests per hour per IP.\n", + "responses": { + "200": { + "description": "Checkout session created", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri", + "description": "Stripe Checkout URL to redirect the user to" + } + } + } + } + } + }, + "413": { + "description": "Request body too large" + }, + "429": { + "description": "Too many checkout requests" + }, + "500": { + "description": "Failed to create checkout session" + } + } + } + }, "/v1/convert/html": { "post": { - "tags": ["Conversion"], + "tags": [ + "Conversion" + ], "summary": "Convert HTML to PDF", - "description": "Renders HTML content as a PDF. Supports full CSS including flexbox, grid, and custom fonts. Bare HTML fragments are auto-wrapped.", - "security": [{ "BearerAuth": [] }, { "ApiKeyHeader": [] }], + "description": "Converts HTML content to a PDF document. Bare HTML fragments are automatically wrapped in a full HTML document.", + "security": [ + { + "BearerAuth": [] + }, + { + "ApiKeyHeader": [] + } + ], "requestBody": { "required": true, "content": { @@ -61,33 +191,78 @@ "allOf": [ { "type": "object", - "required": ["html"], + "required": [ + "html" + ], "properties": { - "html": { "type": "string", "description": "HTML content to convert", "example": "

Hello World

Your first PDF

" }, - "css": { "type": "string", "description": "Optional CSS to inject (for HTML fragments)" } + "html": { + "type": "string", + "description": "HTML content to convert. Can be a full document or a fragment.", + "example": "

Hello World

My first PDF

" + }, + "css": { + "type": "string", + "description": "Optional CSS to inject (only used when html is a fragment, not a full document)", + "example": "body { font-family: sans-serif; padding: 40px; }" + } } }, - { "$ref": "#/components/schemas/PdfOptions" } + { + "$ref": "#/components/schemas/PdfOptions" + } ] } } } }, "responses": { - "200": { "description": "PDF file", "content": { "application/pdf": { "schema": { "type": "string", "format": "binary" } } } }, - "400": { "description": "Invalid request", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }, - "401": { "description": "Missing or invalid API key" }, - "415": { "description": "Unsupported Content-Type" }, - "429": { "description": "Rate limit exceeded or server busy" } + "200": { + "description": "PDF document", + "content": { + "application/pdf": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "400": { + "description": "Missing html field" + }, + "401": { + "description": "Missing API key" + }, + "403": { + "description": "Invalid API key" + }, + "415": { + "description": "Unsupported Content-Type (must be application/json)" + }, + "429": { + "description": "Rate limit or usage limit exceeded" + }, + "500": { + "description": "PDF generation failed" + } } } }, "/v1/convert/markdown": { "post": { - "tags": ["Conversion"], + "tags": [ + "Conversion" + ], "summary": "Convert Markdown to PDF", - "description": "Converts Markdown content to a beautifully styled PDF with syntax highlighting.", - "security": [{ "BearerAuth": [] }, { "ApiKeyHeader": [] }], + "description": "Converts Markdown content to HTML and then to a PDF document.", + "security": [ + { + "BearerAuth": [] + }, + { + "ApiKeyHeader": [] + } + ], "requestBody": { "required": true, "content": { @@ -96,32 +271,77 @@ "allOf": [ { "type": "object", - "required": ["markdown"], + "required": [ + "markdown" + ], "properties": { - "markdown": { "type": "string", "description": "Markdown content to convert", "example": "# Hello World\n\nThis is **bold** and this is *italic*.\n\n- Item 1\n- Item 2" }, - "css": { "type": "string", "description": "Optional custom CSS" } + "markdown": { + "type": "string", + "description": "Markdown content to convert", + "example": "# Hello World\\n\\nThis is **bold** and *italic*." + }, + "css": { + "type": "string", + "description": "Optional CSS to inject into the rendered HTML" + } } }, - { "$ref": "#/components/schemas/PdfOptions" } + { + "$ref": "#/components/schemas/PdfOptions" + } ] } } } }, "responses": { - "200": { "description": "PDF file", "content": { "application/pdf": { "schema": { "type": "string", "format": "binary" } } } }, - "400": { "description": "Invalid request" }, - "401": { "description": "Missing or invalid API key" }, - "429": { "description": "Rate limit exceeded" } + "200": { + "description": "PDF document", + "content": { + "application/pdf": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "400": { + "description": "Missing markdown field" + }, + "401": { + "description": "Missing API key" + }, + "403": { + "description": "Invalid API key" + }, + "415": { + "description": "Unsupported Content-Type" + }, + "429": { + "description": "Rate limit or usage limit exceeded" + }, + "500": { + "description": "PDF generation failed" + } } } }, "/v1/convert/url": { "post": { - "tags": ["Conversion"], + "tags": [ + "Conversion" + ], "summary": "Convert URL to PDF", - "description": "Fetches a URL and converts the rendered page to PDF. Only http/https URLs are supported. Private/reserved IPs are blocked (SSRF protection).", - "security": [{ "BearerAuth": [] }, { "ApiKeyHeader": [] }], + "description": "Fetches a URL and converts the rendered page to PDF. JavaScript is disabled for security.\nPrivate/internal IP addresses are blocked (SSRF protection). DNS is pinned to prevent rebinding.\n", + "security": [ + { + "BearerAuth": [] + }, + { + "ApiKeyHeader": [] + } + ], "requestBody": { "required": true, "content": { @@ -130,35 +350,445 @@ "allOf": [ { "type": "object", - "required": ["url"], + "required": [ + "url" + ], "properties": { - "url": { "type": "string", "format": "uri", "description": "URL to convert", "example": "https://example.com" }, - "waitUntil": { "type": "string", "enum": ["load", "domcontentloaded", "networkidle0", "networkidle2"], "default": "networkidle0", "description": "When to consider navigation complete" } + "url": { + "type": "string", + "format": "uri", + "description": "URL to convert (http or https only)", + "example": "https://example.com" + }, + "waitUntil": { + "type": "string", + "enum": [ + "load", + "domcontentloaded", + "networkidle0", + "networkidle2" + ], + "default": "domcontentloaded", + "description": "When to consider navigation finished" + } } }, - { "$ref": "#/components/schemas/PdfOptions" } + { + "$ref": "#/components/schemas/PdfOptions" + } ] } } } }, "responses": { - "200": { "description": "PDF file", "content": { "application/pdf": { "schema": { "type": "string", "format": "binary" } } } }, - "400": { "description": "Invalid URL or DNS failure" }, - "401": { "description": "Missing or invalid API key" }, - "429": { "description": "Rate limit exceeded" } + "200": { + "description": "PDF document", + "content": { + "application/pdf": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "400": { + "description": "Missing/invalid URL or URL resolves to private IP" + }, + "401": { + "description": "Missing API key" + }, + "403": { + "description": "Invalid API key" + }, + "415": { + "description": "Unsupported Content-Type" + }, + "429": { + "description": "Rate limit or usage limit exceeded" + }, + "500": { + "description": "PDF generation failed" + } + } + } + }, + "/v1/demo/html": { + "post": { + "tags": [ + "Demo" + ], + "summary": "Convert HTML to PDF (demo)", + "description": "Public endpoint — no API key required. Rate limited to 5 requests per hour per IP.\nOutput PDFs include a DocFast watermark. Upgrade to Pro for clean output.\n", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "type": "object", + "required": [ + "html" + ], + "properties": { + "html": { + "type": "string", + "description": "HTML content to convert", + "example": "

Hello World

My first PDF

" + }, + "css": { + "type": "string", + "description": "Optional CSS to inject (used when html is a fragment)" + } + } + }, + { + "$ref": "#/components/schemas/PdfOptions" + } + ] + } + } + } + }, + "responses": { + "200": { + "description": "Watermarked PDF document", + "content": { + "application/pdf": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "400": { + "description": "Missing html field", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "415": { + "description": "Unsupported Content-Type" + }, + "429": { + "description": "Demo rate limit exceeded (5/hour)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Server busy" + }, + "504": { + "description": "PDF generation timed out" + } + } + } + }, + "/v1/demo/markdown": { + "post": { + "tags": [ + "Demo" + ], + "summary": "Convert Markdown to PDF (demo)", + "description": "Public endpoint — no API key required. Rate limited to 5 requests per hour per IP.\nMarkdown is converted to HTML then rendered to PDF with a DocFast watermark.\n", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "type": "object", + "required": [ + "markdown" + ], + "properties": { + "markdown": { + "type": "string", + "description": "Markdown content to convert", + "example": "# Hello World\\n\\nThis is **bold** and *italic*." + }, + "css": { + "type": "string", + "description": "Optional CSS to inject" + } + } + }, + { + "$ref": "#/components/schemas/PdfOptions" + } + ] + } + } + } + }, + "responses": { + "200": { + "description": "Watermarked PDF document", + "content": { + "application/pdf": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "400": { + "description": "Missing markdown field" + }, + "415": { + "description": "Unsupported Content-Type" + }, + "429": { + "description": "Demo rate limit exceeded (5/hour)" + }, + "503": { + "description": "Server busy" + }, + "504": { + "description": "PDF generation timed out" + } + } + } + }, + "/health": { + "get": { + "tags": [ + "System" + ], + "summary": "Health check", + "description": "Returns service health status including database connectivity and browser pool stats.", + "responses": { + "200": { + "description": "Service is healthy", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "ok", + "degraded" + ] + }, + "version": { + "type": "string", + "example": "0.4.0" + }, + "database": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "ok", + "error" + ] + }, + "version": { + "type": "string", + "example": "PostgreSQL 17.4" + } + } + }, + "pool": { + "type": "object", + "properties": { + "size": { + "type": "integer" + }, + "active": { + "type": "integer" + }, + "available": { + "type": "integer" + }, + "queueDepth": { + "type": "integer" + }, + "pdfCount": { + "type": "integer" + }, + "restarting": { + "type": "boolean" + }, + "uptimeSeconds": { + "type": "integer" + } + } + } + } + } + } + } + }, + "503": { + "description": "Service is degraded (database issue)" + } + } + } + }, + "/v1/recover": { + "post": { + "tags": [ + "Account" + ], + "summary": "Request API key recovery", + "description": "Sends a 6-digit verification code to the email address if an account exists.\nResponse is always the same regardless of whether the email exists (to prevent enumeration).\nRate limited to 3 requests per hour.\n", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "email" + ], + "properties": { + "email": { + "type": "string", + "format": "email", + "description": "Email address associated with the API key" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Recovery code sent (or no-op if email not found)", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "recovery_sent" + }, + "message": { + "type": "string" + } + } + } + } + } + }, + "400": { + "description": "Invalid email format" + }, + "429": { + "description": "Too many recovery attempts" + } + } + } + }, + "/v1/recover/verify": { + "post": { + "tags": [ + "Account" + ], + "summary": "Verify recovery code and retrieve API key", + "description": "Verifies the 6-digit code sent via email and returns the API key if valid. Code expires after 15 minutes.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "email", + "code" + ], + "properties": { + "email": { + "type": "string", + "format": "email" + }, + "code": { + "type": "string", + "pattern": "^\\d{6}$", + "description": "6-digit verification code" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "API key recovered", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "recovered" + }, + "apiKey": { + "type": "string", + "description": "The recovered API key" + }, + "tier": { + "type": "string", + "enum": [ + "free", + "pro" + ] + } + } + } + } + } + }, + "400": { + "description": "Invalid verification code or missing fields" + }, + "410": { + "description": "Verification code expired" + }, + "429": { + "description": "Too many failed attempts" + } } } }, "/v1/templates": { "get": { - "tags": ["Templates"], + "tags": [ + "Templates" + ], "summary": "List available templates", - "description": "Returns all available document templates with their field definitions.", - "security": [{ "BearerAuth": [] }, { "ApiKeyHeader": [] }], + "description": "Returns a list of all built-in document templates with their required fields.", + "security": [ + { + "BearerAuth": [] + }, + { + "ApiKeyHeader": [] + } + ], "responses": { "200": { - "description": "Template list", + "description": "List of templates", "content": { "application/json": { "schema": { @@ -169,10 +799,34 @@ "items": { "type": "object", "properties": { - "id": { "type": "string", "example": "invoice" }, - "name": { "type": "string", "example": "Invoice" }, - "description": { "type": "string" }, - "fields": { "type": "array", "items": { "type": "string" } } + "id": { + "type": "string", + "example": "invoice" + }, + "name": { + "type": "string", + "example": "Invoice" + }, + "description": { + "type": "string" + }, + "fields": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "required": { + "type": "boolean" + }, + "description": { + "type": "string" + } + } + } + } } } } @@ -180,243 +834,219 @@ } } } + }, + "401": { + "description": "Missing API key" + }, + "403": { + "description": "Invalid API key" + } + } + } + }, + "/v1/templates/{id}/render": { + "post": { + "tags": [ + "Templates" + ], + "summary": "Render a template to PDF", + "description": "Renders a built-in template with the provided data and returns a PDF.\nUse GET /v1/templates to see available templates and their required fields.\nSpecial fields: `_format` (page size), `_margin` (page margins), `_filename` (output filename).\n", + "security": [ + { + "BearerAuth": [] + }, + { + "ApiKeyHeader": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + }, + "description": "Template ID (e.g. \"invoice\", \"receipt\")" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "description": "Template data (fields depend on template). Can also be passed at root level." + }, + "_format": { + "type": "string", + "enum": [ + "A4", + "Letter", + "Legal", + "A3", + "A5", + "Tabloid" + ], + "default": "A4", + "description": "Page size override" + }, + "_margin": { + "type": "object", + "properties": { + "top": { + "type": "string" + }, + "right": { + "type": "string" + }, + "bottom": { + "type": "string" + }, + "left": { + "type": "string" + } + }, + "description": "Page margin override" + }, + "_filename": { + "type": "string", + "description": "Custom output filename" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "PDF document", + "content": { + "application/pdf": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "400": { + "description": "Missing required template fields" + }, + "401": { + "description": "Missing API key" + }, + "403": { + "description": "Invalid API key" + }, + "404": { + "description": "Template not found" + }, + "500": { + "description": "Template rendering failed" + } + } + } + }, + "/v1/signup/free": { + "post": { + "tags": [ + "Account" + ], + "summary": "Free signup (discontinued)", + "description": "Free accounts have been discontinued. Use the demo endpoint for testing\nor subscribe to Pro for production use.\n", + "deprecated": true, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "email": { + "type": "string", + "format": "email" + } + } + } + } + } + }, + "responses": { + "410": { + "description": "Free accounts discontinued", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "example": "Free accounts have been discontinued." + }, + "demo_endpoint": { + "type": "string", + "example": "/v1/demo/html" + }, + "pro_url": { + "type": "string", + "example": "https://docfast.dev/#pricing" + } + } + } + } + } } } } }, - "/v1/templates/{id}/render": { - "post": { - "tags": ["Templates"], - "summary": "Render a template to PDF", - "description": "Renders a template with the provided data and returns a PDF.", - "security": [{ "BearerAuth": [] }, { "ApiKeyHeader": [] }], - "parameters": [ - { "name": "id", "in": "path", "required": true, "schema": { "type": "string" }, "description": "Template ID (e.g., 'invoice', 'receipt')" } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "data": { "type": "object", "description": "Template data fields", "example": { "company": "Acme Corp", "items": [{ "description": "Widget", "quantity": 5, "price": 9.99 }] } } - } - } - } - } - }, - "responses": { - "200": { "description": "PDF file", "content": { "application/pdf": { "schema": { "type": "string", "format": "binary" } } } }, - "404": { "description": "Template not found" } - } - } - }, - "/v1/signup/free": { - "post": { - "tags": ["Account"], - "summary": "Request a free API key", - "description": "Sends a 6-digit verification code to your email. Use `/v1/signup/verify` to complete signup.", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": ["email"], - "properties": { - "email": { "type": "string", "format": "email", "example": "you@example.com" } - } - } - } - } - }, - "responses": { - "200": { - "description": "Verification code sent", - "content": { "application/json": { "schema": { "type": "object", "properties": { "status": { "type": "string", "example": "verification_required" }, "message": { "type": "string" } } } } } - }, - "409": { "description": "Email already registered" }, - "429": { "description": "Too many signup attempts" } - } - } - }, - "/v1/signup/verify": { - "post": { - "tags": ["Account"], - "summary": "Verify email and get API key", - "description": "Verify your email with the 6-digit code to receive your API key.", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": ["email", "code"], - "properties": { - "email": { "type": "string", "format": "email" }, - "code": { "type": "string", "example": "123456", "description": "6-digit verification code" } - } - } - } - } - }, - "responses": { - "200": { - "description": "API key issued", - "content": { "application/json": { "schema": { "type": "object", "properties": { "status": { "type": "string", "example": "verified" }, "apiKey": { "type": "string", "example": "df_free_abc123..." }, "tier": { "type": "string", "example": "free" } } } } } - }, - "400": { "description": "Invalid code" }, - "410": { "description": "Code expired" }, - "429": { "description": "Too many attempts" } - } - } - }, - "/v1/recover": { - "post": { - "tags": ["Account"], - "summary": "Request API key recovery", - "description": "Sends a verification code to your registered email. Returns the same response whether or not the email exists (prevents enumeration).", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": ["email"], - "properties": { - "email": { "type": "string", "format": "email" } - } - } - } - } - }, - "responses": { - "200": { "description": "Recovery code sent (if account exists)" }, - "429": { "description": "Too many recovery attempts" } - } - } - }, - "/v1/recover/verify": { - "post": { - "tags": ["Account"], - "summary": "Verify recovery code and get API key", - "description": "Verify the recovery code to retrieve your API key. The key is shown only in the response — never sent via email.", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": ["email", "code"], - "properties": { - "email": { "type": "string", "format": "email" }, - "code": { "type": "string", "example": "123456" } - } - } - } - } - }, - "responses": { - "200": { - "description": "API key recovered", - "content": { "application/json": { "schema": { "type": "object", "properties": { "status": { "type": "string", "example": "recovered" }, "apiKey": { "type": "string" }, "tier": { "type": "string" } } } } } - }, - "400": { "description": "Invalid code" }, - "410": { "description": "Code expired" }, - "429": { "description": "Too many attempts" } - } - } - }, - "/v1/email-change": { - "post": { - "tags": ["Account"], - "summary": "Request email change", - "description": "Change the email associated with your API key. Sends a verification code to the new email address.", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": ["apiKey", "newEmail"], - "properties": { - "apiKey": { "type": "string", "description": "Your current API key" }, - "newEmail": { "type": "string", "format": "email", "description": "New email address" } - } - } - } - } - }, - "responses": { - "200": { "description": "Verification code sent to new email" }, - "400": { "description": "Invalid input" }, - "401": { "description": "Invalid API key" }, - "409": { "description": "Email already in use" }, - "429": { "description": "Too many attempts" } - } - } - }, - "/v1/email-change/verify": { - "post": { - "tags": ["Account"], - "summary": "Verify email change", - "description": "Verify the code sent to your new email to complete the change.", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": ["apiKey", "newEmail", "code"], - "properties": { - "apiKey": { "type": "string" }, - "newEmail": { "type": "string", "format": "email" }, - "code": { "type": "string", "example": "123456" } - } - } - } - } - }, - "responses": { - "200": { "description": "Email updated", "content": { "application/json": { "schema": { "type": "object", "properties": { "status": { "type": "string", "example": "updated" }, "newEmail": { "type": "string" } } } } } }, - "400": { "description": "Invalid code" }, - "401": { "description": "Invalid API key" }, - "410": { "description": "Code expired" } - } - } - }, - "/v1/billing/checkout": { - "post": { - "tags": ["Billing"], - "summary": "Start Pro subscription checkout", - "description": "Creates a Stripe Checkout session for the Pro plan ($9/mo). Returns a URL to redirect the user to.", - "responses": { - "200": { "description": "Checkout URL", "content": { "application/json": { "schema": { "type": "object", "properties": { "url": { "type": "string", "format": "uri" } } } } } }, - "500": { "description": "Checkout creation failed" } - } - } - }, "/v1/usage": { "get": { - "tags": ["System"], - "summary": "Get usage statistics", - "description": "Returns your API usage statistics for the current billing period.", - "security": [{ "BearerAuth": [] }, { "ApiKeyHeader": [] }], + "tags": [ + "System" + ], + "summary": "Usage statistics (admin only)", + "description": "Returns usage statistics for the authenticated user. Requires admin API key.", + "security": [ + { + "BearerAuth": [] + }, + { + "ApiKeyHeader": [] + } + ], "responses": { - "200": { "description": "Usage stats" } - } - } - }, - "/health": { - "get": { - "tags": ["System"], - "summary": "Health check", - "description": "Returns service health status. No authentication required.", - "responses": { - "200": { "description": "Service is healthy" } + "200": { + "description": "Usage statistics", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "month": { + "type": "string" + } + } + } + } + } + } + }, + "403": { + "description": "Admin access required" + }, + "503": { + "description": "Admin access not configured" + } } } } } -} +} \ No newline at end of file diff --git a/scripts/generate-openapi.mjs b/scripts/generate-openapi.mjs new file mode 100644 index 0000000..3997b3c --- /dev/null +++ b/scripts/generate-openapi.mjs @@ -0,0 +1,123 @@ +#!/usr/bin/env node +/** + * Generates openapi.json from JSDoc annotations in route files. + * Run: node scripts/generate-openapi.mjs + * Output: public/openapi.json + */ +import swaggerJsdoc from 'swagger-jsdoc'; +import { writeFileSync } from 'fs'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +const options = { + definition: { + openapi: '3.0.3', + info: { + title: 'DocFast API', + version: '1.0.0', + description: `Convert HTML, Markdown, and URLs to pixel-perfect PDFs. Built-in invoice & receipt templates. + +## Authentication +All conversion and template endpoints require an API key via \`Authorization: Bearer \` or \`X-API-Key: \` header. + +## Demo Endpoints +Try the API without signing up! Demo endpoints are public (no API key needed) but rate-limited to 5 requests/hour per IP and produce watermarked PDFs. + +## Rate Limits +- Demo: 5 PDFs/hour per IP (watermarked) +- Pro tier: 5,000 PDFs/month, 30 req/min + +## Getting Started +1. Try the demo at \`POST /v1/demo/html\` — no signup needed +2. Subscribe to Pro at [docfast.dev](https://docfast.dev/#pricing) for clean PDFs +3. Use your API key to convert documents`, + contact: { + name: 'DocFast', + url: 'https://docfast.dev', + email: 'support@docfast.dev' + } + }, + servers: [ + { url: 'https://docfast.dev', description: 'Production' } + ], + tags: [ + { name: 'Demo', description: 'Try the API without signing up — watermarked PDFs, rate-limited' }, + { name: 'Conversion', description: 'Convert HTML, Markdown, or URLs to PDF (requires API key)' }, + { name: 'Templates', description: 'Built-in document templates' }, + { name: 'Account', description: 'Key recovery and email management' }, + { name: 'Billing', description: 'Stripe-powered subscription management' }, + { name: 'System', description: 'Health checks and usage stats' } + ], + components: { + securitySchemes: { + BearerAuth: { + type: 'http', + scheme: 'bearer', + description: 'API key as Bearer token' + }, + ApiKeyHeader: { + type: 'apiKey', + in: 'header', + name: 'X-API-Key', + description: 'API key via X-API-Key header' + } + }, + schemas: { + PdfOptions: { + type: 'object', + properties: { + format: { + type: 'string', + enum: ['A4', 'Letter', 'Legal', 'A3', 'A5', 'Tabloid'], + default: 'A4', + description: 'Page size' + }, + landscape: { + type: 'boolean', + default: false, + description: 'Landscape orientation' + }, + margin: { + type: 'object', + properties: { + top: { type: 'string', description: 'Top margin (e.g. "10mm", "1in")', default: '0' }, + right: { type: 'string', description: 'Right margin', default: '0' }, + bottom: { type: 'string', description: 'Bottom margin', default: '0' }, + left: { type: 'string', description: 'Left margin', default: '0' } + }, + description: 'Page margins' + }, + printBackground: { + type: 'boolean', + default: true, + description: 'Print background colors and images' + }, + filename: { + type: 'string', + description: 'Custom filename for Content-Disposition header', + default: 'document.pdf' + } + } + }, + Error: { + type: 'object', + properties: { + error: { type: 'string', description: 'Error message' } + }, + required: ['error'] + } + } + } + }, + apis: [ + join(__dirname, '../src/routes/*.ts'), + join(__dirname, '../src/openapi-extra.yaml') + ] +}; + +const spec = swaggerJsdoc(options); +const outPath = join(__dirname, '../public/openapi.json'); +writeFileSync(outPath, JSON.stringify(spec, null, 2)); +console.log(`✅ Generated ${outPath} (${Object.keys(spec.paths || {}).length} paths)`); diff --git a/src/openapi-extra.yaml b/src/openapi-extra.yaml new file mode 100644 index 0000000..0e54464 --- /dev/null +++ b/src/openapi-extra.yaml @@ -0,0 +1,63 @@ +paths: + /v1/signup/free: + post: + tags: + - Account + summary: Free signup (discontinued) + description: | + Free accounts have been discontinued. Use the demo endpoint for testing + or subscribe to Pro for production use. + deprecated: true + requestBody: + content: + application/json: + schema: + type: object + properties: + email: + type: string + format: email + responses: + '410': + description: Free accounts discontinued + content: + application/json: + schema: + type: object + properties: + error: + type: string + example: 'Free accounts have been discontinued.' + demo_endpoint: + type: string + example: '/v1/demo/html' + pro_url: + type: string + example: 'https://docfast.dev/#pricing' + /v1/usage: + get: + tags: + - System + summary: Usage statistics (admin only) + description: Returns usage statistics for the authenticated user. Requires admin API key. + security: + - BearerAuth: [] + - ApiKeyHeader: [] + responses: + '200': + description: Usage statistics + content: + application/json: + schema: + type: object + additionalProperties: + type: object + properties: + count: + type: integer + month: + type: string + '403': + description: Admin access required + '503': + description: Admin access not configured diff --git a/src/routes/billing.ts b/src/routes/billing.ts index 98c8a91..8b66921 100644 --- a/src/routes/billing.ts +++ b/src/routes/billing.ts @@ -56,7 +56,35 @@ const checkoutLimiter = rateLimit({ message: { error: "Too many checkout requests. Please try again later." }, }); -// Create a Stripe Checkout session for Pro subscription +/** + * @openapi + * /v1/billing/checkout: + * post: + * tags: [Billing] + * summary: Create a Stripe checkout session + * description: | + * Creates a Stripe Checkout session for a Pro subscription (€9/month). + * Returns a URL to redirect the user to Stripe's hosted payment page. + * Rate limited to 3 requests per hour per IP. + * responses: + * 200: + * description: Checkout session created + * content: + * application/json: + * schema: + * type: object + * properties: + * url: + * type: string + * format: uri + * description: Stripe Checkout URL to redirect the user to + * 413: + * description: Request body too large + * 429: + * description: Too many checkout requests + * 500: + * description: Failed to create checkout session + */ router.post("/checkout", checkoutLimiter, async (req: Request, res: Response) => { // Reject suspiciously large request bodies (>1KB) const contentLength = parseInt(req.headers["content-length"] || "0", 10); diff --git a/src/routes/convert.ts b/src/routes/convert.ts index 3f850a7..de7b984 100644 --- a/src/routes/convert.ts +++ b/src/routes/convert.ts @@ -49,7 +49,55 @@ interface ConvertBody { filename?: string; } -// POST /v1/convert/html +/** + * @openapi + * /v1/convert/html: + * post: + * tags: [Conversion] + * summary: Convert HTML to PDF + * description: Converts HTML content to a PDF document. Bare HTML fragments are automatically wrapped in a full HTML document. + * security: + * - BearerAuth: [] + * - ApiKeyHeader: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * allOf: + * - type: object + * required: [html] + * properties: + * html: + * type: string + * description: HTML content to convert. Can be a full document or a fragment. + * example: '

Hello World

My first PDF

' + * css: + * type: string + * description: Optional CSS to inject (only used when html is a fragment, not a full document) + * example: 'body { font-family: sans-serif; padding: 40px; }' + * - $ref: '#/components/schemas/PdfOptions' + * responses: + * 200: + * description: PDF document + * content: + * application/pdf: + * schema: + * type: string + * format: binary + * 400: + * description: Missing html field + * 401: + * description: Missing API key + * 403: + * description: Invalid API key + * 415: + * description: Unsupported Content-Type (must be application/json) + * 429: + * description: Rate limit or usage limit exceeded + * 500: + * description: PDF generation failed + */ convertRouter.post("/html", async (req: Request & { acquirePdfSlot?: () => Promise; releasePdfSlot?: () => void }, res: Response) => { let slotAcquired = false; try { @@ -103,7 +151,54 @@ convertRouter.post("/html", async (req: Request & { acquirePdfSlot?: () => Promi } }); -// POST /v1/convert/markdown +/** + * @openapi + * /v1/convert/markdown: + * post: + * tags: [Conversion] + * summary: Convert Markdown to PDF + * description: Converts Markdown content to HTML and then to a PDF document. + * security: + * - BearerAuth: [] + * - ApiKeyHeader: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * allOf: + * - type: object + * required: [markdown] + * properties: + * markdown: + * type: string + * description: Markdown content to convert + * example: '# Hello World\n\nThis is **bold** and *italic*.' + * css: + * type: string + * description: Optional CSS to inject into the rendered HTML + * - $ref: '#/components/schemas/PdfOptions' + * responses: + * 200: + * description: PDF document + * content: + * application/pdf: + * schema: + * type: string + * format: binary + * 400: + * description: Missing markdown field + * 401: + * description: Missing API key + * 403: + * description: Invalid API key + * 415: + * description: Unsupported Content-Type + * 429: + * description: Rate limit or usage limit exceeded + * 500: + * description: PDF generation failed + */ convertRouter.post("/markdown", async (req: Request & { acquirePdfSlot?: () => Promise; releasePdfSlot?: () => void }, res: Response) => { let slotAcquired = false; try { @@ -153,7 +248,59 @@ convertRouter.post("/markdown", async (req: Request & { acquirePdfSlot?: () => P } }); -// POST /v1/convert/url +/** + * @openapi + * /v1/convert/url: + * post: + * tags: [Conversion] + * summary: Convert URL to PDF + * description: | + * Fetches a URL and converts the rendered page to PDF. JavaScript is disabled for security. + * Private/internal IP addresses are blocked (SSRF protection). DNS is pinned to prevent rebinding. + * security: + * - BearerAuth: [] + * - ApiKeyHeader: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * allOf: + * - type: object + * required: [url] + * properties: + * url: + * type: string + * format: uri + * description: URL to convert (http or https only) + * example: 'https://example.com' + * waitUntil: + * type: string + * enum: [load, domcontentloaded, networkidle0, networkidle2] + * default: domcontentloaded + * description: When to consider navigation finished + * - $ref: '#/components/schemas/PdfOptions' + * responses: + * 200: + * description: PDF document + * content: + * application/pdf: + * schema: + * type: string + * format: binary + * 400: + * description: Missing/invalid URL or URL resolves to private IP + * 401: + * description: Missing API key + * 403: + * description: Invalid API key + * 415: + * description: Unsupported Content-Type + * 429: + * description: Rate limit or usage limit exceeded + * 500: + * description: PDF generation failed + */ convertRouter.post("/url", async (req: Request & { acquirePdfSlot?: () => Promise; releasePdfSlot?: () => void }, res: Response) => { let slotAcquired = false; try { diff --git a/src/routes/demo.ts b/src/routes/demo.ts index 9551f94..e664862 100644 --- a/src/routes/demo.ts +++ b/src/routes/demo.ts @@ -42,7 +42,59 @@ function sanitizeFilename(name: string): string { return name.replace(/[\x00-\x1f"\\\r\n]/g, "").trim() || "document.pdf"; } -// POST /v1/demo/html +/** + * @openapi + * /v1/demo/html: + * post: + * tags: [Demo] + * summary: Convert HTML to PDF (demo) + * description: | + * Public endpoint — no API key required. Rate limited to 5 requests per hour per IP. + * Output PDFs include a DocFast watermark. Upgrade to Pro for clean output. + * requestBody: + * required: true + * content: + * application/json: + * schema: + * allOf: + * - type: object + * required: [html] + * properties: + * html: + * type: string + * description: HTML content to convert + * example: '

Hello World

My first PDF

' + * css: + * type: string + * description: Optional CSS to inject (used when html is a fragment) + * - $ref: '#/components/schemas/PdfOptions' + * responses: + * 200: + * description: Watermarked PDF document + * content: + * application/pdf: + * schema: + * type: string + * format: binary + * 400: + * description: Missing html field + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 415: + * description: Unsupported Content-Type + * 429: + * description: Demo rate limit exceeded (5/hour) + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 503: + * description: Server busy + * 504: + * description: PDF generation timed out + */ router.post("/html", async (req: Request & { acquirePdfSlot?: () => Promise; releasePdfSlot?: () => void }, res: Response) => { let slotAcquired = false; try { @@ -93,7 +145,51 @@ router.post("/html", async (req: Request & { acquirePdfSlot?: () => Promise Promise; releasePdfSlot?: () => void }, res: Response) => { let slotAcquired = false; try { diff --git a/src/routes/health.ts b/src/routes/health.ts index 0c97dc0..39bfb23 100644 --- a/src/routes/health.ts +++ b/src/routes/health.ts @@ -10,6 +10,56 @@ export const healthRouter = Router(); const HEALTH_CHECK_TIMEOUT_MS = 3000; +/** + * @openapi + * /health: + * get: + * tags: [System] + * summary: Health check + * description: Returns service health status including database connectivity and browser pool stats. + * responses: + * 200: + * description: Service is healthy + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: string + * enum: [ok, degraded] + * version: + * type: string + * example: '0.4.0' + * database: + * type: object + * properties: + * status: + * type: string + * enum: [ok, error] + * version: + * type: string + * example: 'PostgreSQL 17.4' + * pool: + * type: object + * properties: + * size: + * type: integer + * active: + * type: integer + * available: + * type: integer + * queueDepth: + * type: integer + * pdfCount: + * type: integer + * restarting: + * type: boolean + * uptimeSeconds: + * type: integer + * 503: + * description: Service is degraded (database issue) + */ healthRouter.get("/", async (_req, res) => { const poolStats = getPoolStats(); let databaseStatus: any; diff --git a/src/routes/recover.ts b/src/routes/recover.ts index 4ada099..2b8ca7e 100644 --- a/src/routes/recover.ts +++ b/src/routes/recover.ts @@ -15,6 +15,46 @@ const recoverLimiter = rateLimit({ legacyHeaders: false, }); +/** + * @openapi + * /v1/recover: + * post: + * tags: [Account] + * summary: Request API key recovery + * description: | + * Sends a 6-digit verification code to the email address if an account exists. + * Response is always the same regardless of whether the email exists (to prevent enumeration). + * Rate limited to 3 requests per hour. + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [email] + * properties: + * email: + * type: string + * format: email + * description: Email address associated with the API key + * responses: + * 200: + * description: Recovery code sent (or no-op if email not found) + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: string + * example: recovery_sent + * message: + * type: string + * 400: + * description: Invalid email format + * 429: + * description: Too many recovery attempts + */ router.post("/", recoverLimiter, async (req: Request, res: Response) => { const { email } = req.body || {}; @@ -41,6 +81,52 @@ router.post("/", recoverLimiter, async (req: Request, res: Response) => { res.json({ status: "recovery_sent", message: "If an account exists for this email, a verification code has been sent." }); }); +/** + * @openapi + * /v1/recover/verify: + * post: + * tags: [Account] + * summary: Verify recovery code and retrieve API key + * description: Verifies the 6-digit code sent via email and returns the API key if valid. Code expires after 15 minutes. + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [email, code] + * properties: + * email: + * type: string + * format: email + * code: + * type: string + * pattern: '^\d{6}$' + * description: 6-digit verification code + * responses: + * 200: + * description: API key recovered + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: string + * example: recovered + * apiKey: + * type: string + * description: The recovered API key + * tier: + * type: string + * enum: [free, pro] + * 400: + * description: Invalid verification code or missing fields + * 410: + * description: Verification code expired + * 429: + * description: Too many failed attempts + */ router.post("/verify", recoverLimiter, async (req: Request, res: Response) => { const { email, code } = req.body || {}; diff --git a/src/routes/templates.ts b/src/routes/templates.ts index 944bbd8..5210f4f 100644 --- a/src/routes/templates.ts +++ b/src/routes/templates.ts @@ -9,7 +9,53 @@ function sanitizeFilename(name: string): string { export const templatesRouter = Router(); -// GET /v1/templates — list available templates +/** + * @openapi + * /v1/templates: + * get: + * tags: [Templates] + * summary: List available templates + * description: Returns a list of all built-in document templates with their required fields. + * security: + * - BearerAuth: [] + * - ApiKeyHeader: [] + * responses: + * 200: + * description: List of templates + * content: + * application/json: + * schema: + * type: object + * properties: + * templates: + * type: array + * items: + * type: object + * properties: + * id: + * type: string + * example: invoice + * name: + * type: string + * example: Invoice + * description: + * type: string + * fields: + * type: array + * items: + * type: object + * properties: + * name: + * type: string + * required: + * type: boolean + * description: + * type: string + * 401: + * description: Missing API key + * 403: + * description: Invalid API key + */ templatesRouter.get("/", (_req: Request, res: Response) => { const list = Object.entries(templates).map(([id, t]) => ({ id, @@ -20,7 +66,71 @@ templatesRouter.get("/", (_req: Request, res: Response) => { res.json({ templates: list }); }); -// POST /v1/templates/:id/render — render template to PDF +/** + * @openapi + * /v1/templates/{id}/render: + * post: + * tags: [Templates] + * summary: Render a template to PDF + * description: | + * Renders a built-in template with the provided data and returns a PDF. + * Use GET /v1/templates to see available templates and their required fields. + * Special fields: `_format` (page size), `_margin` (page margins), `_filename` (output filename). + * security: + * - BearerAuth: [] + * - ApiKeyHeader: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * description: Template ID (e.g. "invoice", "receipt") + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * data: + * type: object + * description: Template data (fields depend on template). Can also be passed at root level. + * _format: + * type: string + * enum: [A4, Letter, Legal, A3, A5, Tabloid] + * default: A4 + * description: Page size override + * _margin: + * type: object + * properties: + * top: { type: string } + * right: { type: string } + * bottom: { type: string } + * left: { type: string } + * description: Page margin override + * _filename: + * type: string + * description: Custom output filename + * responses: + * 200: + * description: PDF document + * content: + * application/pdf: + * schema: + * type: string + * format: binary + * 400: + * description: Missing required template fields + * 401: + * description: Missing API key + * 403: + * description: Invalid API key + * 404: + * description: Template not found + * 500: + * description: Template rendering failed + */ templatesRouter.post("/:id/render", async (req: Request, res: Response) => { try { const id = req.params.id as string; From 825c6562baff1b25983b1002a12e47ca244ddeb7 Mon Sep 17 00:00:00 2001 From: OpenClaw Date: Fri, 20 Feb 2026 07:56:56 +0000 Subject: [PATCH 002/169] feat: wire up swagger-jsdoc dynamic spec, delete static openapi.json - Create src/swagger.ts config module for swagger-jsdoc - Add GET /openapi.json dynamic route (generated from @openapi annotations) - Delete static public/openapi.json (was drifting from code) - Add @openapi annotation for deprecated /v1/signup/free in index.ts - Import swaggerSpec into index.ts - All 12 endpoints now code-driven: demo/html, demo/markdown, convert/html, convert/markdown, convert/url, templates, templates/{id}/render, recover, recover/verify, billing/checkout, signup/free, health --- dist/index.js | 58 ++- dist/routes/billing.js | 50 +- dist/routes/convert.js | 153 +++++- dist/routes/health.js | 50 ++ dist/routes/recover.js | 86 ++++ dist/routes/templates.js | 114 ++++- package-lock.json | 8 + package.json | 3 +- public/openapi.json | 1052 -------------------------------------- src/index.ts | 28 + src/swagger.ts | 92 ++++ 11 files changed, 624 insertions(+), 1070 deletions(-) delete mode 100644 public/openapi.json create mode 100644 src/swagger.ts diff --git a/dist/index.js b/dist/index.js index 4152bcf..d6e4197 100644 --- a/dist/index.js +++ b/dist/index.js @@ -1,15 +1,18 @@ import express from "express"; import { randomUUID } from "crypto"; +import { createRequire } from "module"; import { compressionMiddleware } from "./middleware/compression.js"; import logger from "./services/logger.js"; import helmet from "helmet"; +const _require = createRequire(import.meta.url); +const APP_VERSION = _require("../package.json").version; import path from "path"; import { fileURLToPath } from "url"; import rateLimit from "express-rate-limit"; import { convertRouter } from "./routes/convert.js"; import { templatesRouter } from "./routes/templates.js"; import { healthRouter } from "./routes/health.js"; -import { signupRouter } from "./routes/signup.js"; +import { demoRouter } from "./routes/demo.js"; import { recoverRouter } from "./routes/recover.js"; import { billingRouter } from "./routes/billing.js"; import { authMiddleware } from "./middleware/auth.js"; @@ -20,6 +23,7 @@ import { initBrowser, closeBrowser } from "./services/browser.js"; import { loadKeys, getAllKeys } from "./services/keys.js"; import { verifyToken, loadVerifications } from "./services/verification.js"; import { initDatabase, pool } from "./services/db.js"; +import { swaggerSpec } from "./swagger.js"; const app = express(); const PORT = parseInt(process.env.PORT || "3100", 10); app.use(helmet({ crossOriginResourcePolicy: { policy: "cross-origin" } })); @@ -48,7 +52,8 @@ app.use(compressionMiddleware); app.use((req, res, next) => { const isAuthBillingRoute = req.path.startsWith('/v1/signup') || req.path.startsWith('/v1/recover') || - req.path.startsWith('/v1/billing'); + req.path.startsWith('/v1/billing') || + req.path.startsWith('/v1/demo'); if (isAuthBillingRoute) { res.setHeader("Access-Control-Allow-Origin", "https://docfast.dev"); } @@ -80,7 +85,36 @@ const limiter = rateLimit({ app.use(limiter); // Public routes app.use("/health", healthRouter); -app.use("/v1/signup", signupRouter); +app.use("/v1/demo", express.json({ limit: "50kb" }), pdfRateLimitMiddleware, demoRouter); +/** + * @openapi + * /v1/signup/free: + * post: + * tags: [Account] + * summary: Request a free API key (discontinued) + * description: Free accounts have been discontinued. Use the demo endpoints or upgrade to Pro. + * responses: + * 410: + * description: Feature discontinued + * content: + * application/json: + * schema: + * type: object + * properties: + * error: + * type: string + * demo_endpoint: + * type: string + * pro_url: + * type: string + */ +app.use("/v1/signup", (_req, res) => { + res.status(410).json({ + error: "Free accounts have been discontinued. Try our demo at POST /v1/demo/html or upgrade to Pro at https://docfast.dev", + demo_endpoint: "/v1/demo/html", + pro_url: "https://docfast.dev/#pricing" + }); +}); app.use("/v1/recover", recoverRouter); app.use("/v1/billing", billingRouter); // Authenticated routes — conversion routes get tighter body limits (500KB) @@ -156,7 +190,7 @@ p{color:#7a8194;margin-bottom:24px;line-height:1.6} ${apiKey ? `
⚠️ Save your API key securely. You can recover it via email if needed.
${apiKey}
- + ` : ``} `; } @@ -168,6 +202,10 @@ app.get("/favicon.ico", (_req, res) => { res.setHeader('Cache-Control', 'public, max-age=604800'); res.sendFile(path.join(__dirname, "../public/favicon.svg")); }); +// Dynamic OpenAPI spec — generated from @openapi JSDoc annotations at startup +app.get("/openapi.json", (_req, res) => { + res.json(swaggerSpec); +}); // Docs page (clean URL) app.get("/docs", (_req, res) => { // Swagger UI 5.x uses new Function() (via ajv) for JSON schema validation. @@ -179,7 +217,6 @@ app.get("/docs", (_req, res) => { // Static asset cache headers middleware app.use((req, res, next) => { if (/\.(css|js|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$/.test(req.path)) { - console.log("CACHE HIT:", req.path); res.setHeader('Cache-Control', 'public, max-age=604800, immutable'); } next(); @@ -209,12 +246,13 @@ app.get("/status", (_req, res) => { app.get("/api", (_req, res) => { res.json({ name: "DocFast API", - version: "0.2.9", + version: APP_VERSION, endpoints: [ - "POST /v1/signup/free — Get a free API key", - "POST /v1/convert/html", - "POST /v1/convert/markdown", - "POST /v1/convert/url", + "POST /v1/demo/html — Try HTML→PDF (no auth, watermarked, 5/hour)", + "POST /v1/demo/markdown — Try Markdown→PDF (no auth, watermarked, 5/hour)", + "POST /v1/convert/html — HTML→PDF (requires API key)", + "POST /v1/convert/markdown — Markdown→PDF (requires API key)", + "POST /v1/convert/url — URL→PDF (requires API key)", "POST /v1/templates/:id/render", "GET /v1/templates", "POST /v1/billing/checkout — Start Pro subscription", diff --git a/dist/routes/billing.js b/dist/routes/billing.js index 0c59405..dbc7c3b 100644 --- a/dist/routes/billing.js +++ b/dist/routes/billing.js @@ -1,4 +1,5 @@ import { Router } from "express"; +import rateLimit from "express-rate-limit"; import Stripe from "stripe"; import { createProKey, downgradeByCustomer, updateEmailByCustomer } from "../services/keys.js"; import logger from "../services/logger.js"; @@ -39,8 +40,51 @@ async function isDocFastSubscription(subscriptionId) { return false; } } -// Create a Stripe Checkout session for Pro subscription -router.post("/checkout", async (_req, res) => { +// Rate limit checkout: max 3 requests per IP per hour +const checkoutLimiter = rateLimit({ + windowMs: 60 * 60 * 1000, // 1 hour + max: 3, + keyGenerator: (req) => req.ip || req.socket.remoteAddress || "unknown", + standardHeaders: true, + legacyHeaders: false, + message: { error: "Too many checkout requests. Please try again later." }, +}); +/** + * @openapi + * /v1/billing/checkout: + * post: + * tags: [Billing] + * summary: Create a Stripe checkout session + * description: | + * Creates a Stripe Checkout session for a Pro subscription (€9/month). + * Returns a URL to redirect the user to Stripe's hosted payment page. + * Rate limited to 3 requests per hour per IP. + * responses: + * 200: + * description: Checkout session created + * content: + * application/json: + * schema: + * type: object + * properties: + * url: + * type: string + * format: uri + * description: Stripe Checkout URL to redirect the user to + * 413: + * description: Request body too large + * 429: + * description: Too many checkout requests + * 500: + * description: Failed to create checkout session + */ +router.post("/checkout", checkoutLimiter, async (req, res) => { + // Reject suspiciously large request bodies (>1KB) + const contentLength = parseInt(req.headers["content-length"] || "0", 10); + if (contentLength > 1024) { + res.status(413).json({ error: "Request body too large" }); + return; + } try { const priceId = await getOrCreateProPrice(); const session = await getStripe().checkout.sessions.create({ @@ -50,6 +94,8 @@ router.post("/checkout", async (_req, res) => { success_url: `${process.env.BASE_URL || "https://docfast.dev"}/v1/billing/success?session_id={CHECKOUT_SESSION_ID}`, cancel_url: `${process.env.BASE_URL || "https://docfast.dev"}/#pricing`, }); + const clientIp = req.ip || req.socket.remoteAddress || "unknown"; + logger.info({ clientIp, sessionId: session.id }, "Checkout session created"); res.json({ url: session.url }); } catch (err) { diff --git a/dist/routes/convert.js b/dist/routes/convert.js index d7b7721..0aa9c50 100644 --- a/dist/routes/convert.js +++ b/dist/routes/convert.js @@ -41,7 +41,55 @@ function sanitizeFilename(name) { return name.replace(/[\x00-\x1f"\\\r\n]/g, "").trim() || "document.pdf"; } export const convertRouter = Router(); -// POST /v1/convert/html +/** + * @openapi + * /v1/convert/html: + * post: + * tags: [Conversion] + * summary: Convert HTML to PDF + * description: Converts HTML content to a PDF document. Bare HTML fragments are automatically wrapped in a full HTML document. + * security: + * - BearerAuth: [] + * - ApiKeyHeader: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * allOf: + * - type: object + * required: [html] + * properties: + * html: + * type: string + * description: HTML content to convert. Can be a full document or a fragment. + * example: '

Hello World

My first PDF

' + * css: + * type: string + * description: Optional CSS to inject (only used when html is a fragment, not a full document) + * example: 'body { font-family: sans-serif; padding: 40px; }' + * - $ref: '#/components/schemas/PdfOptions' + * responses: + * 200: + * description: PDF document + * content: + * application/pdf: + * schema: + * type: string + * format: binary + * 400: + * description: Missing html field + * 401: + * description: Missing API key + * 403: + * description: Invalid API key + * 415: + * description: Unsupported Content-Type (must be application/json) + * 429: + * description: Rate limit or usage limit exceeded + * 500: + * description: PDF generation failed + */ convertRouter.post("/html", async (req, res) => { let slotAcquired = false; try { @@ -90,7 +138,54 @@ convertRouter.post("/html", async (req, res) => { } } }); -// POST /v1/convert/markdown +/** + * @openapi + * /v1/convert/markdown: + * post: + * tags: [Conversion] + * summary: Convert Markdown to PDF + * description: Converts Markdown content to HTML and then to a PDF document. + * security: + * - BearerAuth: [] + * - ApiKeyHeader: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * allOf: + * - type: object + * required: [markdown] + * properties: + * markdown: + * type: string + * description: Markdown content to convert + * example: '# Hello World\n\nThis is **bold** and *italic*.' + * css: + * type: string + * description: Optional CSS to inject into the rendered HTML + * - $ref: '#/components/schemas/PdfOptions' + * responses: + * 200: + * description: PDF document + * content: + * application/pdf: + * schema: + * type: string + * format: binary + * 400: + * description: Missing markdown field + * 401: + * description: Missing API key + * 403: + * description: Invalid API key + * 415: + * description: Unsupported Content-Type + * 429: + * description: Rate limit or usage limit exceeded + * 500: + * description: PDF generation failed + */ convertRouter.post("/markdown", async (req, res) => { let slotAcquired = false; try { @@ -136,7 +231,59 @@ convertRouter.post("/markdown", async (req, res) => { } } }); -// POST /v1/convert/url +/** + * @openapi + * /v1/convert/url: + * post: + * tags: [Conversion] + * summary: Convert URL to PDF + * description: | + * Fetches a URL and converts the rendered page to PDF. JavaScript is disabled for security. + * Private/internal IP addresses are blocked (SSRF protection). DNS is pinned to prevent rebinding. + * security: + * - BearerAuth: [] + * - ApiKeyHeader: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * allOf: + * - type: object + * required: [url] + * properties: + * url: + * type: string + * format: uri + * description: URL to convert (http or https only) + * example: 'https://example.com' + * waitUntil: + * type: string + * enum: [load, domcontentloaded, networkidle0, networkidle2] + * default: domcontentloaded + * description: When to consider navigation finished + * - $ref: '#/components/schemas/PdfOptions' + * responses: + * 200: + * description: PDF document + * content: + * application/pdf: + * schema: + * type: string + * format: binary + * 400: + * description: Missing/invalid URL or URL resolves to private IP + * 401: + * description: Missing API key + * 403: + * description: Invalid API key + * 415: + * description: Unsupported Content-Type + * 429: + * description: Rate limit or usage limit exceeded + * 500: + * description: PDF generation failed + */ convertRouter.post("/url", async (req, res) => { let slotAcquired = false; try { diff --git a/dist/routes/health.js b/dist/routes/health.js index cf3a146..219fcd2 100644 --- a/dist/routes/health.js +++ b/dist/routes/health.js @@ -6,6 +6,56 @@ const require = createRequire(import.meta.url); const { version: APP_VERSION } = require("../../package.json"); export const healthRouter = Router(); const HEALTH_CHECK_TIMEOUT_MS = 3000; +/** + * @openapi + * /health: + * get: + * tags: [System] + * summary: Health check + * description: Returns service health status including database connectivity and browser pool stats. + * responses: + * 200: + * description: Service is healthy + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: string + * enum: [ok, degraded] + * version: + * type: string + * example: '0.4.0' + * database: + * type: object + * properties: + * status: + * type: string + * enum: [ok, error] + * version: + * type: string + * example: 'PostgreSQL 17.4' + * pool: + * type: object + * properties: + * size: + * type: integer + * active: + * type: integer + * available: + * type: integer + * queueDepth: + * type: integer + * pdfCount: + * type: integer + * restarting: + * type: boolean + * uptimeSeconds: + * type: integer + * 503: + * description: Service is degraded (database issue) + */ healthRouter.get("/", async (_req, res) => { const poolStats = getPoolStats(); let databaseStatus; diff --git a/dist/routes/recover.js b/dist/routes/recover.js index cf8bc9f..f1d13fc 100644 --- a/dist/routes/recover.js +++ b/dist/routes/recover.js @@ -12,6 +12,46 @@ const recoverLimiter = rateLimit({ standardHeaders: true, legacyHeaders: false, }); +/** + * @openapi + * /v1/recover: + * post: + * tags: [Account] + * summary: Request API key recovery + * description: | + * Sends a 6-digit verification code to the email address if an account exists. + * Response is always the same regardless of whether the email exists (to prevent enumeration). + * Rate limited to 3 requests per hour. + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [email] + * properties: + * email: + * type: string + * format: email + * description: Email address associated with the API key + * responses: + * 200: + * description: Recovery code sent (or no-op if email not found) + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: string + * example: recovery_sent + * message: + * type: string + * 400: + * description: Invalid email format + * 429: + * description: Too many recovery attempts + */ router.post("/", recoverLimiter, async (req, res) => { const { email } = req.body || {}; if (!email || typeof email !== "string" || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { @@ -31,6 +71,52 @@ router.post("/", recoverLimiter, async (req, res) => { }); res.json({ status: "recovery_sent", message: "If an account exists for this email, a verification code has been sent." }); }); +/** + * @openapi + * /v1/recover/verify: + * post: + * tags: [Account] + * summary: Verify recovery code and retrieve API key + * description: Verifies the 6-digit code sent via email and returns the API key if valid. Code expires after 15 minutes. + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [email, code] + * properties: + * email: + * type: string + * format: email + * code: + * type: string + * pattern: '^\d{6}$' + * description: 6-digit verification code + * responses: + * 200: + * description: API key recovered + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: string + * example: recovered + * apiKey: + * type: string + * description: The recovered API key + * tier: + * type: string + * enum: [free, pro] + * 400: + * description: Invalid verification code or missing fields + * 410: + * description: Verification code expired + * 429: + * description: Too many failed attempts + */ router.post("/verify", recoverLimiter, async (req, res) => { const { email, code } = req.body || {}; if (!email || !code) { diff --git a/dist/routes/templates.js b/dist/routes/templates.js index 5957e83..dae4e9d 100644 --- a/dist/routes/templates.js +++ b/dist/routes/templates.js @@ -6,7 +6,53 @@ function sanitizeFilename(name) { return name.replace(/["\r\n\x00-\x1f]/g, "_").substring(0, 200); } export const templatesRouter = Router(); -// GET /v1/templates — list available templates +/** + * @openapi + * /v1/templates: + * get: + * tags: [Templates] + * summary: List available templates + * description: Returns a list of all built-in document templates with their required fields. + * security: + * - BearerAuth: [] + * - ApiKeyHeader: [] + * responses: + * 200: + * description: List of templates + * content: + * application/json: + * schema: + * type: object + * properties: + * templates: + * type: array + * items: + * type: object + * properties: + * id: + * type: string + * example: invoice + * name: + * type: string + * example: Invoice + * description: + * type: string + * fields: + * type: array + * items: + * type: object + * properties: + * name: + * type: string + * required: + * type: boolean + * description: + * type: string + * 401: + * description: Missing API key + * 403: + * description: Invalid API key + */ templatesRouter.get("/", (_req, res) => { const list = Object.entries(templates).map(([id, t]) => ({ id, @@ -16,7 +62,71 @@ templatesRouter.get("/", (_req, res) => { })); res.json({ templates: list }); }); -// POST /v1/templates/:id/render — render template to PDF +/** + * @openapi + * /v1/templates/{id}/render: + * post: + * tags: [Templates] + * summary: Render a template to PDF + * description: | + * Renders a built-in template with the provided data and returns a PDF. + * Use GET /v1/templates to see available templates and their required fields. + * Special fields: `_format` (page size), `_margin` (page margins), `_filename` (output filename). + * security: + * - BearerAuth: [] + * - ApiKeyHeader: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * description: Template ID (e.g. "invoice", "receipt") + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * data: + * type: object + * description: Template data (fields depend on template). Can also be passed at root level. + * _format: + * type: string + * enum: [A4, Letter, Legal, A3, A5, Tabloid] + * default: A4 + * description: Page size override + * _margin: + * type: object + * properties: + * top: { type: string } + * right: { type: string } + * bottom: { type: string } + * left: { type: string } + * description: Page margin override + * _filename: + * type: string + * description: Custom output filename + * responses: + * 200: + * description: PDF document + * content: + * application/pdf: + * schema: + * type: string + * format: binary + * 400: + * description: Missing required template fields + * 401: + * description: Missing API key + * 403: + * description: Invalid API key + * 404: + * description: Template not found + * 500: + * description: Template rendering failed + */ templatesRouter.post("/:id/render", async (req, res) => { try { const id = req.params.id; diff --git a/package-lock.json b/package-lock.json index 3a19ea0..349c20e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "@types/node": "^22.0.0", "@types/nodemailer": "^7.0.9", "@types/pg": "^8.11.0", + "@types/swagger-jsdoc": "^6.0.4", "terser": "^5.46.0", "tsx": "^4.19.0", "typescript": "^5.7.0", @@ -1170,6 +1171,13 @@ "@types/node": "*" } }, + "node_modules/@types/swagger-jsdoc": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@types/swagger-jsdoc/-/swagger-jsdoc-6.0.4.tgz", + "integrity": "sha512-W+Xw5epcOZrF/AooUM/PccNMSAFOKWZA5dasNyMujTwsBkU74njSJBpvCCJhHAJ95XRMzQrrW844Btu0uoetwQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/yauzl": { "version": "2.10.3", "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", diff --git a/package.json b/package.json index 1844068..9395b77 100644 --- a/package.json +++ b/package.json @@ -32,10 +32,11 @@ "@types/node": "^22.0.0", "@types/nodemailer": "^7.0.9", "@types/pg": "^8.11.0", + "@types/swagger-jsdoc": "^6.0.4", "terser": "^5.46.0", "tsx": "^4.19.0", "typescript": "^5.7.0", "vitest": "^3.0.0" }, "type": "module" -} \ No newline at end of file +} diff --git a/public/openapi.json b/public/openapi.json deleted file mode 100644 index 2192f5d..0000000 --- a/public/openapi.json +++ /dev/null @@ -1,1052 +0,0 @@ -{ - "openapi": "3.0.3", - "info": { - "title": "DocFast API", - "version": "1.0.0", - "description": "Convert HTML, Markdown, and URLs to pixel-perfect PDFs. Built-in invoice & receipt templates.\n\n## Authentication\nAll conversion and template endpoints require an API key via `Authorization: Bearer ` or `X-API-Key: ` header.\n\n## Demo Endpoints\nTry the API without signing up! Demo endpoints are public (no API key needed) but rate-limited to 5 requests/hour per IP and produce watermarked PDFs.\n\n## Rate Limits\n- Demo: 5 PDFs/hour per IP (watermarked)\n- Pro tier: 5,000 PDFs/month, 30 req/min\n\n## Getting Started\n1. Try the demo at `POST /v1/demo/html` — no signup needed\n2. Subscribe to Pro at [docfast.dev](https://docfast.dev/#pricing) for clean PDFs\n3. Use your API key to convert documents", - "contact": { - "name": "DocFast", - "url": "https://docfast.dev", - "email": "support@docfast.dev" - } - }, - "servers": [ - { - "url": "https://docfast.dev", - "description": "Production" - } - ], - "tags": [ - { - "name": "Demo", - "description": "Try the API without signing up — watermarked PDFs, rate-limited" - }, - { - "name": "Conversion", - "description": "Convert HTML, Markdown, or URLs to PDF (requires API key)" - }, - { - "name": "Templates", - "description": "Built-in document templates" - }, - { - "name": "Account", - "description": "Key recovery and email management" - }, - { - "name": "Billing", - "description": "Stripe-powered subscription management" - }, - { - "name": "System", - "description": "Health checks and usage stats" - } - ], - "components": { - "securitySchemes": { - "BearerAuth": { - "type": "http", - "scheme": "bearer", - "description": "API key as Bearer token" - }, - "ApiKeyHeader": { - "type": "apiKey", - "in": "header", - "name": "X-API-Key", - "description": "API key via X-API-Key header" - } - }, - "schemas": { - "PdfOptions": { - "type": "object", - "properties": { - "format": { - "type": "string", - "enum": [ - "A4", - "Letter", - "Legal", - "A3", - "A5", - "Tabloid" - ], - "default": "A4", - "description": "Page size" - }, - "landscape": { - "type": "boolean", - "default": false, - "description": "Landscape orientation" - }, - "margin": { - "type": "object", - "properties": { - "top": { - "type": "string", - "description": "Top margin (e.g. \"10mm\", \"1in\")", - "default": "0" - }, - "right": { - "type": "string", - "description": "Right margin", - "default": "0" - }, - "bottom": { - "type": "string", - "description": "Bottom margin", - "default": "0" - }, - "left": { - "type": "string", - "description": "Left margin", - "default": "0" - } - }, - "description": "Page margins" - }, - "printBackground": { - "type": "boolean", - "default": true, - "description": "Print background colors and images" - }, - "filename": { - "type": "string", - "description": "Custom filename for Content-Disposition header", - "default": "document.pdf" - } - } - }, - "Error": { - "type": "object", - "properties": { - "error": { - "type": "string", - "description": "Error message" - } - }, - "required": [ - "error" - ] - } - } - }, - "paths": { - "/v1/billing/checkout": { - "post": { - "tags": [ - "Billing" - ], - "summary": "Create a Stripe checkout session", - "description": "Creates a Stripe Checkout session for a Pro subscription (€9/month).\nReturns a URL to redirect the user to Stripe's hosted payment page.\nRate limited to 3 requests per hour per IP.\n", - "responses": { - "200": { - "description": "Checkout session created", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "url": { - "type": "string", - "format": "uri", - "description": "Stripe Checkout URL to redirect the user to" - } - } - } - } - } - }, - "413": { - "description": "Request body too large" - }, - "429": { - "description": "Too many checkout requests" - }, - "500": { - "description": "Failed to create checkout session" - } - } - } - }, - "/v1/convert/html": { - "post": { - "tags": [ - "Conversion" - ], - "summary": "Convert HTML to PDF", - "description": "Converts HTML content to a PDF document. Bare HTML fragments are automatically wrapped in a full HTML document.", - "security": [ - { - "BearerAuth": [] - }, - { - "ApiKeyHeader": [] - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "type": "object", - "required": [ - "html" - ], - "properties": { - "html": { - "type": "string", - "description": "HTML content to convert. Can be a full document or a fragment.", - "example": "

Hello World

My first PDF

" - }, - "css": { - "type": "string", - "description": "Optional CSS to inject (only used when html is a fragment, not a full document)", - "example": "body { font-family: sans-serif; padding: 40px; }" - } - } - }, - { - "$ref": "#/components/schemas/PdfOptions" - } - ] - } - } - } - }, - "responses": { - "200": { - "description": "PDF document", - "content": { - "application/pdf": { - "schema": { - "type": "string", - "format": "binary" - } - } - } - }, - "400": { - "description": "Missing html field" - }, - "401": { - "description": "Missing API key" - }, - "403": { - "description": "Invalid API key" - }, - "415": { - "description": "Unsupported Content-Type (must be application/json)" - }, - "429": { - "description": "Rate limit or usage limit exceeded" - }, - "500": { - "description": "PDF generation failed" - } - } - } - }, - "/v1/convert/markdown": { - "post": { - "tags": [ - "Conversion" - ], - "summary": "Convert Markdown to PDF", - "description": "Converts Markdown content to HTML and then to a PDF document.", - "security": [ - { - "BearerAuth": [] - }, - { - "ApiKeyHeader": [] - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "type": "object", - "required": [ - "markdown" - ], - "properties": { - "markdown": { - "type": "string", - "description": "Markdown content to convert", - "example": "# Hello World\\n\\nThis is **bold** and *italic*." - }, - "css": { - "type": "string", - "description": "Optional CSS to inject into the rendered HTML" - } - } - }, - { - "$ref": "#/components/schemas/PdfOptions" - } - ] - } - } - } - }, - "responses": { - "200": { - "description": "PDF document", - "content": { - "application/pdf": { - "schema": { - "type": "string", - "format": "binary" - } - } - } - }, - "400": { - "description": "Missing markdown field" - }, - "401": { - "description": "Missing API key" - }, - "403": { - "description": "Invalid API key" - }, - "415": { - "description": "Unsupported Content-Type" - }, - "429": { - "description": "Rate limit or usage limit exceeded" - }, - "500": { - "description": "PDF generation failed" - } - } - } - }, - "/v1/convert/url": { - "post": { - "tags": [ - "Conversion" - ], - "summary": "Convert URL to PDF", - "description": "Fetches a URL and converts the rendered page to PDF. JavaScript is disabled for security.\nPrivate/internal IP addresses are blocked (SSRF protection). DNS is pinned to prevent rebinding.\n", - "security": [ - { - "BearerAuth": [] - }, - { - "ApiKeyHeader": [] - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "type": "object", - "required": [ - "url" - ], - "properties": { - "url": { - "type": "string", - "format": "uri", - "description": "URL to convert (http or https only)", - "example": "https://example.com" - }, - "waitUntil": { - "type": "string", - "enum": [ - "load", - "domcontentloaded", - "networkidle0", - "networkidle2" - ], - "default": "domcontentloaded", - "description": "When to consider navigation finished" - } - } - }, - { - "$ref": "#/components/schemas/PdfOptions" - } - ] - } - } - } - }, - "responses": { - "200": { - "description": "PDF document", - "content": { - "application/pdf": { - "schema": { - "type": "string", - "format": "binary" - } - } - } - }, - "400": { - "description": "Missing/invalid URL or URL resolves to private IP" - }, - "401": { - "description": "Missing API key" - }, - "403": { - "description": "Invalid API key" - }, - "415": { - "description": "Unsupported Content-Type" - }, - "429": { - "description": "Rate limit or usage limit exceeded" - }, - "500": { - "description": "PDF generation failed" - } - } - } - }, - "/v1/demo/html": { - "post": { - "tags": [ - "Demo" - ], - "summary": "Convert HTML to PDF (demo)", - "description": "Public endpoint — no API key required. Rate limited to 5 requests per hour per IP.\nOutput PDFs include a DocFast watermark. Upgrade to Pro for clean output.\n", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "type": "object", - "required": [ - "html" - ], - "properties": { - "html": { - "type": "string", - "description": "HTML content to convert", - "example": "

Hello World

My first PDF

" - }, - "css": { - "type": "string", - "description": "Optional CSS to inject (used when html is a fragment)" - } - } - }, - { - "$ref": "#/components/schemas/PdfOptions" - } - ] - } - } - } - }, - "responses": { - "200": { - "description": "Watermarked PDF document", - "content": { - "application/pdf": { - "schema": { - "type": "string", - "format": "binary" - } - } - } - }, - "400": { - "description": "Missing html field", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "415": { - "description": "Unsupported Content-Type" - }, - "429": { - "description": "Demo rate limit exceeded (5/hour)", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "503": { - "description": "Server busy" - }, - "504": { - "description": "PDF generation timed out" - } - } - } - }, - "/v1/demo/markdown": { - "post": { - "tags": [ - "Demo" - ], - "summary": "Convert Markdown to PDF (demo)", - "description": "Public endpoint — no API key required. Rate limited to 5 requests per hour per IP.\nMarkdown is converted to HTML then rendered to PDF with a DocFast watermark.\n", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "type": "object", - "required": [ - "markdown" - ], - "properties": { - "markdown": { - "type": "string", - "description": "Markdown content to convert", - "example": "# Hello World\\n\\nThis is **bold** and *italic*." - }, - "css": { - "type": "string", - "description": "Optional CSS to inject" - } - } - }, - { - "$ref": "#/components/schemas/PdfOptions" - } - ] - } - } - } - }, - "responses": { - "200": { - "description": "Watermarked PDF document", - "content": { - "application/pdf": { - "schema": { - "type": "string", - "format": "binary" - } - } - } - }, - "400": { - "description": "Missing markdown field" - }, - "415": { - "description": "Unsupported Content-Type" - }, - "429": { - "description": "Demo rate limit exceeded (5/hour)" - }, - "503": { - "description": "Server busy" - }, - "504": { - "description": "PDF generation timed out" - } - } - } - }, - "/health": { - "get": { - "tags": [ - "System" - ], - "summary": "Health check", - "description": "Returns service health status including database connectivity and browser pool stats.", - "responses": { - "200": { - "description": "Service is healthy", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "status": { - "type": "string", - "enum": [ - "ok", - "degraded" - ] - }, - "version": { - "type": "string", - "example": "0.4.0" - }, - "database": { - "type": "object", - "properties": { - "status": { - "type": "string", - "enum": [ - "ok", - "error" - ] - }, - "version": { - "type": "string", - "example": "PostgreSQL 17.4" - } - } - }, - "pool": { - "type": "object", - "properties": { - "size": { - "type": "integer" - }, - "active": { - "type": "integer" - }, - "available": { - "type": "integer" - }, - "queueDepth": { - "type": "integer" - }, - "pdfCount": { - "type": "integer" - }, - "restarting": { - "type": "boolean" - }, - "uptimeSeconds": { - "type": "integer" - } - } - } - } - } - } - } - }, - "503": { - "description": "Service is degraded (database issue)" - } - } - } - }, - "/v1/recover": { - "post": { - "tags": [ - "Account" - ], - "summary": "Request API key recovery", - "description": "Sends a 6-digit verification code to the email address if an account exists.\nResponse is always the same regardless of whether the email exists (to prevent enumeration).\nRate limited to 3 requests per hour.\n", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "email" - ], - "properties": { - "email": { - "type": "string", - "format": "email", - "description": "Email address associated with the API key" - } - } - } - } - } - }, - "responses": { - "200": { - "description": "Recovery code sent (or no-op if email not found)", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "status": { - "type": "string", - "example": "recovery_sent" - }, - "message": { - "type": "string" - } - } - } - } - } - }, - "400": { - "description": "Invalid email format" - }, - "429": { - "description": "Too many recovery attempts" - } - } - } - }, - "/v1/recover/verify": { - "post": { - "tags": [ - "Account" - ], - "summary": "Verify recovery code and retrieve API key", - "description": "Verifies the 6-digit code sent via email and returns the API key if valid. Code expires after 15 minutes.", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "email", - "code" - ], - "properties": { - "email": { - "type": "string", - "format": "email" - }, - "code": { - "type": "string", - "pattern": "^\\d{6}$", - "description": "6-digit verification code" - } - } - } - } - } - }, - "responses": { - "200": { - "description": "API key recovered", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "status": { - "type": "string", - "example": "recovered" - }, - "apiKey": { - "type": "string", - "description": "The recovered API key" - }, - "tier": { - "type": "string", - "enum": [ - "free", - "pro" - ] - } - } - } - } - } - }, - "400": { - "description": "Invalid verification code or missing fields" - }, - "410": { - "description": "Verification code expired" - }, - "429": { - "description": "Too many failed attempts" - } - } - } - }, - "/v1/templates": { - "get": { - "tags": [ - "Templates" - ], - "summary": "List available templates", - "description": "Returns a list of all built-in document templates with their required fields.", - "security": [ - { - "BearerAuth": [] - }, - { - "ApiKeyHeader": [] - } - ], - "responses": { - "200": { - "description": "List of templates", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "templates": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string", - "example": "invoice" - }, - "name": { - "type": "string", - "example": "Invoice" - }, - "description": { - "type": "string" - }, - "fields": { - "type": "array", - "items": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "required": { - "type": "boolean" - }, - "description": { - "type": "string" - } - } - } - } - } - } - } - } - } - } - } - }, - "401": { - "description": "Missing API key" - }, - "403": { - "description": "Invalid API key" - } - } - } - }, - "/v1/templates/{id}/render": { - "post": { - "tags": [ - "Templates" - ], - "summary": "Render a template to PDF", - "description": "Renders a built-in template with the provided data and returns a PDF.\nUse GET /v1/templates to see available templates and their required fields.\nSpecial fields: `_format` (page size), `_margin` (page margins), `_filename` (output filename).\n", - "security": [ - { - "BearerAuth": [] - }, - { - "ApiKeyHeader": [] - } - ], - "parameters": [ - { - "in": "path", - "name": "id", - "required": true, - "schema": { - "type": "string" - }, - "description": "Template ID (e.g. \"invoice\", \"receipt\")" - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "data": { - "type": "object", - "description": "Template data (fields depend on template). Can also be passed at root level." - }, - "_format": { - "type": "string", - "enum": [ - "A4", - "Letter", - "Legal", - "A3", - "A5", - "Tabloid" - ], - "default": "A4", - "description": "Page size override" - }, - "_margin": { - "type": "object", - "properties": { - "top": { - "type": "string" - }, - "right": { - "type": "string" - }, - "bottom": { - "type": "string" - }, - "left": { - "type": "string" - } - }, - "description": "Page margin override" - }, - "_filename": { - "type": "string", - "description": "Custom output filename" - } - } - } - } - } - }, - "responses": { - "200": { - "description": "PDF document", - "content": { - "application/pdf": { - "schema": { - "type": "string", - "format": "binary" - } - } - } - }, - "400": { - "description": "Missing required template fields" - }, - "401": { - "description": "Missing API key" - }, - "403": { - "description": "Invalid API key" - }, - "404": { - "description": "Template not found" - }, - "500": { - "description": "Template rendering failed" - } - } - } - }, - "/v1/signup/free": { - "post": { - "tags": [ - "Account" - ], - "summary": "Free signup (discontinued)", - "description": "Free accounts have been discontinued. Use the demo endpoint for testing\nor subscribe to Pro for production use.\n", - "deprecated": true, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "email": { - "type": "string", - "format": "email" - } - } - } - } - } - }, - "responses": { - "410": { - "description": "Free accounts discontinued", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string", - "example": "Free accounts have been discontinued." - }, - "demo_endpoint": { - "type": "string", - "example": "/v1/demo/html" - }, - "pro_url": { - "type": "string", - "example": "https://docfast.dev/#pricing" - } - } - } - } - } - } - } - } - }, - "/v1/usage": { - "get": { - "tags": [ - "System" - ], - "summary": "Usage statistics (admin only)", - "description": "Returns usage statistics for the authenticated user. Requires admin API key.", - "security": [ - { - "BearerAuth": [] - }, - { - "ApiKeyHeader": [] - } - ], - "responses": { - "200": { - "description": "Usage statistics", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": { - "type": "object", - "properties": { - "count": { - "type": "integer" - }, - "month": { - "type": "string" - } - } - } - } - } - } - }, - "403": { - "description": "Admin access required" - }, - "503": { - "description": "Admin access not configured" - } - } - } - } - } -} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 284c94b..b039c07 100644 --- a/src/index.ts +++ b/src/index.ts @@ -24,6 +24,7 @@ import { initBrowser, closeBrowser } from "./services/browser.js"; import { loadKeys, getAllKeys } from "./services/keys.js"; import { verifyToken, loadVerifications } from "./services/verification.js"; import { initDatabase, pool } from "./services/db.js"; +import { swaggerSpec } from "./swagger.js"; const app = express(); const PORT = parseInt(process.env.PORT || "3100", 10); @@ -98,6 +99,28 @@ app.use(limiter); // Public routes app.use("/health", healthRouter); app.use("/v1/demo", express.json({ limit: "50kb" }), pdfRateLimitMiddleware, demoRouter); +/** + * @openapi + * /v1/signup/free: + * post: + * tags: [Account] + * summary: Request a free API key (discontinued) + * description: Free accounts have been discontinued. Use the demo endpoints or upgrade to Pro. + * responses: + * 410: + * description: Feature discontinued + * content: + * application/json: + * schema: + * type: object + * properties: + * error: + * type: string + * demo_endpoint: + * type: string + * pro_url: + * type: string + */ app.use("/v1/signup", (_req, res) => { res.status(410).json({ error: "Free accounts have been discontinued. Try our demo at POST /v1/demo/html or upgrade to Pro at https://docfast.dev", @@ -196,6 +219,11 @@ app.get("/favicon.ico", (_req, res) => { res.sendFile(path.join(__dirname, "../public/favicon.svg")); }); +// Dynamic OpenAPI spec — generated from @openapi JSDoc annotations at startup +app.get("/openapi.json", (_req, res) => { + res.json(swaggerSpec); +}); + // Docs page (clean URL) app.get("/docs", (_req, res) => { // Swagger UI 5.x uses new Function() (via ajv) for JSON schema validation. diff --git a/src/swagger.ts b/src/swagger.ts new file mode 100644 index 0000000..7a605db --- /dev/null +++ b/src/swagger.ts @@ -0,0 +1,92 @@ +import swaggerJsdoc from "swagger-jsdoc"; +import { createRequire } from "module"; + +const _require = createRequire(import.meta.url); +const { version } = _require("../package.json"); + +const options: swaggerJsdoc.Options = { + definition: { + openapi: "3.0.3", + info: { + title: "DocFast API", + version, + description: + "Convert HTML, Markdown, and URLs to pixel-perfect PDFs. Built-in invoice & receipt templates.\n\n## Authentication\nAll conversion and template endpoints require an API key via `Authorization: Bearer ` or `X-API-Key: ` header.\n\n## Rate Limits\n- Free tier: 100 PDFs/month, 10 req/min\n- Pro tier: 5,000 PDFs/month, 30 req/min\n\n## Getting Started\n1. Try the demo endpoints (no auth required, 5/hour limit)\n2. Upgrade to Pro at [docfast.dev](https://docfast.dev)\n3. Use your API key to convert documents", + contact: { + name: "DocFast", + url: "https://docfast.dev", + email: "support@docfast.dev", + }, + }, + servers: [{ url: "https://docfast.dev", description: "Production" }], + tags: [ + { name: "Demo", description: "Try the API without authentication (watermarked, 5/hour)" }, + { name: "Conversion", description: "Convert HTML, Markdown, or URLs to PDF" }, + { name: "Templates", description: "Built-in document templates" }, + { name: "Account", description: "Signup and key recovery" }, + { name: "Billing", description: "Stripe-powered subscription management" }, + { name: "System", description: "Health checks and usage stats" }, + ], + components: { + securitySchemes: { + BearerAuth: { + type: "http", + scheme: "bearer", + description: "API key as Bearer token", + }, + ApiKeyHeader: { + type: "apiKey", + in: "header", + name: "X-API-Key", + description: "API key via X-API-Key header", + }, + }, + schemas: { + PdfOptions: { + type: "object", + properties: { + format: { + type: "string", + enum: ["A4", "Letter", "Legal", "A3", "A5", "Tabloid"], + default: "A4", + description: "Page size", + }, + landscape: { + type: "boolean", + default: false, + description: "Landscape orientation", + }, + margin: { + type: "object", + properties: { + top: { type: "string", example: "20mm" }, + bottom: { type: "string", example: "20mm" }, + left: { type: "string", example: "15mm" }, + right: { type: "string", example: "15mm" }, + }, + }, + printBackground: { + type: "boolean", + default: true, + description: "Print background graphics", + }, + filename: { + type: "string", + default: "document.pdf", + description: "Suggested filename for the PDF", + }, + }, + }, + Error: { + type: "object", + properties: { + error: { type: "string", description: "Error message" }, + }, + }, + }, + }, + }, + apis: ["./dist/routes/*.js", "./dist/index.js"], +}; + +export const swaggerSpec = swaggerJsdoc(options); From 0295dc1dae830a66e005d5dde14469da7b50b9e9 Mon Sep 17 00:00:00 2001 From: DocFast CEO Date: Fri, 20 Feb 2026 08:07:17 +0000 Subject: [PATCH 003/169] fix(landing): remove Free tier, add playground, update CTAs (BUG-080) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove Free tier pricing card entirely - Remove signup modal (no more free signups) - Add interactive playground section (paste HTML → watermarked PDF) - Hero CTAs: 'Try Demo →' and 'Get Pro API Key — €9/mo' - Pricing: single Pro card at €9/mo - Update structured data to remove Free offer --- templates/pages/index.html | 98 +++++++++++--------------------------- 1 file changed, 29 insertions(+), 69 deletions(-) diff --git a/templates/pages/index.html b/templates/pages/index.html index c614cd3..3fec6da 100644 --- a/templates/pages/index.html +++ b/templates/pages/index.html @@ -16,7 +16,7 @@ \n\n\n
\n
\n
Acme Corp
\n
123 Business Ave, Suite 100
San Francisco, CA 94102
\n
\n
\n
Invoice
\n
#INV-2024-0042
\n
Feb 20, 2026
\n
\n
\n
\n
Bill To
Jane Smith
456 Client Road
New York, NY 10001
\n
Payment Due
March 20, 2026
Payment Method
Bank Transfer
\n
\n \n \n \n \n \n \n \n \n
DescriptionQtyRateAmount
Web Development — Landing Page40 hrs$150$6,000
UI/UX Design — Mockups16 hrs$125$2,000
API Integration & Testing24 hrs$150$3,600
Total$11,600
\n \n\n', + report: '\n\n\n\n\n

Q4 2025 Performance Report

\n
Prepared by Analytics Team — February 2026
\n
\n
142%
Revenue Growth
\n
2.4M
API Calls
\n
99.9%
Uptime
\n
\n

Executive Summary

\n

Q4 saw exceptional growth across all key metrics. Customer acquisition increased by 85% while churn decreased to an all-time low of 1.2%. Our expansion into the EU market contributed significantly to revenue gains.

\n
\n

🎯 Key Achievement

\n

Crossed 10,000 active users milestone in December, two months ahead of target. Enterprise segment grew 200% QoQ.

\n
\n

Product Updates

\n

Launched 3 major features: batch processing, webhook notifications, and custom templates. Template engine adoption reached 40% of Pro users within the first month.

\n

Outlook

\n

Q1 2026 focus areas include Markdown-to-PDF improvements, enhanced template library, and SOC 2 certification to unlock enterprise sales pipeline.

\n\n', + custom: '\n\n\n\n\n

Hello World!

\n

Edit this HTML and watch the preview update in real time.

\n

Then click Generate PDF to download it.

\n\n' +}; + +var previewDebounce = null; +function updatePreview() { + var iframe = document.getElementById('demoPreview'); + var html = document.getElementById('demoHtml').value; + if (!iframe) return; + var doc = iframe.contentDocument || iframe.contentWindow.document; + doc.open(); + doc.write(html); + doc.close(); +} + +function setTemplate(name) { + var ta = document.getElementById('demoHtml'); + ta.value = pgTemplates[name] || pgTemplates.custom; + updatePreview(); + // Update active tab + document.querySelectorAll('.pg-tab').forEach(function(t) { + var isActive = t.getAttribute('data-template') === name; + t.classList.toggle('active', isActive); + t.setAttribute('aria-selected', isActive ? 'true' : 'false'); + }); +} + async function generateDemo() { var btn = document.getElementById('demoGenerateBtn'); var status = document.getElementById('demoStatus'); @@ -183,14 +212,16 @@ async function generateDemo() { if (!html) { errorEl.textContent = 'Please enter some HTML.'; errorEl.style.display = 'block'; - result.style.display = 'none'; + result.classList.remove('visible'); return; } errorEl.style.display = 'none'; - result.style.display = 'none'; + result.classList.remove('visible'); btn.disabled = true; - status.textContent = 'Generating PDF…'; + btn.classList.add('pg-generating'); + status.textContent = 'Generating…'; + var startTime = performance.now(); try { var res = await fetch('/v1/demo/html', { @@ -204,21 +235,25 @@ async function generateDemo() { errorEl.textContent = data.error || 'Something went wrong.'; errorEl.style.display = 'block'; btn.disabled = false; + btn.classList.remove('pg-generating'); status.textContent = ''; return; } + var elapsed = ((performance.now() - startTime) / 1000).toFixed(1); var blob = await res.blob(); var url = URL.createObjectURL(blob); - var dl = document.getElementById('demoDownload'); - dl.href = url; - result.style.display = 'block'; + document.getElementById('demoDownload').href = url; + document.getElementById('demoTime').textContent = elapsed; + result.classList.add('visible'); status.textContent = ''; btn.disabled = false; + btn.classList.remove('pg-generating'); } catch (err) { errorEl.textContent = 'Network error. Please try again.'; errorEl.style.display = 'block'; btn.disabled = false; + btn.classList.remove('pg-generating'); status.textContent = ''; } } @@ -233,6 +268,21 @@ document.addEventListener('DOMContentLoaded', function() { // Demo playground document.getElementById('demoGenerateBtn').addEventListener('click', generateDemo); + // Playground tabs + document.querySelectorAll('.pg-tab').forEach(function(tab) { + tab.addEventListener('click', function() { setTemplate(this.getAttribute('data-template')); }); + }); + // Init with invoice template + setTemplate('invoice'); + // Live preview on input + document.getElementById('demoHtml').addEventListener('input', function() { + clearTimeout(previewDebounce); + previewDebounce = setTimeout(updatePreview, 150); + }); + // Playground checkout button + var pgCheckout = document.getElementById('btn-checkout-playground'); + if (pgCheckout) pgCheckout.addEventListener('click', checkout); + // Checkout buttons document.getElementById('btn-checkout').addEventListener('click', checkout); var heroCheckout = document.getElementById('btn-checkout-hero'); diff --git a/public/app.min.js b/public/app.min.js index 904a38d..c5006e6 100644 --- a/public/app.min.js +++ b/public/app.min.js @@ -1 +1 @@ -var recoverEmail="";function showRecoverState(e){["recoverInitial","recoverLoading","recoverVerify","recoverResult"].forEach(function(e){var t=document.getElementById(e);t&&t.classList.remove("active")}),document.getElementById(e).classList.add("active")}function openRecover(){document.getElementById("recoverModal").classList.add("active"),showRecoverState("recoverInitial");var e=document.getElementById("recoverError");e&&(e.style.display="none");var t=document.getElementById("recoverVerifyError");t&&(t.style.display="none"),document.getElementById("recoverEmailInput").value="",document.getElementById("recoverCode").value="",recoverEmail="",setTimeout(function(){document.getElementById("recoverEmailInput").focus()},100)}function closeRecover(){document.getElementById("recoverModal").classList.remove("active")}async function submitRecover(){var e=document.getElementById("recoverError"),t=document.getElementById("recoverBtn"),n=document.getElementById("recoverEmailInput").value.trim();if(!n||!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(n))return e.textContent="Please enter a valid email address.",void(e.style.display="block");e.style.display="none",t.disabled=!0,showRecoverState("recoverLoading");try{var a=await fetch("/v1/recover",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({email:n})}),o=await a.json();if(!a.ok)return showRecoverState("recoverInitial"),e.textContent=o.error||"Something went wrong.",e.style.display="block",void(t.disabled=!1);recoverEmail=n,document.getElementById("recoverEmailDisplay").textContent=n,showRecoverState("recoverVerify"),document.getElementById("recoverCode").focus(),t.disabled=!1}catch(n){showRecoverState("recoverInitial"),e.textContent="Network error. Please try again.",e.style.display="block",t.disabled=!1}}async function submitRecoverVerify(){var e=document.getElementById("recoverVerifyError"),t=document.getElementById("recoverVerifyBtn"),n=document.getElementById("recoverCode").value.trim();if(!n||!/^\d{6}$/.test(n))return e.textContent="Please enter a 6-digit code.",void(e.style.display="block");e.style.display="none",t.disabled=!0;try{var a=await fetch("/v1/recover/verify",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({email:recoverEmail,code:n})}),o=await a.json();if(!a.ok)return e.textContent=o.error||"Verification failed.",e.style.display="block",void(t.disabled=!1);if(o.apiKey){document.getElementById("recoveredKeyText").textContent=o.apiKey,showRecoverState("recoverResult");var i=document.querySelector("#recoverResult h2");i&&(i.setAttribute("tabindex","-1"),i.focus())}else e.textContent=o.message||"No key found for this email.",e.style.display="block",t.disabled=!1}catch(n){e.textContent="Network error. Please try again.",e.style.display="block",t.disabled=!1}}function copyRecoveredKey(){doCopy(document.getElementById("recoveredKeyText").textContent,document.getElementById("copyRecoveredBtn"))}function doCopy(e,t){function n(){t.textContent="✓ Copied!",setTimeout(function(){t.textContent="Copy"},2e3)}function a(){t.textContent="Failed",setTimeout(function(){t.textContent="Copy"},2e3)}try{navigator.clipboard&&window.isSecureContext?navigator.clipboard.writeText(e).then(n).catch(function(){fallbackCopy(e,n,a)}):fallbackCopy(e,n,a)}catch(e){a()}}function fallbackCopy(e,t,n){try{var a=document.createElement("textarea");a.value=e,a.style.position="fixed",a.style.opacity="0",a.style.top="-9999px",document.body.appendChild(a),a.focus(),a.select();var o=document.execCommand("copy");document.body.removeChild(a),o?t():n()}catch(e){n()}}async function checkout(){try{var e=await fetch("/v1/billing/checkout",{method:"POST"}),t=await e.json();t.url?window.location.href=t.url:alert("Checkout is not available yet. Please try again later.")}catch(e){alert("Something went wrong. Please try again.")}}async function generateDemo(){var e=document.getElementById("demoGenerateBtn"),t=document.getElementById("demoStatus"),n=document.getElementById("demoResult"),a=document.getElementById("demoError"),o=document.getElementById("demoHtml").value.trim();if(!o)return a.textContent="Please enter some HTML.",a.style.display="block",void(n.style.display="none");a.style.display="none",n.style.display="none",e.disabled=!0,t.textContent="Generating PDF…";try{var i=await fetch("/v1/demo/html",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({html:o})});if(!i.ok){var l=await i.json();return a.textContent=l.error||"Something went wrong.",a.style.display="block",e.disabled=!1,void(t.textContent="")}var r=await i.blob(),c=URL.createObjectURL(r);document.getElementById("demoDownload").href=c,n.style.display="block",t.textContent="",e.disabled=!1}catch(n){a.textContent="Network error. Please try again.",a.style.display="block",e.disabled=!1,t.textContent=""}}document.addEventListener("DOMContentLoaded",function(){"#change-email"===window.location.hash&&openEmailChange(),document.getElementById("demoGenerateBtn").addEventListener("click",generateDemo),document.getElementById("btn-checkout").addEventListener("click",checkout);var e=document.getElementById("btn-checkout-hero");e&&e.addEventListener("click",checkout),document.getElementById("btn-close-recover").addEventListener("click",closeRecover),document.getElementById("recoverBtn").addEventListener("click",submitRecover),document.getElementById("recoverVerifyBtn").addEventListener("click",submitRecoverVerify),document.getElementById("copyRecoveredBtn").addEventListener("click",copyRecoveredKey),document.getElementById("recoverModal").addEventListener("click",function(e){e.target===this&&closeRecover()}),document.querySelectorAll(".open-recover").forEach(function(e){e.addEventListener("click",function(e){e.preventDefault(),openRecover()})}),document.querySelectorAll('a[href^="#"]').forEach(function(e){e.addEventListener("click",function(e){var t=this.getAttribute("href");if("#"!==t){e.preventDefault();var n=document.querySelector(t);n&&n.scrollIntoView({behavior:"smooth"})}})})});var emailChangeApiKey="",emailChangeNewEmail="";function showEmailChangeState(e){["emailChangeInitial","emailChangeLoading","emailChangeVerify","emailChangeResult"].forEach(function(e){var t=document.getElementById(e);t&&t.classList.remove("active")}),document.getElementById(e).classList.add("active")}function openEmailChange(){closeRecover(),document.getElementById("emailChangeModal").classList.add("active"),showEmailChangeState("emailChangeInitial");var e=document.getElementById("emailChangeError");e&&(e.style.display="none");var t=document.getElementById("emailChangeVerifyError");t&&(t.style.display="none"),document.getElementById("emailChangeApiKey").value="",document.getElementById("emailChangeNewEmail").value="",document.getElementById("emailChangeCode").value="",emailChangeApiKey="",emailChangeNewEmail=""}function closeEmailChange(){document.getElementById("emailChangeModal").classList.remove("active")}async function submitEmailChange(){var e=document.getElementById("emailChangeError"),t=document.getElementById("emailChangeBtn"),n=document.getElementById("emailChangeApiKey").value.trim(),a=document.getElementById("emailChangeNewEmail").value.trim();if(!n)return e.textContent="Please enter your API key.",void(e.style.display="block");if(!a||!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(a))return e.textContent="Please enter a valid email address.",void(e.style.display="block");e.style.display="none",t.disabled=!0,showEmailChangeState("emailChangeLoading");try{var o=await fetch("/v1/email-change",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({apiKey:n,newEmail:a})}),i=await o.json();if(!o.ok)return showEmailChangeState("emailChangeInitial"),e.textContent=i.error||"Something went wrong.",e.style.display="block",void(t.disabled=!1);emailChangeApiKey=n,emailChangeNewEmail=a,document.getElementById("emailChangeEmailDisplay").textContent=a,showEmailChangeState("emailChangeVerify"),document.getElementById("emailChangeCode").focus(),t.disabled=!1}catch(n){showEmailChangeState("emailChangeInitial"),e.textContent="Network error. Please try again.",e.style.display="block",t.disabled=!1}}async function submitEmailChangeVerify(){var e=document.getElementById("emailChangeVerifyError"),t=document.getElementById("emailChangeVerifyBtn"),n=document.getElementById("emailChangeCode").value.trim();if(!n||!/^\d{6}$/.test(n))return e.textContent="Please enter a 6-digit code.",void(e.style.display="block");e.style.display="none",t.disabled=!0;try{var a=await fetch("/v1/email-change/verify",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({apiKey:emailChangeApiKey,newEmail:emailChangeNewEmail,code:n})}),o=await a.json();if(!a.ok)return e.textContent=o.error||"Verification failed.",e.style.display="block",void(t.disabled=!1);document.getElementById("emailChangeNewDisplay").textContent=o.newEmail||emailChangeNewEmail,showEmailChangeState("emailChangeResult");var i=document.querySelector("#emailChangeResult h2");i&&(i.setAttribute("tabindex","-1"),i.focus())}catch(n){e.textContent="Network error. Please try again.",e.style.display="block",t.disabled=!1}}document.addEventListener("DOMContentLoaded",function(){var e=document.getElementById("btn-close-email-change");e&&e.addEventListener("click",closeEmailChange);var t=document.getElementById("emailChangeBtn");t&&t.addEventListener("click",submitEmailChange);var n=document.getElementById("emailChangeVerifyBtn");n&&n.addEventListener("click",submitEmailChangeVerify);var a=document.getElementById("emailChangeModal");a&&a.addEventListener("click",function(e){e.target===this&&closeEmailChange()}),document.querySelectorAll(".open-email-change").forEach(function(e){e.addEventListener("click",function(e){e.preventDefault(),openEmailChange()})})}),function(){function e(){for(var e=["recoverModal","emailChangeModal"],t=0;t\n\n\n\n\n
\n
\n
Acme Corp
\n
123 Business Ave, Suite 100
San Francisco, CA 94102
\n
\n
\n
Invoice
\n
#INV-2024-0042
\n
Feb 20, 2026
\n
\n
\n
\n
Bill To
Jane Smith
456 Client Road
New York, NY 10001
\n
Payment Due
March 20, 2026
Payment Method
Bank Transfer
\n
\n \n \n \n \n \n \n \n \n
DescriptionQtyRateAmount
Web Development — Landing Page40 hrs$150$6,000
UI/UX Design — Mockups16 hrs$125$2,000
API Integration & Testing24 hrs$150$3,600
Total$11,600
\n \n\n',report:'\n\n\n\n\n

Q4 2025 Performance Report

\n
Prepared by Analytics Team — February 2026
\n
\n
142%
Revenue Growth
\n
2.4M
API Calls
\n
99.9%
Uptime
\n
\n

Executive Summary

\n

Q4 saw exceptional growth across all key metrics. Customer acquisition increased by 85% while churn decreased to an all-time low of 1.2%. Our expansion into the EU market contributed significantly to revenue gains.

\n
\n

🎯 Key Achievement

\n

Crossed 10,000 active users milestone in December, two months ahead of target. Enterprise segment grew 200% QoQ.

\n
\n

Product Updates

\n

Launched 3 major features: batch processing, webhook notifications, and custom templates. Template engine adoption reached 40% of Pro users within the first month.

\n

Outlook

\n

Q1 2026 focus areas include Markdown-to-PDF improvements, enhanced template library, and SOC 2 certification to unlock enterprise sales pipeline.

\n\n',custom:"\n\n\n\n\n

Hello World!

\n

Edit this HTML and watch the preview update in real time.

\n

Then click Generate PDF to download it.

\n\n"},previewDebounce=null;function updatePreview(){var e=document.getElementById("demoPreview"),t=document.getElementById("demoHtml").value;if(e){var n=e.contentDocument||e.contentWindow.document;n.open(),n.write(t),n.close()}}function setTemplate(e){document.getElementById("demoHtml").value=pgTemplates[e]||pgTemplates.custom,updatePreview(),document.querySelectorAll(".pg-tab").forEach(function(t){var n=t.getAttribute("data-template")===e;t.classList.toggle("active",n),t.setAttribute("aria-selected",n?"true":"false")})}async function generateDemo(){var e=document.getElementById("demoGenerateBtn"),t=document.getElementById("demoStatus"),n=document.getElementById("demoResult"),a=document.getElementById("demoError"),o=document.getElementById("demoHtml").value.trim();if(!o)return a.textContent="Please enter some HTML.",a.style.display="block",void n.classList.remove("visible");a.style.display="none",n.classList.remove("visible"),e.disabled=!0,e.classList.add("pg-generating"),t.textContent="Generating…";var i=performance.now();try{var r=await fetch("/v1/demo/html",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({html:o})});if(!r.ok){var l=await r.json();return a.textContent=l.error||"Something went wrong.",a.style.display="block",e.disabled=!1,e.classList.remove("pg-generating"),void(t.textContent="")}var d=((performance.now()-i)/1e3).toFixed(1),c=await r.blob(),s=URL.createObjectURL(c);document.getElementById("demoDownload").href=s,document.getElementById("demoTime").textContent=d,n.classList.add("visible"),t.textContent="",e.disabled=!1,e.classList.remove("pg-generating")}catch(n){a.textContent="Network error. Please try again.",a.style.display="block",e.disabled=!1,e.classList.remove("pg-generating"),t.textContent=""}}document.addEventListener("DOMContentLoaded",function(){"#change-email"===window.location.hash&&openEmailChange(),document.getElementById("demoGenerateBtn").addEventListener("click",generateDemo),document.querySelectorAll(".pg-tab").forEach(function(e){e.addEventListener("click",function(){setTemplate(this.getAttribute("data-template"))})}),setTemplate("invoice"),document.getElementById("demoHtml").addEventListener("input",function(){clearTimeout(previewDebounce),previewDebounce=setTimeout(updatePreview,150)});var e=document.getElementById("btn-checkout-playground");e&&e.addEventListener("click",checkout),document.getElementById("btn-checkout").addEventListener("click",checkout);var t=document.getElementById("btn-checkout-hero");t&&t.addEventListener("click",checkout),document.getElementById("btn-close-recover").addEventListener("click",closeRecover),document.getElementById("recoverBtn").addEventListener("click",submitRecover),document.getElementById("recoverVerifyBtn").addEventListener("click",submitRecoverVerify),document.getElementById("copyRecoveredBtn").addEventListener("click",copyRecoveredKey),document.getElementById("recoverModal").addEventListener("click",function(e){e.target===this&&closeRecover()}),document.querySelectorAll(".open-recover").forEach(function(e){e.addEventListener("click",function(e){e.preventDefault(),openRecover()})}),document.querySelectorAll('a[href^="#"]').forEach(function(e){e.addEventListener("click",function(e){var t=this.getAttribute("href");if("#"!==t){e.preventDefault();var n=document.querySelector(t);n&&n.scrollIntoView({behavior:"smooth"})}})})});var emailChangeApiKey="",emailChangeNewEmail="";function showEmailChangeState(e){["emailChangeInitial","emailChangeLoading","emailChangeVerify","emailChangeResult"].forEach(function(e){var t=document.getElementById(e);t&&t.classList.remove("active")}),document.getElementById(e).classList.add("active")}function openEmailChange(){closeRecover(),document.getElementById("emailChangeModal").classList.add("active"),showEmailChangeState("emailChangeInitial");var e=document.getElementById("emailChangeError");e&&(e.style.display="none");var t=document.getElementById("emailChangeVerifyError");t&&(t.style.display="none"),document.getElementById("emailChangeApiKey").value="",document.getElementById("emailChangeNewEmail").value="",document.getElementById("emailChangeCode").value="",emailChangeApiKey="",emailChangeNewEmail=""}function closeEmailChange(){document.getElementById("emailChangeModal").classList.remove("active")}async function submitEmailChange(){var e=document.getElementById("emailChangeError"),t=document.getElementById("emailChangeBtn"),n=document.getElementById("emailChangeApiKey").value.trim(),a=document.getElementById("emailChangeNewEmail").value.trim();if(!n)return e.textContent="Please enter your API key.",void(e.style.display="block");if(!a||!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(a))return e.textContent="Please enter a valid email address.",void(e.style.display="block");e.style.display="none",t.disabled=!0,showEmailChangeState("emailChangeLoading");try{var o=await fetch("/v1/email-change",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({apiKey:n,newEmail:a})}),i=await o.json();if(!o.ok)return showEmailChangeState("emailChangeInitial"),e.textContent=i.error||"Something went wrong.",e.style.display="block",void(t.disabled=!1);emailChangeApiKey=n,emailChangeNewEmail=a,document.getElementById("emailChangeEmailDisplay").textContent=a,showEmailChangeState("emailChangeVerify"),document.getElementById("emailChangeCode").focus(),t.disabled=!1}catch(n){showEmailChangeState("emailChangeInitial"),e.textContent="Network error. Please try again.",e.style.display="block",t.disabled=!1}}async function submitEmailChangeVerify(){var e=document.getElementById("emailChangeVerifyError"),t=document.getElementById("emailChangeVerifyBtn"),n=document.getElementById("emailChangeCode").value.trim();if(!n||!/^\d{6}$/.test(n))return e.textContent="Please enter a 6-digit code.",void(e.style.display="block");e.style.display="none",t.disabled=!0;try{var a=await fetch("/v1/email-change/verify",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({apiKey:emailChangeApiKey,newEmail:emailChangeNewEmail,code:n})}),o=await a.json();if(!a.ok)return e.textContent=o.error||"Verification failed.",e.style.display="block",void(t.disabled=!1);document.getElementById("emailChangeNewDisplay").textContent=o.newEmail||emailChangeNewEmail,showEmailChangeState("emailChangeResult");var i=document.querySelector("#emailChangeResult h2");i&&(i.setAttribute("tabindex","-1"),i.focus())}catch(n){e.textContent="Network error. Please try again.",e.style.display="block",t.disabled=!1}}document.addEventListener("DOMContentLoaded",function(){var e=document.getElementById("btn-close-email-change");e&&e.addEventListener("click",closeEmailChange);var t=document.getElementById("emailChangeBtn");t&&t.addEventListener("click",submitEmailChange);var n=document.getElementById("emailChangeVerifyBtn");n&&n.addEventListener("click",submitEmailChangeVerify);var a=document.getElementById("emailChangeModal");a&&a.addEventListener("click",function(e){e.target===this&&closeEmailChange()}),document.querySelectorAll(".open-email-change").forEach(function(e){e.addEventListener("click",function(e){e.preventDefault(),openEmailChange()})})}),function(){function e(){for(var e=["recoverModal","emailChangeModal"],t=0;t - + -{{> nav}} + -
-
+
🚀 Simple PDF API for Developers

HTML to PDF
in one API call

@@ -56,7 +362,7 @@
-
+
@@ -86,7 +392,7 @@
🇪🇺
-

Hosted in the EU

+

Hosted in the EU

Your data never leaves the EU • GDPR Compliant • Hetzner Germany (Nuremberg)

@@ -132,20 +438,69 @@
-
+
-

Try it now

-

Paste HTML, get a watermarked PDF. No signup required.

-
- -
- - +

Try it — right now

+

Pick a template or write your own HTML. Generate a real PDF in seconds.

+ + +
+ + + +
+ + +
+
+
+ + HTML +
+
- - + + +
+ + +
+ + + +
+
+
+
+

PDF generated in 0.4s

+ Download PDF → +
+
+
+
+
🆓 Free Demo
+
Watermarked output
+
+
+
+
⚡ Pro
+
Clean, production-ready
+
+
+
+
@@ -167,28 +522,38 @@
  • No watermarks
  • Priority support (support@docfast.dev)
  • - +
    - - -{{> footer}} + - @@ -223,7 +587,43 @@ - + + + + diff --git a/templates/pages/index.html b/templates/pages/index.html index 3fec6da..a8f49d6 100644 --- a/templates/pages/index.html +++ b/templates/pages/index.html @@ -255,6 +255,63 @@ html, body { #emailChangeResult.active { display: block; } #emailChangeVerify.active { display: block; } +/* Playground — redesigned */ +.playground { padding: 80px 0; } +.pg-tabs { display: flex; gap: 8px; justify-content: center; margin-bottom: 24px; flex-wrap: wrap; } +.pg-tab { background: var(--card); border: 1px solid var(--border); color: var(--muted); padding: 10px 20px; border-radius: 8px; font-size: 0.85rem; font-weight: 600; cursor: pointer; transition: all 0.2s; } +.pg-tab:hover { border-color: var(--muted); color: var(--fg); } +.pg-tab.active { background: rgba(52,211,153,0.08); border-color: var(--accent); color: var(--accent); } +.pg-split { display: grid; grid-template-columns: 1fr 1fr; gap: 0; border: 1px solid var(--border); border-radius: var(--radius-lg); overflow: hidden; background: var(--card); min-height: 380px; } +.pg-editor-pane, .pg-preview-pane { display: flex; flex-direction: column; min-height: 0; } +.pg-pane-header { display: flex; align-items: center; gap: 10px; padding: 10px 16px; background: #1a1f2b; border-bottom: 1px solid var(--border); } +.pg-pane-header-preview { justify-content: space-between; } +.pg-pane-dots { display: flex; gap: 5px; } +.pg-pane-dots span { width: 8px; height: 8px; border-radius: 50%; } +.pg-pane-dots span:nth-child(1) { background: #f87171; } +.pg-pane-dots span:nth-child(2) { background: #fbbf24; } +.pg-pane-dots span:nth-child(3) { background: #34d399; } +.pg-pane-label { font-size: 0.75rem; color: var(--muted); font-family: monospace; font-weight: 600; letter-spacing: 0.5px; text-transform: uppercase; } +.pg-preview-badge { font-size: 0.65rem; color: var(--accent); background: rgba(52,211,153,0.08); padding: 3px 8px; border-radius: 4px; font-weight: 500; } +#demoHtml { flex: 1; width: 100%; padding: 16px; border: none; background: transparent; color: var(--fg); font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace; font-size: 0.82rem; line-height: 1.7; resize: none; outline: none; tab-size: 2; } +.pg-preview-pane { border-left: 1px solid var(--border); } +.pg-preview-frame-wrap { flex: 1; background: #fff; position: relative; overflow: hidden; } +#demoPreview { width: 100%; height: 100%; border: none; background: #fff; } +.pg-actions { display: flex; align-items: center; gap: 16px; justify-content: center; margin-top: 24px; flex-wrap: wrap; } +.btn-lg { padding: 16px 36px; font-size: 1.05rem; border-radius: 12px; } +.btn-sm { padding: 10px 20px; font-size: 0.85rem; } +.pg-btn-icon { font-size: 1.1rem; } +.pg-status { color: var(--muted); font-size: 0.9rem; } +.pg-result { display: none; margin-top: 24px; background: var(--card); border: 1px solid var(--accent); border-radius: var(--radius-lg); padding: 28px; animation: pgSlideIn 0.3s ease; } +.pg-result.visible { display: block; } +@keyframes pgSlideIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } } +.pg-result-inner { display: flex; align-items: center; gap: 16px; } +.pg-result-icon { font-size: 2rem; } +.pg-result-title { font-weight: 700; font-size: 1.05rem; margin-bottom: 8px; } +.pg-result-comparison { display: flex; align-items: center; gap: 16px; justify-content: center; margin-top: 24px; padding-top: 24px; border-top: 1px solid var(--border); } +.pg-compare-item { padding: 12px 20px; border-radius: 8px; text-align: center; flex: 1; max-width: 200px; } +.pg-compare-free { background: rgba(248,113,113,0.06); border: 1px solid rgba(248,113,113,0.15); } +.pg-compare-pro { background: rgba(52,211,153,0.06); border: 1px solid rgba(52,211,153,0.2); } +.pg-compare-label { font-weight: 700; font-size: 0.9rem; margin-bottom: 4px; } +.pg-compare-free .pg-compare-label { color: #f87171; } +.pg-compare-pro .pg-compare-label { color: var(--accent); } +.pg-compare-desc { color: var(--muted); font-size: 0.8rem; } +.pg-compare-arrow { color: var(--muted); font-size: 1.2rem; font-weight: 700; } +.pg-result-cta { text-align: center; margin-top: 20px; } +.pg-generating .pg-btn-icon { display: inline-block; animation: spin 0.7s linear infinite; } +@media (max-width: 768px) { + .pg-split { grid-template-columns: 1fr; min-height: auto; } + .pg-preview-pane { border-left: none; border-top: 1px solid var(--border); } + .pg-preview-frame-wrap { height: 250px; } + #demoHtml { min-height: 200px; } + .pg-result-comparison { flex-direction: column; gap: 8px; } + .pg-compare-arrow { transform: rotate(90deg); } + .pg-compare-item { max-width: 100%; } +} +@media (max-width: 375px) { + .pg-tabs { gap: 4px; } + .pg-tab { padding: 8px 12px; font-size: 0.75rem; } +} + /* Focus-visible for accessibility */ .btn:focus-visible, a:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } /* Skip to content */ @@ -381,20 +438,69 @@ html, body {
    -
    +
    -

    Try it now

    -

    Paste HTML, get a watermarked PDF. No signup required.

    -
    - -
    - - +

    Try it — right now

    +

    Pick a template or write your own HTML. Generate a real PDF in seconds.

    + + +
    + + + +
    + + +
    +
    +
    + + HTML +
    +
    - - + + +
    + + +
    + + + +
    +
    +
    +
    +

    PDF generated in 0.4s

    + Download PDF → +
    +
    +
    +
    +
    🆓 Free Demo
    +
    Watermarked output
    +
    +
    +
    +
    ⚡ Pro
    +
    Clean, production-ready
    +
    +
    +
    +
    From ca070520b4418f8e0e3a141932720d3df2afda69 Mon Sep 17 00:00:00 2001 From: DocFast CEO Date: Fri, 20 Feb 2026 09:46:40 +0000 Subject: [PATCH 006/169] Remove rate limiting mention from landing page Rate limiting is a technical constraint, not a feature to advertise. Focus on what customers get: security, zero storage, streaming. --- public/index.html | 2 +- public/src/index.html | 2 +- templates/pages/index.html | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/public/index.html b/public/index.html index ae69e3f..8b8ac67 100644 --- a/public/index.html +++ b/public/index.html @@ -432,7 +432,7 @@ html, body {

    Secure by Default

    -

    HTTPS only. Rate limiting. No data stored. PDFs stream directly — nothing touches disk.

    +

    HTTPS only. No data stored. PDFs stream directly to you — nothing touches disk.

    diff --git a/public/src/index.html b/public/src/index.html index ae69e3f..8b8ac67 100644 --- a/public/src/index.html +++ b/public/src/index.html @@ -432,7 +432,7 @@ html, body {

    Secure by Default

    -

    HTTPS only. Rate limiting. No data stored. PDFs stream directly — nothing touches disk.

    +

    HTTPS only. No data stored. PDFs stream directly to you — nothing touches disk.

    diff --git a/templates/pages/index.html b/templates/pages/index.html index a8f49d6..8e826cc 100644 --- a/templates/pages/index.html +++ b/templates/pages/index.html @@ -432,7 +432,7 @@ html, body {

    Secure by Default

    -

    HTTPS only. Rate limiting. No data stored. PDFs stream directly — nothing touches disk.

    +

    HTTPS only. No data stored. PDFs stream directly to you — nothing touches disk.

    From 432a24dd81b235ec1fc4e4dffccfb8994ba17143 Mon Sep 17 00:00:00 2001 From: DocFast CEO Date: Fri, 20 Feb 2026 09:51:20 +0000 Subject: [PATCH 007/169] fix: download button in playground + de-emphasize rate limits - Fix download button: exclude #demoDownload from smooth scroll handler that was calling preventDefault() on blob: URLs after PDF generation - Replace '5,000 PDFs per month' with 'High-volume PDF generation' in pricing - Update schema.org structured data to remove specific limits --- public/app.js | 3 ++- public/app.min.js | 2 +- public/index.html | 4 ++-- public/src/index.html | 4 ++-- 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/public/app.js b/public/app.js index f613f65..808c0e3 100644 --- a/public/app.js +++ b/public/app.js @@ -302,8 +302,9 @@ document.addEventListener('DOMContentLoaded', function() { el.addEventListener('click', function(e) { e.preventDefault(); openRecover(); }); }); - // Smooth scroll for hash links + // Smooth scroll for hash links (exclude download link) document.querySelectorAll('a[href^="#"]').forEach(function(a) { + if (a.id === 'demoDownload') return; a.addEventListener('click', function(e) { var target = this.getAttribute('href'); if (target === '#') return; diff --git a/public/app.min.js b/public/app.min.js index c5006e6..86e9023 100644 --- a/public/app.min.js +++ b/public/app.min.js @@ -1 +1 @@ -var recoverEmail="";function showRecoverState(e){["recoverInitial","recoverLoading","recoverVerify","recoverResult"].forEach(function(e){var t=document.getElementById(e);t&&t.classList.remove("active")}),document.getElementById(e).classList.add("active")}function openRecover(){document.getElementById("recoverModal").classList.add("active"),showRecoverState("recoverInitial");var e=document.getElementById("recoverError");e&&(e.style.display="none");var t=document.getElementById("recoverVerifyError");t&&(t.style.display="none"),document.getElementById("recoverEmailInput").value="",document.getElementById("recoverCode").value="",recoverEmail="",setTimeout(function(){document.getElementById("recoverEmailInput").focus()},100)}function closeRecover(){document.getElementById("recoverModal").classList.remove("active")}async function submitRecover(){var e=document.getElementById("recoverError"),t=document.getElementById("recoverBtn"),n=document.getElementById("recoverEmailInput").value.trim();if(!n||!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(n))return e.textContent="Please enter a valid email address.",void(e.style.display="block");e.style.display="none",t.disabled=!0,showRecoverState("recoverLoading");try{var a=await fetch("/v1/recover",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({email:n})}),o=await a.json();if(!a.ok)return showRecoverState("recoverInitial"),e.textContent=o.error||"Something went wrong.",e.style.display="block",void(t.disabled=!1);recoverEmail=n,document.getElementById("recoverEmailDisplay").textContent=n,showRecoverState("recoverVerify"),document.getElementById("recoverCode").focus(),t.disabled=!1}catch(n){showRecoverState("recoverInitial"),e.textContent="Network error. Please try again.",e.style.display="block",t.disabled=!1}}async function submitRecoverVerify(){var e=document.getElementById("recoverVerifyError"),t=document.getElementById("recoverVerifyBtn"),n=document.getElementById("recoverCode").value.trim();if(!n||!/^\d{6}$/.test(n))return e.textContent="Please enter a 6-digit code.",void(e.style.display="block");e.style.display="none",t.disabled=!0;try{var a=await fetch("/v1/recover/verify",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({email:recoverEmail,code:n})}),o=await a.json();if(!a.ok)return e.textContent=o.error||"Verification failed.",e.style.display="block",void(t.disabled=!1);if(o.apiKey){document.getElementById("recoveredKeyText").textContent=o.apiKey,showRecoverState("recoverResult");var i=document.querySelector("#recoverResult h2");i&&(i.setAttribute("tabindex","-1"),i.focus())}else e.textContent=o.message||"No key found for this email.",e.style.display="block",t.disabled=!1}catch(n){e.textContent="Network error. Please try again.",e.style.display="block",t.disabled=!1}}function copyRecoveredKey(){doCopy(document.getElementById("recoveredKeyText").textContent,document.getElementById("copyRecoveredBtn"))}function doCopy(e,t){function n(){t.textContent="✓ Copied!",setTimeout(function(){t.textContent="Copy"},2e3)}function a(){t.textContent="Failed",setTimeout(function(){t.textContent="Copy"},2e3)}try{navigator.clipboard&&window.isSecureContext?navigator.clipboard.writeText(e).then(n).catch(function(){fallbackCopy(e,n,a)}):fallbackCopy(e,n,a)}catch(e){a()}}function fallbackCopy(e,t,n){try{var a=document.createElement("textarea");a.value=e,a.style.position="fixed",a.style.opacity="0",a.style.top="-9999px",document.body.appendChild(a),a.focus(),a.select();var o=document.execCommand("copy");document.body.removeChild(a),o?t():n()}catch(e){n()}}async function checkout(){try{var e=await fetch("/v1/billing/checkout",{method:"POST"}),t=await e.json();t.url?window.location.href=t.url:alert("Checkout is not available yet. Please try again later.")}catch(e){alert("Something went wrong. Please try again.")}}var pgTemplates={invoice:'\n\n\n\n\n
    \n
    \n
    Acme Corp
    \n
    123 Business Ave, Suite 100
    San Francisco, CA 94102
    \n
    \n
    \n
    Invoice
    \n
    #INV-2024-0042
    \n
    Feb 20, 2026
    \n
    \n
    \n
    \n
    Bill To
    Jane Smith
    456 Client Road
    New York, NY 10001
    \n
    Payment Due
    March 20, 2026
    Payment Method
    Bank Transfer
    \n
    \n \n \n \n \n \n \n \n \n
    DescriptionQtyRateAmount
    Web Development — Landing Page40 hrs$150$6,000
    UI/UX Design — Mockups16 hrs$125$2,000
    API Integration & Testing24 hrs$150$3,600
    Total$11,600
    \n \n\n',report:'\n\n\n\n\n

    Q4 2025 Performance Report

    \n
    Prepared by Analytics Team — February 2026
    \n
    \n
    142%
    Revenue Growth
    \n
    2.4M
    API Calls
    \n
    99.9%
    Uptime
    \n
    \n

    Executive Summary

    \n

    Q4 saw exceptional growth across all key metrics. Customer acquisition increased by 85% while churn decreased to an all-time low of 1.2%. Our expansion into the EU market contributed significantly to revenue gains.

    \n
    \n

    🎯 Key Achievement

    \n

    Crossed 10,000 active users milestone in December, two months ahead of target. Enterprise segment grew 200% QoQ.

    \n
    \n

    Product Updates

    \n

    Launched 3 major features: batch processing, webhook notifications, and custom templates. Template engine adoption reached 40% of Pro users within the first month.

    \n

    Outlook

    \n

    Q1 2026 focus areas include Markdown-to-PDF improvements, enhanced template library, and SOC 2 certification to unlock enterprise sales pipeline.

    \n\n',custom:"\n\n\n\n\n

    Hello World!

    \n

    Edit this HTML and watch the preview update in real time.

    \n

    Then click Generate PDF to download it.

    \n\n"},previewDebounce=null;function updatePreview(){var e=document.getElementById("demoPreview"),t=document.getElementById("demoHtml").value;if(e){var n=e.contentDocument||e.contentWindow.document;n.open(),n.write(t),n.close()}}function setTemplate(e){document.getElementById("demoHtml").value=pgTemplates[e]||pgTemplates.custom,updatePreview(),document.querySelectorAll(".pg-tab").forEach(function(t){var n=t.getAttribute("data-template")===e;t.classList.toggle("active",n),t.setAttribute("aria-selected",n?"true":"false")})}async function generateDemo(){var e=document.getElementById("demoGenerateBtn"),t=document.getElementById("demoStatus"),n=document.getElementById("demoResult"),a=document.getElementById("demoError"),o=document.getElementById("demoHtml").value.trim();if(!o)return a.textContent="Please enter some HTML.",a.style.display="block",void n.classList.remove("visible");a.style.display="none",n.classList.remove("visible"),e.disabled=!0,e.classList.add("pg-generating"),t.textContent="Generating…";var i=performance.now();try{var r=await fetch("/v1/demo/html",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({html:o})});if(!r.ok){var l=await r.json();return a.textContent=l.error||"Something went wrong.",a.style.display="block",e.disabled=!1,e.classList.remove("pg-generating"),void(t.textContent="")}var d=((performance.now()-i)/1e3).toFixed(1),c=await r.blob(),s=URL.createObjectURL(c);document.getElementById("demoDownload").href=s,document.getElementById("demoTime").textContent=d,n.classList.add("visible"),t.textContent="",e.disabled=!1,e.classList.remove("pg-generating")}catch(n){a.textContent="Network error. Please try again.",a.style.display="block",e.disabled=!1,e.classList.remove("pg-generating"),t.textContent=""}}document.addEventListener("DOMContentLoaded",function(){"#change-email"===window.location.hash&&openEmailChange(),document.getElementById("demoGenerateBtn").addEventListener("click",generateDemo),document.querySelectorAll(".pg-tab").forEach(function(e){e.addEventListener("click",function(){setTemplate(this.getAttribute("data-template"))})}),setTemplate("invoice"),document.getElementById("demoHtml").addEventListener("input",function(){clearTimeout(previewDebounce),previewDebounce=setTimeout(updatePreview,150)});var e=document.getElementById("btn-checkout-playground");e&&e.addEventListener("click",checkout),document.getElementById("btn-checkout").addEventListener("click",checkout);var t=document.getElementById("btn-checkout-hero");t&&t.addEventListener("click",checkout),document.getElementById("btn-close-recover").addEventListener("click",closeRecover),document.getElementById("recoverBtn").addEventListener("click",submitRecover),document.getElementById("recoverVerifyBtn").addEventListener("click",submitRecoverVerify),document.getElementById("copyRecoveredBtn").addEventListener("click",copyRecoveredKey),document.getElementById("recoverModal").addEventListener("click",function(e){e.target===this&&closeRecover()}),document.querySelectorAll(".open-recover").forEach(function(e){e.addEventListener("click",function(e){e.preventDefault(),openRecover()})}),document.querySelectorAll('a[href^="#"]').forEach(function(e){e.addEventListener("click",function(e){var t=this.getAttribute("href");if("#"!==t){e.preventDefault();var n=document.querySelector(t);n&&n.scrollIntoView({behavior:"smooth"})}})})});var emailChangeApiKey="",emailChangeNewEmail="";function showEmailChangeState(e){["emailChangeInitial","emailChangeLoading","emailChangeVerify","emailChangeResult"].forEach(function(e){var t=document.getElementById(e);t&&t.classList.remove("active")}),document.getElementById(e).classList.add("active")}function openEmailChange(){closeRecover(),document.getElementById("emailChangeModal").classList.add("active"),showEmailChangeState("emailChangeInitial");var e=document.getElementById("emailChangeError");e&&(e.style.display="none");var t=document.getElementById("emailChangeVerifyError");t&&(t.style.display="none"),document.getElementById("emailChangeApiKey").value="",document.getElementById("emailChangeNewEmail").value="",document.getElementById("emailChangeCode").value="",emailChangeApiKey="",emailChangeNewEmail=""}function closeEmailChange(){document.getElementById("emailChangeModal").classList.remove("active")}async function submitEmailChange(){var e=document.getElementById("emailChangeError"),t=document.getElementById("emailChangeBtn"),n=document.getElementById("emailChangeApiKey").value.trim(),a=document.getElementById("emailChangeNewEmail").value.trim();if(!n)return e.textContent="Please enter your API key.",void(e.style.display="block");if(!a||!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(a))return e.textContent="Please enter a valid email address.",void(e.style.display="block");e.style.display="none",t.disabled=!0,showEmailChangeState("emailChangeLoading");try{var o=await fetch("/v1/email-change",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({apiKey:n,newEmail:a})}),i=await o.json();if(!o.ok)return showEmailChangeState("emailChangeInitial"),e.textContent=i.error||"Something went wrong.",e.style.display="block",void(t.disabled=!1);emailChangeApiKey=n,emailChangeNewEmail=a,document.getElementById("emailChangeEmailDisplay").textContent=a,showEmailChangeState("emailChangeVerify"),document.getElementById("emailChangeCode").focus(),t.disabled=!1}catch(n){showEmailChangeState("emailChangeInitial"),e.textContent="Network error. Please try again.",e.style.display="block",t.disabled=!1}}async function submitEmailChangeVerify(){var e=document.getElementById("emailChangeVerifyError"),t=document.getElementById("emailChangeVerifyBtn"),n=document.getElementById("emailChangeCode").value.trim();if(!n||!/^\d{6}$/.test(n))return e.textContent="Please enter a 6-digit code.",void(e.style.display="block");e.style.display="none",t.disabled=!0;try{var a=await fetch("/v1/email-change/verify",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({apiKey:emailChangeApiKey,newEmail:emailChangeNewEmail,code:n})}),o=await a.json();if(!a.ok)return e.textContent=o.error||"Verification failed.",e.style.display="block",void(t.disabled=!1);document.getElementById("emailChangeNewDisplay").textContent=o.newEmail||emailChangeNewEmail,showEmailChangeState("emailChangeResult");var i=document.querySelector("#emailChangeResult h2");i&&(i.setAttribute("tabindex","-1"),i.focus())}catch(n){e.textContent="Network error. Please try again.",e.style.display="block",t.disabled=!1}}document.addEventListener("DOMContentLoaded",function(){var e=document.getElementById("btn-close-email-change");e&&e.addEventListener("click",closeEmailChange);var t=document.getElementById("emailChangeBtn");t&&t.addEventListener("click",submitEmailChange);var n=document.getElementById("emailChangeVerifyBtn");n&&n.addEventListener("click",submitEmailChangeVerify);var a=document.getElementById("emailChangeModal");a&&a.addEventListener("click",function(e){e.target===this&&closeEmailChange()}),document.querySelectorAll(".open-email-change").forEach(function(e){e.addEventListener("click",function(e){e.preventDefault(),openEmailChange()})})}),function(){function e(){for(var e=["recoverModal","emailChangeModal"],t=0;t\n\n\n\n\n
    \n
    \n
    Acme Corp
    \n
    123 Business Ave, Suite 100
    San Francisco, CA 94102
    \n
    \n
    \n
    Invoice
    \n
    #INV-2024-0042
    \n
    Feb 20, 2026
    \n
    \n
    \n
    \n
    Bill To
    Jane Smith
    456 Client Road
    New York, NY 10001
    \n
    Payment Due
    March 20, 2026
    Payment Method
    Bank Transfer
    \n
    \n \n \n \n \n \n \n \n \n
    DescriptionQtyRateAmount
    Web Development — Landing Page40 hrs$150$6,000
    UI/UX Design — Mockups16 hrs$125$2,000
    API Integration & Testing24 hrs$150$3,600
    Total$11,600
    \n \n\n',report:'\n\n\n\n\n

    Q4 2025 Performance Report

    \n
    Prepared by Analytics Team — February 2026
    \n
    \n
    142%
    Revenue Growth
    \n
    2.4M
    API Calls
    \n
    99.9%
    Uptime
    \n
    \n

    Executive Summary

    \n

    Q4 saw exceptional growth across all key metrics. Customer acquisition increased by 85% while churn decreased to an all-time low of 1.2%. Our expansion into the EU market contributed significantly to revenue gains.

    \n
    \n

    🎯 Key Achievement

    \n

    Crossed 10,000 active users milestone in December, two months ahead of target. Enterprise segment grew 200% QoQ.

    \n
    \n

    Product Updates

    \n

    Launched 3 major features: batch processing, webhook notifications, and custom templates. Template engine adoption reached 40% of Pro users within the first month.

    \n

    Outlook

    \n

    Q1 2026 focus areas include Markdown-to-PDF improvements, enhanced template library, and SOC 2 certification to unlock enterprise sales pipeline.

    \n\n',custom:"\n\n\n\n\n

    Hello World!

    \n

    Edit this HTML and watch the preview update in real time.

    \n

    Then click Generate PDF to download it.

    \n\n"},previewDebounce=null;function updatePreview(){var e=document.getElementById("demoPreview"),t=document.getElementById("demoHtml").value;if(e){var n=e.contentDocument||e.contentWindow.document;n.open(),n.write(t),n.close()}}function setTemplate(e){document.getElementById("demoHtml").value=pgTemplates[e]||pgTemplates.custom,updatePreview(),document.querySelectorAll(".pg-tab").forEach(function(t){var n=t.getAttribute("data-template")===e;t.classList.toggle("active",n),t.setAttribute("aria-selected",n?"true":"false")})}async function generateDemo(){var e=document.getElementById("demoGenerateBtn"),t=document.getElementById("demoStatus"),n=document.getElementById("demoResult"),a=document.getElementById("demoError"),o=document.getElementById("demoHtml").value.trim();if(!o)return a.textContent="Please enter some HTML.",a.style.display="block",void n.classList.remove("visible");a.style.display="none",n.classList.remove("visible"),e.disabled=!0,e.classList.add("pg-generating"),t.textContent="Generating…";var i=performance.now();try{var r=await fetch("/v1/demo/html",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({html:o})});if(!r.ok){var l=await r.json();return a.textContent=l.error||"Something went wrong.",a.style.display="block",e.disabled=!1,e.classList.remove("pg-generating"),void(t.textContent="")}var d=((performance.now()-i)/1e3).toFixed(1),c=await r.blob(),s=URL.createObjectURL(c);document.getElementById("demoDownload").href=s,document.getElementById("demoTime").textContent=d,n.classList.add("visible"),t.textContent="",e.disabled=!1,e.classList.remove("pg-generating")}catch(n){a.textContent="Network error. Please try again.",a.style.display="block",e.disabled=!1,e.classList.remove("pg-generating"),t.textContent=""}}document.addEventListener("DOMContentLoaded",function(){"#change-email"===window.location.hash&&openEmailChange(),document.getElementById("demoGenerateBtn").addEventListener("click",generateDemo),document.querySelectorAll(".pg-tab").forEach(function(e){e.addEventListener("click",function(){setTemplate(this.getAttribute("data-template"))})}),setTemplate("invoice"),document.getElementById("demoHtml").addEventListener("input",function(){clearTimeout(previewDebounce),previewDebounce=setTimeout(updatePreview,150)});var e=document.getElementById("btn-checkout-playground");e&&e.addEventListener("click",checkout),document.getElementById("btn-checkout").addEventListener("click",checkout);var t=document.getElementById("btn-checkout-hero");t&&t.addEventListener("click",checkout),document.getElementById("btn-close-recover").addEventListener("click",closeRecover),document.getElementById("recoverBtn").addEventListener("click",submitRecover),document.getElementById("recoverVerifyBtn").addEventListener("click",submitRecoverVerify),document.getElementById("copyRecoveredBtn").addEventListener("click",copyRecoveredKey),document.getElementById("recoverModal").addEventListener("click",function(e){e.target===this&&closeRecover()}),document.querySelectorAll(".open-recover").forEach(function(e){e.addEventListener("click",function(e){e.preventDefault(),openRecover()})}),document.querySelectorAll('a[href^="#"]').forEach(function(e){"demoDownload"!==e.id&&e.addEventListener("click",function(e){var t=this.getAttribute("href");if("#"!==t){e.preventDefault();var n=document.querySelector(t);n&&n.scrollIntoView({behavior:"smooth"})}})})});var emailChangeApiKey="",emailChangeNewEmail="";function showEmailChangeState(e){["emailChangeInitial","emailChangeLoading","emailChangeVerify","emailChangeResult"].forEach(function(e){var t=document.getElementById(e);t&&t.classList.remove("active")}),document.getElementById(e).classList.add("active")}function openEmailChange(){closeRecover(),document.getElementById("emailChangeModal").classList.add("active"),showEmailChangeState("emailChangeInitial");var e=document.getElementById("emailChangeError");e&&(e.style.display="none");var t=document.getElementById("emailChangeVerifyError");t&&(t.style.display="none"),document.getElementById("emailChangeApiKey").value="",document.getElementById("emailChangeNewEmail").value="",document.getElementById("emailChangeCode").value="",emailChangeApiKey="",emailChangeNewEmail=""}function closeEmailChange(){document.getElementById("emailChangeModal").classList.remove("active")}async function submitEmailChange(){var e=document.getElementById("emailChangeError"),t=document.getElementById("emailChangeBtn"),n=document.getElementById("emailChangeApiKey").value.trim(),a=document.getElementById("emailChangeNewEmail").value.trim();if(!n)return e.textContent="Please enter your API key.",void(e.style.display="block");if(!a||!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(a))return e.textContent="Please enter a valid email address.",void(e.style.display="block");e.style.display="none",t.disabled=!0,showEmailChangeState("emailChangeLoading");try{var o=await fetch("/v1/email-change",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({apiKey:n,newEmail:a})}),i=await o.json();if(!o.ok)return showEmailChangeState("emailChangeInitial"),e.textContent=i.error||"Something went wrong.",e.style.display="block",void(t.disabled=!1);emailChangeApiKey=n,emailChangeNewEmail=a,document.getElementById("emailChangeEmailDisplay").textContent=a,showEmailChangeState("emailChangeVerify"),document.getElementById("emailChangeCode").focus(),t.disabled=!1}catch(n){showEmailChangeState("emailChangeInitial"),e.textContent="Network error. Please try again.",e.style.display="block",t.disabled=!1}}async function submitEmailChangeVerify(){var e=document.getElementById("emailChangeVerifyError"),t=document.getElementById("emailChangeVerifyBtn"),n=document.getElementById("emailChangeCode").value.trim();if(!n||!/^\d{6}$/.test(n))return e.textContent="Please enter a 6-digit code.",void(e.style.display="block");e.style.display="none",t.disabled=!0;try{var a=await fetch("/v1/email-change/verify",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({apiKey:emailChangeApiKey,newEmail:emailChangeNewEmail,code:n})}),o=await a.json();if(!a.ok)return e.textContent=o.error||"Verification failed.",e.style.display="block",void(t.disabled=!1);document.getElementById("emailChangeNewDisplay").textContent=o.newEmail||emailChangeNewEmail,showEmailChangeState("emailChangeResult");var i=document.querySelector("#emailChangeResult h2");i&&(i.setAttribute("tabindex","-1"),i.focus())}catch(n){e.textContent="Network error. Please try again.",e.style.display="block",t.disabled=!1}}document.addEventListener("DOMContentLoaded",function(){var e=document.getElementById("btn-close-email-change");e&&e.addEventListener("click",closeEmailChange);var t=document.getElementById("emailChangeBtn");t&&t.addEventListener("click",submitEmailChange);var n=document.getElementById("emailChangeVerifyBtn");n&&n.addEventListener("click",submitEmailChangeVerify);var a=document.getElementById("emailChangeModal");a&&a.addEventListener("click",function(e){e.target===this&&closeEmailChange()}),document.querySelectorAll(".open-email-change").forEach(function(e){e.addEventListener("click",function(e){e.preventDefault(),openEmailChange()})})}),function(){function e(){for(var e=["recoverModal","emailChangeModal"],t=0;t +
    ${Array(80).fill('
    DEMO — docfast.dev
    ').join("")}
    +
    Generated by DocFast — docfast.dev | Upgrade to Pro for clean PDFs
    `; function injectWatermark(html: string): string { if (html.includes("")) { From c7ee2a8d74259cf97a31a5a3bf0ba5c298bd4cfc Mon Sep 17 00:00:00 2001 From: OpenClawd Date: Fri, 20 Feb 2026 09:59:59 +0000 Subject: [PATCH 010/169] ci: retrigger build From 8777b1fc3dbb118637ab598536163a8b50193495 Mon Sep 17 00:00:00 2001 From: DocFast Bot Date: Fri, 20 Feb 2026 10:01:43 +0000 Subject: [PATCH 011/169] chore: bump version to 0.4.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 9395b77..85b2dcc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "docfast-api", - "version": "0.4.0", + "version": "0.4.1", "description": "Markdown/HTML to PDF API with built-in invoice templates", "main": "dist/index.js", "scripts": { From 6b0d9d8f4062bd14f48b0b6b0668db091b5cc14b Mon Sep 17 00:00:00 2001 From: DocFast Bot Date: Fri, 20 Feb 2026 10:02:35 +0000 Subject: [PATCH 012/169] fix: use SVG background-repeat for reliable diagonal watermark tiling HTML div tiles were too faint. SVG background pattern renders reliably in Chromium print mode with consistent coverage. --- src/routes/demo.ts | 21 +++------------------ 1 file changed, 3 insertions(+), 18 deletions(-) diff --git a/src/routes/demo.ts b/src/routes/demo.ts index 78e968c..fb8a094 100644 --- a/src/routes/demo.ts +++ b/src/routes/demo.ts @@ -6,25 +6,10 @@ import logger from "../services/logger.js"; const router = Router(); +const WATERMARK_SVG = `DEMO — docfast.dev`; +const WATERMARK_BG = `data:image/svg+xml,${encodeURIComponent(WATERMARK_SVG)}`; const WATERMARK_HTML = ` - -
    ${Array(80).fill('
    DEMO — docfast.dev
    ').join("")}
    +
    Generated by DocFast — docfast.dev | Upgrade to Pro for clean PDFs
    `; function injectWatermark(html: string): string { From 1d97f5e2aa3fda4ce903d5bf92c0081faab14cc1 Mon Sep 17 00:00:00 2001 From: DocFast Bot Date: Fri, 20 Feb 2026 10:04:45 +0000 Subject: [PATCH 013/169] Add /examples page with code examples for common use cases --- public/examples.html | 359 ++++++++++++++++++++++++++++++++++++++ public/impressum.html | 18 +- public/partials/_nav.html | 1 + public/privacy.html | 18 +- public/sitemap.xml | 1 + public/src/examples.html | 296 +++++++++++++++++++++++++++++++ public/status.html | 1 + public/terms.html | 20 ++- 8 files changed, 692 insertions(+), 22 deletions(-) create mode 100644 public/examples.html create mode 100644 public/src/examples.html diff --git a/public/examples.html b/public/examples.html new file mode 100644 index 0000000..bbf5fd2 --- /dev/null +++ b/public/examples.html @@ -0,0 +1,359 @@ + + + + + +Code Examples — DocFast HTML to PDF API + + + + + + + + + + + + + + + + + + +
    +
    + +
    +

    Code Examples

    +

    Practical examples for generating PDFs with the DocFast API — invoices, reports, receipts, and integration guides.

    +
    + + + + +
    +

    Generate an Invoice PDF

    +

    Create a professional invoice with inline CSS and convert it to PDF with a single API call.

    + +
    + HTML — invoice.html +
    <html>
    +<body style="font-family: sans-serif; padding: 40px; color: #333;">
    +  <div style="display: flex; justify-content: space-between;">
    +    <div>
    +      <h1 style="margin: 0; color: #111;">INVOICE</h1>
    +      <p style="color: #666;">#INV-2026-0042</p>
    +    </div>
    +    <div style="text-align: right;">
    +      <strong>Acme Corp</strong><br>
    +      123 Main St<br>
    +      hello@acme.com
    +    </div>
    +  </div>
    +
    +  <table style="width: 100%; border-collapse: collapse; margin-top: 40px;">
    +    <tr style="border-bottom: 2px solid #111;">
    +      <th style="text-align: left; padding: 8px 0;">Item</th>
    +      <th style="text-align: right; padding: 8px 0;">Qty</th>
    +      <th style="text-align: right; padding: 8px 0;">Price</th>
    +    </tr>
    +    <tr style="border-bottom: 1px solid #eee;">
    +      <td style="padding: 12px 0;">API Pro Plan (monthly)</td>
    +      <td style="text-align: right;">1</td>
    +      <td style="text-align: right;">$49.00</td>
    +    </tr>
    +    <tr>
    +      <td style="padding: 12px 0;">Extra PDF renders (500)</td>
    +      <td style="text-align: right;">500</td>
    +      <td style="text-align: right;">$15.00</td>
    +    </tr>
    +  </table>
    +
    +  <p style="text-align: right; font-size: 1.4em; margin-top: 24px;">
    +    <strong>Total: $64.00</strong>
    +  </p>
    +</body>
    +</html>
    +
    + +
    + curl +
    curl -X POST https://api.docfast.dev/v1/convert/html \
    +  -H "Authorization: Bearer YOUR_API_KEY" \
    +  -H "Content-Type: application/json" \
    +  -d '{"html": "<html>...your invoice HTML...</html>"}' \
    +  --output invoice.pdf
    +
    +
    + + +
    +

    Convert Markdown to PDF

    +

    Send Markdown content directly — DocFast renders it with clean typography and outputs a styled PDF.

    + +
    + curl +
    curl -X POST https://api.docfast.dev/v1/convert/markdown \
    +  -H "Authorization: Bearer YOUR_API_KEY" \
    +  -H "Content-Type: application/json" \
    +  -d '{
    +    "markdown": "# Project Report\n\n## Summary\n\nQ4 revenue grew **32%** year-over-year.\n\n## Key Metrics\n\n| Metric | Value |\n|--------|-------|\n| Revenue | $1.2M |\n| Users | 45,000 |\n| Uptime | 99.97% |\n\n## Next Steps\n\n1. Launch mobile SDK\n2. Expand EU infrastructure\n3. SOC 2 certification"
    +  }' \
    +  --output report.pdf
    +
    +
    + + +
    +

    HTML Report with Charts

    +

    Embed inline SVG charts in your HTML for data-driven reports — no JavaScript or external libraries needed.

    + +
    + HTML — report with SVG bar chart +
    <html>
    +<body style="font-family: sans-serif; padding: 40px;">
    +  <h1>Quarterly Revenue</h1>
    +
    +  <svg width="400" height="200" viewBox="0 0 400 200">
    +    <!-- Bars -->
    +    <rect x="20"  y="120" width="60" height="80"  fill="#34d399"/>
    +    <rect x="110" y="80"  width="60" height="120" fill="#34d399"/>
    +    <rect x="200" y="50"  width="60" height="150" fill="#34d399"/>
    +    <rect x="290" y="20"  width="60" height="180" fill="#34d399"/>
    +    <!-- Labels -->
    +    <text x="50"  y="115" text-anchor="middle" font-size="12">$80k</text>
    +    <text x="140" y="75"  text-anchor="middle" font-size="12">$120k</text>
    +    <text x="230" y="45"  text-anchor="middle" font-size="12">$150k</text>
    +    <text x="320" y="15"  text-anchor="middle" font-size="12">$180k</text>
    +  </svg>
    +</body>
    +</html>
    +
    + +
    + curl +
    curl -X POST https://api.docfast.dev/v1/convert/html \
    +  -H "Authorization: Bearer YOUR_API_KEY" \
    +  -H "Content-Type: application/json" \
    +  -d @report.json \
    +  --output chart-report.pdf
    +
    +
    + + +
    +

    Receipt / Confirmation PDF

    +

    Generate a simple receipt or order confirmation — perfect for e-commerce and SaaS billing.

    + +
    + HTML — receipt template +
    <html>
    +<body style="font-family: sans-serif; max-width: 400px; margin: 0 auto; padding: 40px;">
    +  <div style="text-align: center; margin-bottom: 24px;">
    +    <h2 style="margin: 0;">Payment Receipt</h2>
    +    <p style="color: #888;">Feb 20, 2026</p>
    +  </div>
    +
    +  <hr style="border: none; border-top: 1px dashed #ccc;">
    +
    +  <p><strong>Order:</strong> #ORD-98712</p>
    +  <p><strong>Customer:</strong> jane@example.com</p>
    +
    +  <table style="width: 100%; margin: 16px 0;">
    +    <tr>
    +      <td>Pro Plan</td>
    +      <td style="text-align: right;">$29.00</td>
    +    </tr>
    +    <tr>
    +      <td>Tax</td>
    +      <td style="text-align: right;">$2.90</td>
    +    </tr>
    +  </table>
    +
    +  <hr style="border: none; border-top: 1px dashed #ccc;">
    +
    +  <p style="text-align: right; font-size: 1.3em;">
    +    <strong>Total: $31.90</strong>
    +  </p>
    +  <p style="text-align: center; color: #34d399; margin-top: 24px;">
    +    ✓ Payment successful
    +  </p>
    +</body>
    +</html>
    +
    +
    + + +
    +

    Node.js Integration

    +

    A complete Node.js script to generate a PDF and save it to disk. Works with Node 18+ using native fetch.

    + +
    + JavaScript — generate-pdf.mjs +
    const html = `
    +  <h1>Hello from Node.js</h1>
    +  <p>Generated at ${new Date().toISOString()}</p>
    +`;
    +
    +const res = await fetch("https://api.docfast.dev/v1/convert/html", {
    +  method: "POST",
    +  headers: {
    +    "Authorization": `Bearer ${process.env.DOCFAST_API_KEY}`,
    +    "Content-Type": "application/json",
    +  },
    +  body: JSON.stringify({ html }),
    +});
    +
    +if (!res.ok) throw new Error(`API error: ${res.status}`);
    +
    +const buffer = Buffer.from(await res.arrayBuffer());
    +await import("fs").then(fs =>
    +  fs.writeFileSync("output.pdf", buffer)
    +);
    +
    +console.log("✓ Saved output.pdf");
    +
    +
    + + +
    +

    Python Integration

    +

    Generate a PDF from Python using the requests library. Drop this into any Flask, Django, or FastAPI app.

    + +
    + Python — generate_pdf.py +
    import os
    +import requests
    +
    +html = """
    +<h1>Hello from Python</h1>
    +<p>This PDF was generated via the DocFast API.</p>
    +<ul>
    +  <li>Fast rendering</li>
    +  <li>Pixel-perfect output</li>
    +  <li>Simple REST API</li>
    +</ul>
    +"""
    +
    +response = requests.post(
    +    "https://api.docfast.dev/v1/convert/html",
    +    headers={
    +        "Authorization": f"Bearer {os.environ['DOCFAST_API_KEY']}",
    +        "Content-Type": "application/json",
    +    },
    +    json={"html": html},
    +)
    +
    +response.raise_for_status()
    +
    +with open("output.pdf", "wb") as f:
    +    f.write(response.content)
    +
    +print("✓ Saved output.pdf")
    +
    +
    + +
    +
    + + + + + diff --git a/public/impressum.html b/public/impressum.html index 5907197..191cf16 100644 --- a/public/impressum.html +++ b/public/impressum.html @@ -8,6 +8,9 @@ + + + @@ -23,7 +26,7 @@ body { font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Robo a { color: var(--accent); text-decoration: none; transition: color 0.2s; } a:hover { color: var(--accent-hover); } .container { max-width: 800px; margin: 0 auto; padding: 0 24px; } -nav { padding: 20px 0; border-bottom: 1px solid var(--border); } +nav { padding: 20px 0; border-bottom: 1px solid var(--border); position: sticky; top: 0; background: var(--bg); z-index: 100; } nav .container { display: flex; align-items: center; justify-content: space-between; } .logo { font-size: 1.25rem; font-weight: 700; letter-spacing: -0.5px; color: var(--fg); display: flex; align-items: center; gap: 8px; text-decoration: none; } .logo span { color: var(--accent); } @@ -47,14 +50,15 @@ footer .container { display: flex; justify-content: space-between; align-items: footer .container { flex-direction: column; text-align: center; } .nav-links { gap: 16px; } } -/* Skip to content */ -.skip-link { position: absolute; top: -100%; left: 16px; background: var(--accent); color: #0b0d11; padding: 8px 16px; border-radius: 0 0 8px 8px; font-weight: 600; font-size: 0.9rem; z-index: 200; transition: top 0.2s; } + +.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; } +.skip-link { position: absolute; top: -100%; left: 16px; background: var(--accent); color: #0b0d11; padding: 8px 16px; border-radius: 0 0 8px 8px; font-weight: 600; font-size: 0.9rem; z-index: 200; transition: top 0.2s; text-decoration: none; } .skip-link:focus { top: 0; } - + -
    +

    Impressum

    Legal notice according to § 5 ECG and § 25 MedienG (Austrian law)

    @@ -103,8 +108,7 @@ footer .container { display: flex; justify-content: space-between; align-items:
    diff --git a/public/privacy.html b/public/privacy.html index a0d2055..43ae51b 100644 --- a/public/privacy.html +++ b/public/privacy.html @@ -8,6 +8,9 @@ + + + @@ -23,7 +26,7 @@ body { font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Robo a { color: var(--accent); text-decoration: none; transition: color 0.2s; } a:hover { color: var(--accent-hover); } .container { max-width: 800px; margin: 0 auto; padding: 0 24px; } -nav { padding: 20px 0; border-bottom: 1px solid var(--border); } +nav { padding: 20px 0; border-bottom: 1px solid var(--border); position: sticky; top: 0; background: var(--bg); z-index: 100; } nav .container { display: flex; align-items: center; justify-content: space-between; } .logo { font-size: 1.25rem; font-weight: 700; letter-spacing: -0.5px; color: var(--fg); display: flex; align-items: center; gap: 8px; text-decoration: none; } .logo span { color: var(--accent); } @@ -47,14 +50,15 @@ footer .container { display: flex; justify-content: space-between; align-items: footer .container { flex-direction: column; text-align: center; } .nav-links { gap: 16px; } } -/* Skip to content */ -.skip-link { position: absolute; top: -100%; left: 16px; background: var(--accent); color: #0b0d11; padding: 8px 16px; border-radius: 0 0 8px 8px; font-weight: 600; font-size: 0.9rem; z-index: 200; transition: top 0.2s; } + +.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; } +.skip-link { position: absolute; top: -100%; left: 16px; background: var(--accent); color: #0b0d11; padding: 8px 16px; border-radius: 0 0 8px 8px; font-weight: 600; font-size: 0.9rem; z-index: 200; transition: top 0.2s; text-decoration: none; } .skip-link:focus { top: 0; } - + -
    +

    Privacy Policy

    Last updated: February 16, 2026

    @@ -185,8 +190,7 @@ footer .container { display: flex; justify-content: space-between; align-items:
    diff --git a/public/terms.html b/public/terms.html index b950ea7..33a777c 100644 --- a/public/terms.html +++ b/public/terms.html @@ -8,6 +8,9 @@ + + + @@ -23,7 +26,7 @@ body { font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Robo a { color: var(--accent); text-decoration: none; transition: color 0.2s; } a:hover { color: var(--accent-hover); } .container { max-width: 800px; margin: 0 auto; padding: 0 24px; } -nav { padding: 20px 0; border-bottom: 1px solid var(--border); } +nav { padding: 20px 0; border-bottom: 1px solid var(--border); position: sticky; top: 0; background: var(--bg); z-index: 100; } nav .container { display: flex; align-items: center; justify-content: space-between; } .logo { font-size: 1.25rem; font-weight: 700; letter-spacing: -0.5px; color: var(--fg); display: flex; align-items: center; gap: 8px; text-decoration: none; } .logo span { color: var(--accent); } @@ -47,14 +50,15 @@ footer .container { display: flex; justify-content: space-between; align-items: footer .container { flex-direction: column; text-align: center; } .nav-links { gap: 16px; } } -/* Skip to content */ -.skip-link { position: absolute; top: -100%; left: 16px; background: var(--accent); color: #0b0d11; padding: 8px 16px; border-radius: 0 0 8px 8px; font-weight: 600; font-size: 0.9rem; z-index: 200; transition: top 0.2s; } + +.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; } +.skip-link { position: absolute; top: -100%; left: 16px; background: var(--accent); color: #0b0d11; padding: 8px 16px; border-radius: 0 0 8px 8px; font-weight: 600; font-size: 0.9rem; z-index: 200; transition: top 0.2s; text-decoration: none; } .skip-link:focus { top: 0; } - + -
    +

    Terms of Service

    Last updated: February 16, 2026

    @@ -145,7 +150,7 @@ footer .container { display: flex; justify-content: space-between; align-items:

    5.2 Performance

    @@ -257,8 +262,7 @@ footer .container { display: flex; justify-content: space-between; align-items:

    Python Integration

    -

    Generate a PDF from Python using the requests library. Drop this into any Flask, Django, or FastAPI app.

    +

    Install the official SDK: pip install docfast

    - Python — generate_pdf.py -
    import os
    -import requests
    +        Python — Using the SDK (recommended)
    +        
    from docfast import DocFast
     
    -html = """
    -<h1>Hello from Python</h1>
    -<p>This PDF was generated via the DocFast API.</p>
    -<ul>
    -  <li>Fast rendering</li>
    -  <li>Pixel-perfect output</li>
    -  <li>Simple REST API</li>
    -</ul>
    -"""
    +client = DocFast("df_pro_your_api_key")
    +
    +# HTML to PDF
    +pdf = client.html("<h1>Hello World</h1>")
    +with open("output.pdf", "wb") as f:
    +    f.write(pdf)
    +
    +# With options
    +pdf = client.html(html, format="A4", landscape=True)
    +
    +# Async support
    +from docfast import AsyncDocFast
    +
    +async with AsyncDocFast("df_pro_your_api_key") as client:
    +    pdf = await client.html("<h1>Hello</h1>")
    +
    + +
    + Python — Using requests (no SDK) +
    import requests
     
     response = requests.post(
    -    "https://api.docfast.dev/v1/convert/html",
    -    headers={
    -        "Authorization": f"Bearer {os.environ['DOCFAST_API_KEY']}",
    -        "Content-Type": "application/json",
    -    },
    -    json={"html": html},
    +    "https://docfast.dev/v1/convert/html",
    +    headers={"Authorization": f"Bearer {api_key}"},
    +    json={"html": "<h1>Hello</h1>"},
     )
    -
    -response.raise_for_status()
    -
    -with open("output.pdf", "wb") as f:
    -    f.write(response.content)
    -
    -print("✓ Saved output.pdf")
    +pdf = response.content
    diff --git a/public/index.html b/public/index.html index a160313..6628c13 100644 --- a/public/index.html +++ b/public/index.html @@ -402,7 +402,7 @@ html, body {

    Everything you need

    -

    A complete PDF generation API. No SDKs, no dependencies, no setup.

    +

    A complete PDF generation API. Official SDKs for Node.js & Python, or just use curl.

    From 7ab371a40b8ddb3b562204cd0ce6fe1a97272797 Mon Sep 17 00:00:00 2001 From: OpenClaw Agent Date: Sat, 21 Feb 2026 07:02:20 +0000 Subject: [PATCH 024/169] Update landing page copy: replace 'No SDKs' with SDK availability messaging --- public/src/index.html | 2 +- templates/pages/index.html | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/public/src/index.html b/public/src/index.html index a160313..d93794c 100644 --- a/public/src/index.html +++ b/public/src/index.html @@ -402,7 +402,7 @@ html, body {

    Everything you need

    -

    A complete PDF generation API. No SDKs, no dependencies, no setup.

    +

    Official SDKs for Node.js and Python. Or just use curl.

    diff --git a/templates/pages/index.html b/templates/pages/index.html index 5003e78..b9ee550 100644 --- a/templates/pages/index.html +++ b/templates/pages/index.html @@ -402,7 +402,7 @@ html, body {

    Everything you need

    -

    A complete PDF generation API. No SDKs, no dependencies, no setup.

    +

    Official SDKs for Node.js and Python. Or just use curl.

    From a5f3683e3074e93332c4606fdbff7a20d80eabe0 Mon Sep 17 00:00:00 2001 From: OpenClaw Agent Date: Sat, 21 Feb 2026 07:03:27 +0000 Subject: [PATCH 025/169] Build pages with updated SDK messaging --- public/impressum.html | 18 +++++++----------- public/index.html | 2 +- public/privacy.html | 18 +++++++----------- public/terms.html | 20 ++++++++------------ 4 files changed, 23 insertions(+), 35 deletions(-) diff --git a/public/impressum.html b/public/impressum.html index 191cf16..5907197 100644 --- a/public/impressum.html +++ b/public/impressum.html @@ -8,9 +8,6 @@ - - - @@ -26,7 +23,7 @@ body { font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Robo a { color: var(--accent); text-decoration: none; transition: color 0.2s; } a:hover { color: var(--accent-hover); } .container { max-width: 800px; margin: 0 auto; padding: 0 24px; } -nav { padding: 20px 0; border-bottom: 1px solid var(--border); position: sticky; top: 0; background: var(--bg); z-index: 100; } +nav { padding: 20px 0; border-bottom: 1px solid var(--border); } nav .container { display: flex; align-items: center; justify-content: space-between; } .logo { font-size: 1.25rem; font-weight: 700; letter-spacing: -0.5px; color: var(--fg); display: flex; align-items: center; gap: 8px; text-decoration: none; } .logo span { color: var(--accent); } @@ -50,15 +47,14 @@ footer .container { display: flex; justify-content: space-between; align-items: footer .container { flex-direction: column; text-align: center; } .nav-links { gap: 16px; } } - -.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; } -.skip-link { position: absolute; top: -100%; left: 16px; background: var(--accent); color: #0b0d11; padding: 8px 16px; border-radius: 0 0 8px 8px; font-weight: 600; font-size: 0.9rem; z-index: 200; transition: top 0.2s; text-decoration: none; } +/* Skip to content */ +.skip-link { position: absolute; top: -100%; left: 16px; background: var(--accent); color: #0b0d11; padding: 8px 16px; border-radius: 0 0 8px 8px; font-weight: 600; font-size: 0.9rem; z-index: 200; transition: top 0.2s; } .skip-link:focus { top: 0; } + -
    -
    +

    Impressum

    Legal notice according to § 5 ECG and § 25 MedienG (Austrian law)

    @@ -108,7 +103,8 @@ footer .container { display: flex; justify-content: space-between; align-items:
    From f17b483682a24e2ec42641f6e159b6db99b14ec2 Mon Sep 17 00:00:00 2001 From: OpenClaw Date: Sun, 22 Feb 2026 16:02:32 +0000 Subject: [PATCH 038/169] fix: correct Pro plan description from 'unlimited' to '5,000/month' --- public/index.html | 4 ++-- public/src/index.html | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/public/index.html b/public/index.html index fcb2227..95c75d9 100644 --- a/public/index.html +++ b/public/index.html @@ -16,7 +16,7 @@ + diff --git a/public/openapi.json b/public/openapi.json index 2192f5d..9e26dfe 100644 --- a/public/openapi.json +++ b/public/openapi.json @@ -1,1052 +1 @@ -{ - "openapi": "3.0.3", - "info": { - "title": "DocFast API", - "version": "1.0.0", - "description": "Convert HTML, Markdown, and URLs to pixel-perfect PDFs. Built-in invoice & receipt templates.\n\n## Authentication\nAll conversion and template endpoints require an API key via `Authorization: Bearer ` or `X-API-Key: ` header.\n\n## Demo Endpoints\nTry the API without signing up! Demo endpoints are public (no API key needed) but rate-limited to 5 requests/hour per IP and produce watermarked PDFs.\n\n## Rate Limits\n- Demo: 5 PDFs/hour per IP (watermarked)\n- Pro tier: 5,000 PDFs/month, 30 req/min\n\n## Getting Started\n1. Try the demo at `POST /v1/demo/html` — no signup needed\n2. Subscribe to Pro at [docfast.dev](https://docfast.dev/#pricing) for clean PDFs\n3. Use your API key to convert documents", - "contact": { - "name": "DocFast", - "url": "https://docfast.dev", - "email": "support@docfast.dev" - } - }, - "servers": [ - { - "url": "https://docfast.dev", - "description": "Production" - } - ], - "tags": [ - { - "name": "Demo", - "description": "Try the API without signing up — watermarked PDFs, rate-limited" - }, - { - "name": "Conversion", - "description": "Convert HTML, Markdown, or URLs to PDF (requires API key)" - }, - { - "name": "Templates", - "description": "Built-in document templates" - }, - { - "name": "Account", - "description": "Key recovery and email management" - }, - { - "name": "Billing", - "description": "Stripe-powered subscription management" - }, - { - "name": "System", - "description": "Health checks and usage stats" - } - ], - "components": { - "securitySchemes": { - "BearerAuth": { - "type": "http", - "scheme": "bearer", - "description": "API key as Bearer token" - }, - "ApiKeyHeader": { - "type": "apiKey", - "in": "header", - "name": "X-API-Key", - "description": "API key via X-API-Key header" - } - }, - "schemas": { - "PdfOptions": { - "type": "object", - "properties": { - "format": { - "type": "string", - "enum": [ - "A4", - "Letter", - "Legal", - "A3", - "A5", - "Tabloid" - ], - "default": "A4", - "description": "Page size" - }, - "landscape": { - "type": "boolean", - "default": false, - "description": "Landscape orientation" - }, - "margin": { - "type": "object", - "properties": { - "top": { - "type": "string", - "description": "Top margin (e.g. \"10mm\", \"1in\")", - "default": "0" - }, - "right": { - "type": "string", - "description": "Right margin", - "default": "0" - }, - "bottom": { - "type": "string", - "description": "Bottom margin", - "default": "0" - }, - "left": { - "type": "string", - "description": "Left margin", - "default": "0" - } - }, - "description": "Page margins" - }, - "printBackground": { - "type": "boolean", - "default": true, - "description": "Print background colors and images" - }, - "filename": { - "type": "string", - "description": "Custom filename for Content-Disposition header", - "default": "document.pdf" - } - } - }, - "Error": { - "type": "object", - "properties": { - "error": { - "type": "string", - "description": "Error message" - } - }, - "required": [ - "error" - ] - } - } - }, - "paths": { - "/v1/billing/checkout": { - "post": { - "tags": [ - "Billing" - ], - "summary": "Create a Stripe checkout session", - "description": "Creates a Stripe Checkout session for a Pro subscription (€9/month).\nReturns a URL to redirect the user to Stripe's hosted payment page.\nRate limited to 3 requests per hour per IP.\n", - "responses": { - "200": { - "description": "Checkout session created", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "url": { - "type": "string", - "format": "uri", - "description": "Stripe Checkout URL to redirect the user to" - } - } - } - } - } - }, - "413": { - "description": "Request body too large" - }, - "429": { - "description": "Too many checkout requests" - }, - "500": { - "description": "Failed to create checkout session" - } - } - } - }, - "/v1/convert/html": { - "post": { - "tags": [ - "Conversion" - ], - "summary": "Convert HTML to PDF", - "description": "Converts HTML content to a PDF document. Bare HTML fragments are automatically wrapped in a full HTML document.", - "security": [ - { - "BearerAuth": [] - }, - { - "ApiKeyHeader": [] - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "type": "object", - "required": [ - "html" - ], - "properties": { - "html": { - "type": "string", - "description": "HTML content to convert. Can be a full document or a fragment.", - "example": "

    Hello World

    My first PDF

    " - }, - "css": { - "type": "string", - "description": "Optional CSS to inject (only used when html is a fragment, not a full document)", - "example": "body { font-family: sans-serif; padding: 40px; }" - } - } - }, - { - "$ref": "#/components/schemas/PdfOptions" - } - ] - } - } - } - }, - "responses": { - "200": { - "description": "PDF document", - "content": { - "application/pdf": { - "schema": { - "type": "string", - "format": "binary" - } - } - } - }, - "400": { - "description": "Missing html field" - }, - "401": { - "description": "Missing API key" - }, - "403": { - "description": "Invalid API key" - }, - "415": { - "description": "Unsupported Content-Type (must be application/json)" - }, - "429": { - "description": "Rate limit or usage limit exceeded" - }, - "500": { - "description": "PDF generation failed" - } - } - } - }, - "/v1/convert/markdown": { - "post": { - "tags": [ - "Conversion" - ], - "summary": "Convert Markdown to PDF", - "description": "Converts Markdown content to HTML and then to a PDF document.", - "security": [ - { - "BearerAuth": [] - }, - { - "ApiKeyHeader": [] - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "type": "object", - "required": [ - "markdown" - ], - "properties": { - "markdown": { - "type": "string", - "description": "Markdown content to convert", - "example": "# Hello World\\n\\nThis is **bold** and *italic*." - }, - "css": { - "type": "string", - "description": "Optional CSS to inject into the rendered HTML" - } - } - }, - { - "$ref": "#/components/schemas/PdfOptions" - } - ] - } - } - } - }, - "responses": { - "200": { - "description": "PDF document", - "content": { - "application/pdf": { - "schema": { - "type": "string", - "format": "binary" - } - } - } - }, - "400": { - "description": "Missing markdown field" - }, - "401": { - "description": "Missing API key" - }, - "403": { - "description": "Invalid API key" - }, - "415": { - "description": "Unsupported Content-Type" - }, - "429": { - "description": "Rate limit or usage limit exceeded" - }, - "500": { - "description": "PDF generation failed" - } - } - } - }, - "/v1/convert/url": { - "post": { - "tags": [ - "Conversion" - ], - "summary": "Convert URL to PDF", - "description": "Fetches a URL and converts the rendered page to PDF. JavaScript is disabled for security.\nPrivate/internal IP addresses are blocked (SSRF protection). DNS is pinned to prevent rebinding.\n", - "security": [ - { - "BearerAuth": [] - }, - { - "ApiKeyHeader": [] - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "type": "object", - "required": [ - "url" - ], - "properties": { - "url": { - "type": "string", - "format": "uri", - "description": "URL to convert (http or https only)", - "example": "https://example.com" - }, - "waitUntil": { - "type": "string", - "enum": [ - "load", - "domcontentloaded", - "networkidle0", - "networkidle2" - ], - "default": "domcontentloaded", - "description": "When to consider navigation finished" - } - } - }, - { - "$ref": "#/components/schemas/PdfOptions" - } - ] - } - } - } - }, - "responses": { - "200": { - "description": "PDF document", - "content": { - "application/pdf": { - "schema": { - "type": "string", - "format": "binary" - } - } - } - }, - "400": { - "description": "Missing/invalid URL or URL resolves to private IP" - }, - "401": { - "description": "Missing API key" - }, - "403": { - "description": "Invalid API key" - }, - "415": { - "description": "Unsupported Content-Type" - }, - "429": { - "description": "Rate limit or usage limit exceeded" - }, - "500": { - "description": "PDF generation failed" - } - } - } - }, - "/v1/demo/html": { - "post": { - "tags": [ - "Demo" - ], - "summary": "Convert HTML to PDF (demo)", - "description": "Public endpoint — no API key required. Rate limited to 5 requests per hour per IP.\nOutput PDFs include a DocFast watermark. Upgrade to Pro for clean output.\n", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "type": "object", - "required": [ - "html" - ], - "properties": { - "html": { - "type": "string", - "description": "HTML content to convert", - "example": "

    Hello World

    My first PDF

    " - }, - "css": { - "type": "string", - "description": "Optional CSS to inject (used when html is a fragment)" - } - } - }, - { - "$ref": "#/components/schemas/PdfOptions" - } - ] - } - } - } - }, - "responses": { - "200": { - "description": "Watermarked PDF document", - "content": { - "application/pdf": { - "schema": { - "type": "string", - "format": "binary" - } - } - } - }, - "400": { - "description": "Missing html field", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "415": { - "description": "Unsupported Content-Type" - }, - "429": { - "description": "Demo rate limit exceeded (5/hour)", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "503": { - "description": "Server busy" - }, - "504": { - "description": "PDF generation timed out" - } - } - } - }, - "/v1/demo/markdown": { - "post": { - "tags": [ - "Demo" - ], - "summary": "Convert Markdown to PDF (demo)", - "description": "Public endpoint — no API key required. Rate limited to 5 requests per hour per IP.\nMarkdown is converted to HTML then rendered to PDF with a DocFast watermark.\n", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "allOf": [ - { - "type": "object", - "required": [ - "markdown" - ], - "properties": { - "markdown": { - "type": "string", - "description": "Markdown content to convert", - "example": "# Hello World\\n\\nThis is **bold** and *italic*." - }, - "css": { - "type": "string", - "description": "Optional CSS to inject" - } - } - }, - { - "$ref": "#/components/schemas/PdfOptions" - } - ] - } - } - } - }, - "responses": { - "200": { - "description": "Watermarked PDF document", - "content": { - "application/pdf": { - "schema": { - "type": "string", - "format": "binary" - } - } - } - }, - "400": { - "description": "Missing markdown field" - }, - "415": { - "description": "Unsupported Content-Type" - }, - "429": { - "description": "Demo rate limit exceeded (5/hour)" - }, - "503": { - "description": "Server busy" - }, - "504": { - "description": "PDF generation timed out" - } - } - } - }, - "/health": { - "get": { - "tags": [ - "System" - ], - "summary": "Health check", - "description": "Returns service health status including database connectivity and browser pool stats.", - "responses": { - "200": { - "description": "Service is healthy", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "status": { - "type": "string", - "enum": [ - "ok", - "degraded" - ] - }, - "version": { - "type": "string", - "example": "0.4.0" - }, - "database": { - "type": "object", - "properties": { - "status": { - "type": "string", - "enum": [ - "ok", - "error" - ] - }, - "version": { - "type": "string", - "example": "PostgreSQL 17.4" - } - } - }, - "pool": { - "type": "object", - "properties": { - "size": { - "type": "integer" - }, - "active": { - "type": "integer" - }, - "available": { - "type": "integer" - }, - "queueDepth": { - "type": "integer" - }, - "pdfCount": { - "type": "integer" - }, - "restarting": { - "type": "boolean" - }, - "uptimeSeconds": { - "type": "integer" - } - } - } - } - } - } - } - }, - "503": { - "description": "Service is degraded (database issue)" - } - } - } - }, - "/v1/recover": { - "post": { - "tags": [ - "Account" - ], - "summary": "Request API key recovery", - "description": "Sends a 6-digit verification code to the email address if an account exists.\nResponse is always the same regardless of whether the email exists (to prevent enumeration).\nRate limited to 3 requests per hour.\n", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "email" - ], - "properties": { - "email": { - "type": "string", - "format": "email", - "description": "Email address associated with the API key" - } - } - } - } - } - }, - "responses": { - "200": { - "description": "Recovery code sent (or no-op if email not found)", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "status": { - "type": "string", - "example": "recovery_sent" - }, - "message": { - "type": "string" - } - } - } - } - } - }, - "400": { - "description": "Invalid email format" - }, - "429": { - "description": "Too many recovery attempts" - } - } - } - }, - "/v1/recover/verify": { - "post": { - "tags": [ - "Account" - ], - "summary": "Verify recovery code and retrieve API key", - "description": "Verifies the 6-digit code sent via email and returns the API key if valid. Code expires after 15 minutes.", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "email", - "code" - ], - "properties": { - "email": { - "type": "string", - "format": "email" - }, - "code": { - "type": "string", - "pattern": "^\\d{6}$", - "description": "6-digit verification code" - } - } - } - } - } - }, - "responses": { - "200": { - "description": "API key recovered", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "status": { - "type": "string", - "example": "recovered" - }, - "apiKey": { - "type": "string", - "description": "The recovered API key" - }, - "tier": { - "type": "string", - "enum": [ - "free", - "pro" - ] - } - } - } - } - } - }, - "400": { - "description": "Invalid verification code or missing fields" - }, - "410": { - "description": "Verification code expired" - }, - "429": { - "description": "Too many failed attempts" - } - } - } - }, - "/v1/templates": { - "get": { - "tags": [ - "Templates" - ], - "summary": "List available templates", - "description": "Returns a list of all built-in document templates with their required fields.", - "security": [ - { - "BearerAuth": [] - }, - { - "ApiKeyHeader": [] - } - ], - "responses": { - "200": { - "description": "List of templates", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "templates": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string", - "example": "invoice" - }, - "name": { - "type": "string", - "example": "Invoice" - }, - "description": { - "type": "string" - }, - "fields": { - "type": "array", - "items": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "required": { - "type": "boolean" - }, - "description": { - "type": "string" - } - } - } - } - } - } - } - } - } - } - } - }, - "401": { - "description": "Missing API key" - }, - "403": { - "description": "Invalid API key" - } - } - } - }, - "/v1/templates/{id}/render": { - "post": { - "tags": [ - "Templates" - ], - "summary": "Render a template to PDF", - "description": "Renders a built-in template with the provided data and returns a PDF.\nUse GET /v1/templates to see available templates and their required fields.\nSpecial fields: `_format` (page size), `_margin` (page margins), `_filename` (output filename).\n", - "security": [ - { - "BearerAuth": [] - }, - { - "ApiKeyHeader": [] - } - ], - "parameters": [ - { - "in": "path", - "name": "id", - "required": true, - "schema": { - "type": "string" - }, - "description": "Template ID (e.g. \"invoice\", \"receipt\")" - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "data": { - "type": "object", - "description": "Template data (fields depend on template). Can also be passed at root level." - }, - "_format": { - "type": "string", - "enum": [ - "A4", - "Letter", - "Legal", - "A3", - "A5", - "Tabloid" - ], - "default": "A4", - "description": "Page size override" - }, - "_margin": { - "type": "object", - "properties": { - "top": { - "type": "string" - }, - "right": { - "type": "string" - }, - "bottom": { - "type": "string" - }, - "left": { - "type": "string" - } - }, - "description": "Page margin override" - }, - "_filename": { - "type": "string", - "description": "Custom output filename" - } - } - } - } - } - }, - "responses": { - "200": { - "description": "PDF document", - "content": { - "application/pdf": { - "schema": { - "type": "string", - "format": "binary" - } - } - } - }, - "400": { - "description": "Missing required template fields" - }, - "401": { - "description": "Missing API key" - }, - "403": { - "description": "Invalid API key" - }, - "404": { - "description": "Template not found" - }, - "500": { - "description": "Template rendering failed" - } - } - } - }, - "/v1/signup/free": { - "post": { - "tags": [ - "Account" - ], - "summary": "Free signup (discontinued)", - "description": "Free accounts have been discontinued. Use the demo endpoint for testing\nor subscribe to Pro for production use.\n", - "deprecated": true, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "email": { - "type": "string", - "format": "email" - } - } - } - } - } - }, - "responses": { - "410": { - "description": "Free accounts discontinued", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "error": { - "type": "string", - "example": "Free accounts have been discontinued." - }, - "demo_endpoint": { - "type": "string", - "example": "/v1/demo/html" - }, - "pro_url": { - "type": "string", - "example": "https://docfast.dev/#pricing" - } - } - } - } - } - } - } - } - }, - "/v1/usage": { - "get": { - "tags": [ - "System" - ], - "summary": "Usage statistics (admin only)", - "description": "Returns usage statistics for the authenticated user. Requires admin API key.", - "security": [ - { - "BearerAuth": [] - }, - { - "ApiKeyHeader": [] - } - ], - "responses": { - "200": { - "description": "Usage statistics", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": { - "type": "object", - "properties": { - "count": { - "type": "integer" - }, - "month": { - "type": "string" - } - } - } - } - } - } - }, - "403": { - "description": "Admin access required" - }, - "503": { - "description": "Admin access not configured" - } - } - } - } - } -} \ No newline at end of file +{} \ No newline at end of file diff --git a/public/src/index.html b/public/src/index.html index 7b02099..b8a3d8a 100644 --- a/public/src/index.html +++ b/public/src/index.html @@ -672,6 +672,6 @@ html, body {
    - + diff --git a/public/src/status.html b/public/src/status.html index f616ed9..9a1fcd2 100644 --- a/public/src/status.html +++ b/public/src/status.html @@ -49,6 +49,6 @@ {{> footer}} - + diff --git a/public/status.html b/public/status.html index c7288eb..b13374c 100644 --- a/public/status.html +++ b/public/status.html @@ -112,6 +112,6 @@ footer .container { display: flex; justify-content: space-between; align-items:
    - + diff --git a/public/status.js b/public/status.js index bd8a0b2..40617dd 100644 --- a/public/status.js +++ b/public/status.js @@ -1,48 +1 @@ -async function fetchStatus() { - const el = document.getElementById("status-content"); - try { - const res = await fetch("/health"); - const d = await res.json(); - const isOk = d.status === "ok"; - const isDegraded = d.status === "degraded"; - const dotClass = isOk ? "ok" : isDegraded ? "degraded" : "error"; - const label = isOk ? "All Systems Operational" : isDegraded ? "Degraded Performance" : "Service Disruption"; - const now = new Date().toLocaleTimeString(); - - el.innerHTML = - "
    " + - "
    " + label + "
    " + - "
    Version " + d.version + " · Last checked " + now + " · Auto-refreshes every 30s
    " + - "
    " + - "
    " + - "
    " + - "

    🗄️ Database

    " + - "
    Status" + (d.database && d.database.status === "ok" ? "Connected" : "Error") + "
    " + - "
    Engine" + (d.database ? d.database.version : "Unknown") + "
    " + - "
    " + - "
    " + - "

    🖨️ PDF Engine

    " + - "
    Status 0 ? "ok" : "warn") + "\">" + (d.pool && d.pool.available > 0 ? "Ready" : "Busy") + "
    " + - "
    Available" + (d.pool ? d.pool.available : 0) + " / " + (d.pool ? d.pool.size : 0) + "
    " + - "
    Queue 0 ? "warn" : "ok") + "\">" + (d.pool ? d.pool.queueDepth : 0) + " waiting
    " + - "
    PDFs Generated" + (d.pool ? d.pool.pdfCount.toLocaleString() : "0") + "
    " + - "
    Uptime" + formatUptime(d.pool ? d.pool.uptimeSeconds : 0) + "
    " + - "
    " + - "
    " + - ""; - } catch (e) { - el.innerHTML = "
    Unable to reach API
    The service may be temporarily unavailable. Please try again shortly.
    "; - } -} - -function formatUptime(s) { - if (!s && s !== 0) return "Unknown"; - if (s < 60) return s + "s"; - if (s < 3600) return Math.floor(s/60) + "m " + (s%60) + "s"; - var h = Math.floor(s/3600); - var m = Math.floor((s%3600)/60); - return h + "h " + m + "m"; -} - -fetchStatus(); -setInterval(fetchStatus, 30000); +async function fetchStatus(){const s=document.getElementById("status-content");try{const a=await fetch("/health"),t=await a.json(),e="ok"===t.status,l="degraded"===t.status,o=e?"ok":l?"degraded":"error",n=e?"All Systems Operational":l?"Degraded Performance":"Service Disruption",i=(new Date).toLocaleTimeString();s.innerHTML='
    '+n+'
    Version '+t.version+" · Last checked "+i+' · Auto-refreshes every 30s

    🗄️ Database

    Status'+(t.database&&"ok"===t.database.status?"Connected":"Error")+'
    Engine'+(t.database?t.database.version:"Unknown")+'

    🖨️ PDF Engine

    Status'+(t.pool&&t.pool.available>0?"Ready":"Busy")+'
    Available'+(t.pool?t.pool.available:0)+" / "+(t.pool?t.pool.size:0)+'
    Queue'+(t.pool?t.pool.queueDepth:0)+' waiting
    PDFs Generated'+(t.pool?t.pool.pdfCount.toLocaleString():"0")+'
    Uptime'+formatUptime(t.pool?t.pool.uptimeSeconds:0)+'
    '}catch(a){s.innerHTML='
    Unable to reach API
    The service may be temporarily unavailable. Please try again shortly.
    '}}function formatUptime(s){return s||0===s?s<60?s+"s":s<3600?Math.floor(s/60)+"m "+s%60+"s":Math.floor(s/3600)+"h "+Math.floor(s%3600/60)+"m":"Unknown"}fetchStatus(),setInterval(fetchStatus,3e4); \ No newline at end of file diff --git a/scripts/build-html.cjs b/scripts/build-html.cjs index 9bdecb2..d3c3188 100644 --- a/scripts/build-html.cjs +++ b/scripts/build-html.cjs @@ -47,18 +47,18 @@ for (const file of files) { } console.log('Done.'); -// JS Minification (requires terser) +// JS Minification (overwrite original files) const { execSync } = require("child_process"); -const jsFiles = [ - { src: "public/app.js", out: "public/app.min.js" }, - { src: "public/status.js", out: "public/status.min.js" }, -]; +const jsFiles = ["public/app.js", "public/status.js"]; console.log("Minifying JS..."); -for (const { src, out } of jsFiles) { - const srcPath = path.join(__dirname, "..", src); - const outPath = path.join(__dirname, "..", out); - if (fs.existsSync(srcPath)) { - execSync(`npx terser ${srcPath} -o ${outPath} -c -m`, { stdio: "inherit" }); - console.log(` Minified: ${src} → ${out}`); +for (const jsFile of jsFiles) { + const filePath = path.join(__dirname, "..", jsFile); + if (fs.existsSync(filePath)) { + // Create backup, minify, then overwrite original + const backupPath = filePath + ".bak"; + fs.copyFileSync(filePath, backupPath); + execSync(`npx terser ${filePath} -o ${filePath} -c -m`, { stdio: "inherit" }); + fs.unlinkSync(backupPath); // Clean up backup + console.log(` Minified: ${jsFile} (overwritten)`); } } diff --git a/src/__tests__/api.test.ts b/src/__tests__/api.test.ts index 7a0b8ce..5954ea7 100644 --- a/src/__tests__/api.test.ts +++ b/src/__tests__/api.test.ts @@ -456,3 +456,152 @@ describe("API root", () => { expect(data.endpoints).toBeInstanceOf(Array); }); }); + +describe("JS minification", () => { + it("serves minified JS files in homepage HTML", async () => { + const res = await fetch(`${BASE}/`); + expect(res.status).toBe(200); + const html = await res.text(); + + // Check that HTML references app.js and status.js + expect(html).toContain('src="/app.js"'); + + // Fetch the JS file and verify it's minified (no excessive whitespace) + const jsRes = await fetch(`${BASE}/app.js`); + expect(jsRes.status).toBe(200); + const jsContent = await jsRes.text(); + + // Minified JS should not have excessive whitespace or comments + // Basic check: line count should be reasonable for minified code + const lineCount = jsContent.split('\n').length; + expect(lineCount).toBeLessThan(50); // Original has ~400+ lines, minified should be much less + + // Should not contain developer comments (/* ... */) + expect(jsContent).not.toMatch(/\/\*[\s\S]*?\*\//); + }); +}); + +describe("Usage endpoint", () => { + it("requires authentication (401 without key)", async () => { + const res = await fetch(`${BASE}/v1/usage`); + expect(res.status).toBe(401); + const data = await res.json(); + expect(data.error).toBeDefined(); + expect(typeof data.error).toBe("string"); + }); + + it("requires admin key (503 when not configured)", async () => { + const res = await fetch(`${BASE}/v1/usage`, { + headers: { Authorization: "Bearer test-key" }, + }); + expect(res.status).toBe(503); + const data = await res.json(); + expect(data.error).toBeDefined(); + expect(data.error).toContain("Admin access not configured"); + }); + + it("returns usage data with admin key", async () => { + // This test will likely fail since we don't have an admin key set in test environment + // But it documents the expected behavior + const res = await fetch(`${BASE}/v1/usage`, { + headers: { Authorization: "Bearer admin-key" }, + }); + // Could be 503 (admin access not configured) or 403 (admin access required) + expect([403, 503]).toContain(res.status); + }); +}); + +describe("Billing checkout", () => { + it("has rate limiting headers", async () => { + const res = await fetch(`${BASE}/v1/billing/checkout`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + + // Check rate limit headers are present (express-rate-limit should add these) + const limitHeader = res.headers.get("ratelimit-limit"); + const remainingHeader = res.headers.get("ratelimit-remaining"); + const resetHeader = res.headers.get("ratelimit-reset"); + + expect(limitHeader).toBeDefined(); + expect(remainingHeader).toBeDefined(); + expect(resetHeader).toBeDefined(); + }); + + it("fails when Stripe not configured", async () => { + const res = await fetch(`${BASE}/v1/billing/checkout`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + // Returns 500 due to missing STRIPE_SECRET_KEY in test environment + expect(res.status).toBe(500); + const data = await res.json(); + expect(data.error).toBeDefined(); + }); +}); + +describe("Rate limit headers on PDF endpoints", () => { + it("includes rate limit headers on HTML conversion", async () => { + const res = await fetch(`${BASE}/v1/convert/html`, { + method: "POST", + headers: { + Authorization: "Bearer test-key", + "Content-Type": "application/json" + }, + body: JSON.stringify({ html: "

    Test

    " }), + }); + + expect(res.status).toBe(200); + + // Check for rate limit headers + const limitHeader = res.headers.get("ratelimit-limit"); + const remainingHeader = res.headers.get("ratelimit-remaining"); + const resetHeader = res.headers.get("ratelimit-reset"); + + expect(limitHeader).toBeDefined(); + expect(remainingHeader).toBeDefined(); + expect(resetHeader).toBeDefined(); + }); + + it("includes rate limit headers on demo endpoint", async () => { + const res = await fetch(`${BASE}/v1/demo/html`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ html: "

    Demo Test

    " }), + }); + + expect(res.status).toBe(200); + + // Check for rate limit headers + const limitHeader = res.headers.get("ratelimit-limit"); + const remainingHeader = res.headers.get("ratelimit-remaining"); + const resetHeader = res.headers.get("ratelimit-reset"); + + expect(limitHeader).toBeDefined(); + expect(remainingHeader).toBeDefined(); + expect(resetHeader).toBeDefined(); + }); +}); + +describe("404 handler", () => { + it("returns proper JSON error format for API routes", async () => { + const res = await fetch(`${BASE}/v1/nonexistent-endpoint`); + expect(res.status).toBe(404); + const data = await res.json(); + expect(typeof data.error).toBe("string"); + expect(data.error).toContain("Not Found"); + expect(data.error).toContain("GET"); + expect(data.error).toContain("/v1/nonexistent-endpoint"); + }); + + it("returns HTML 404 for non-API routes", async () => { + const res = await fetch(`${BASE}/nonexistent-page`); + expect(res.status).toBe(404); + const html = await res.text(); + expect(html).toContain(""); + expect(html).toContain("404"); + expect(html).toContain("Page Not Found"); + }); +}); From 288d6c7aab5e26a8d78dc563f9d0d7eb69bc3173 Mon Sep 17 00:00:00 2001 From: DocFast CEO Date: Wed, 25 Feb 2026 13:04:26 +0000 Subject: [PATCH 051/169] fix: revert swagger-jsdoc to 6.2.8 (7.0.0-rc.6 broke OpenAPI spec generation) + add OpenAPI spec tests swagger-jsdoc 7.0.0-rc.6 returns empty spec (0 paths), breaking /docs and /openapi.json. Reverted to 6.2.8 which correctly generates all 10+ paths. Added 2 regression tests to catch this in CI. --- package-lock.json | 65 ++++++++++++++++++++++++++------------- package.json | 2 +- src/__tests__/api.test.ts | 21 +++++++++++++ 3 files changed, 66 insertions(+), 22 deletions(-) diff --git a/package-lock.json b/package-lock.json index 49e2634..99652fa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,7 @@ "pino": "^10.3.1", "puppeteer": "^24.0.0", "stripe": "^20.3.1", - "swagger-jsdoc": "^7.0.0-rc.6", + "swagger-jsdoc": "^6.2.8", "swagger-ui-dist": "^5.31.0" }, "devDependencies": { @@ -63,9 +63,9 @@ "license": "MIT" }, "node_modules/@apidevtools/swagger-parser": { - "version": "10.0.2", - "resolved": "https://registry.npmjs.org/@apidevtools/swagger-parser/-/swagger-parser-10.0.2.tgz", - "integrity": "sha512-JFxcEyp8RlNHgBCE98nwuTkZT6eNFPc1aosWV6wPcQph72TSEEu1k3baJD4/x1qznU+JiDdz8F5pTwabZh+Dhg==", + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/@apidevtools/swagger-parser/-/swagger-parser-10.0.3.tgz", + "integrity": "sha512-sNiLY51vZOmSPFZA5TF35KZ2HbgYklQnTSDnkghamzLb3EkNtcQnrBQEj5AOCxHpTtXpqMCRM1CrmV2rG6nw4g==", "license": "MIT", "dependencies": { "@apidevtools/json-schema-ref-parser": "^9.0.6", @@ -73,7 +73,7 @@ "@apidevtools/swagger-methods": "^3.0.2", "@jsdevtools/ono": "^7.1.3", "call-me-maybe": "^1.0.1", - "z-schema": "^4.2.3" + "z-schema": "^5.0.1" }, "peerDependencies": { "openapi-types": ">=7" @@ -1713,7 +1713,7 @@ "version": "2.20.3", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/compressible": { @@ -4002,21 +4002,34 @@ } }, "node_modules/swagger-jsdoc": { - "version": "7.0.0-rc.6", - "resolved": "https://registry.npmjs.org/swagger-jsdoc/-/swagger-jsdoc-7.0.0-rc.6.tgz", - "integrity": "sha512-LIvIPQxipRaOzIij+HrWOcCWTINE6OeJuqmXCfDkofVcstPVABHRkaAc3D7vrX9s7L0ccH0sH0amwHgN6+SXPg==", + "version": "6.2.8", + "resolved": "https://registry.npmjs.org/swagger-jsdoc/-/swagger-jsdoc-6.2.8.tgz", + "integrity": "sha512-VPvil1+JRpmJ55CgAtn8DIcpBs0bL5L3q5bVQvF4tAW/k/9JYSj7dCpaYCAv5rufe0vcCbBRQXGvzpkWjvLklQ==", "license": "MIT", "dependencies": { + "commander": "6.2.0", "doctrine": "3.0.0", "glob": "7.1.6", - "lodash.mergewith": "4.6.2", - "swagger-parser": "10.0.2", + "lodash.mergewith": "^4.6.2", + "swagger-parser": "^10.0.3", "yaml": "2.0.0-1" }, + "bin": { + "swagger-jsdoc": "bin/swagger-jsdoc.js" + }, "engines": { "node": ">=12.0.0" } }, + "node_modules/swagger-jsdoc/node_modules/commander": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.0.tgz", + "integrity": "sha512-zP4jEKbe8SHzKJYQmq8Y9gYjtO/POJLgIdKgV7B9qNmABVFVc+ctqSX6iXh4mCpJfRBOabiZ2YKPg8ciDw6C+Q==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/swagger-jsdoc/node_modules/yaml": { "version": "2.0.0-1", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.0.0-1.tgz", @@ -4027,12 +4040,12 @@ } }, "node_modules/swagger-parser": { - "version": "10.0.2", - "resolved": "https://registry.npmjs.org/swagger-parser/-/swagger-parser-10.0.2.tgz", - "integrity": "sha512-9jHkHM+QXyLGFLk1DkXBwV+4HyNm0Za3b8/zk/+mjr8jgOSiqm3FOTHBSDsBjtn9scdL+8eWcHdupp2NLM8tDw==", + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/swagger-parser/-/swagger-parser-10.0.3.tgz", + "integrity": "sha512-nF7oMeL4KypldrQhac8RyHerJeGPD1p2xDh900GPvc+Nk7nWP6jX2FcC7WmkinMoAmoO774+AFXcWsW8gMWEIg==", "license": "MIT", "dependencies": { - "@apidevtools/swagger-parser": "10.0.2" + "@apidevtools/swagger-parser": "10.0.3" }, "engines": { "node": ">=10" @@ -4644,23 +4657,33 @@ } }, "node_modules/z-schema": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/z-schema/-/z-schema-4.2.4.tgz", - "integrity": "sha512-YvBeW5RGNeNzKOUJs3rTL4+9rpcvHXt5I051FJbOcitV8bl40pEfcG0Q+dWSwS0/BIYrMZ/9HHoqLllMkFhD0w==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/z-schema/-/z-schema-5.0.5.tgz", + "integrity": "sha512-D7eujBWkLa3p2sIpJA0d1pr7es+a7m0vFAnZLlCEKq/Ij2k0MLi9Br2UPxoxdYystm5K1yeBGzub0FlYUEWj2Q==", "license": "MIT", "dependencies": { "lodash.get": "^4.4.2", "lodash.isequal": "^4.5.0", - "validator": "^13.6.0" + "validator": "^13.7.0" }, "bin": { "z-schema": "bin/z-schema" }, "engines": { - "node": ">=6.0.0" + "node": ">=8.0.0" }, "optionalDependencies": { - "commander": "^2.7.1" + "commander": "^9.4.1" + } + }, + "node_modules/z-schema/node_modules/commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": "^12.20.0 || >=14" } }, "node_modules/zod": { diff --git a/package.json b/package.json index bffa2a5..e9db8d0 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "pino": "^10.3.1", "puppeteer": "^24.0.0", "stripe": "^20.3.1", - "swagger-jsdoc": "^7.0.0-rc.6", + "swagger-jsdoc": "^6.2.8", "swagger-ui-dist": "^5.31.0" }, "devDependencies": { diff --git a/src/__tests__/api.test.ts b/src/__tests__/api.test.ts index 5954ea7..d173297 100644 --- a/src/__tests__/api.test.ts +++ b/src/__tests__/api.test.ts @@ -585,6 +585,27 @@ describe("Rate limit headers on PDF endpoints", () => { }); }); +describe("OpenAPI spec", () => { + it("returns a valid OpenAPI 3.0 spec with paths", async () => { + const res = await fetch(`${BASE}/openapi.json`); + expect(res.status).toBe(200); + const spec = await res.json(); + expect(spec.openapi).toBe("3.0.3"); + expect(spec.info).toBeDefined(); + expect(spec.info.title).toBe("DocFast API"); + expect(Object.keys(spec.paths).length).toBeGreaterThanOrEqual(8); + }); + + it("includes all major endpoint groups", async () => { + const res = await fetch(`${BASE}/openapi.json`); + const spec = await res.json(); + const paths = Object.keys(spec.paths); + expect(paths).toContain("/v1/convert/html"); + expect(paths).toContain("/v1/convert/markdown"); + expect(paths).toContain("/health"); + }); +}); + describe("404 handler", () => { it("returns proper JSON error format for API routes", async () => { const res = await fetch(`${BASE}/v1/nonexistent-endpoint`); From c4fea7932c72470950c80d90b1eeabed97856a84 Mon Sep 17 00:00:00 2001 From: DocFast Dev Date: Wed, 25 Feb 2026 13:10:32 +0000 Subject: [PATCH 052/169] feat: add unhandled error handlers + SSRF and Content-Disposition tests --- src/__tests__/api.test.ts | 22 ++++++++++++++++++++++ src/index.ts | 10 ++++++++++ 2 files changed, 32 insertions(+) diff --git a/src/__tests__/api.test.ts b/src/__tests__/api.test.ts index d173297..0645f00 100644 --- a/src/__tests__/api.test.ts +++ b/src/__tests__/api.test.ts @@ -197,6 +197,28 @@ describe("URL to PDF", () => { expect(data.error).toContain("private"); }); + it("blocks 0.0.0.0 (SSRF protection)", async () => { + const res = await fetch(`${BASE}/v1/convert/url`, { + method: "POST", + headers: { Authorization: "Bearer test-key", "Content-Type": "application/json" }, + body: JSON.stringify({ url: "http://0.0.0.0" }), + }); + expect(res.status).toBe(400); + const data = await res.json(); + expect(data.error).toContain("private"); + }); + + it("returns default filename in Content-Disposition for /convert/html", async () => { + const res = await fetch(`${BASE}/v1/convert/html`, { + method: "POST", + headers: { Authorization: "Bearer test-key", "Content-Type": "application/json" }, + body: JSON.stringify({ html: "

    hello

    " }), + }); + expect(res.status).toBe(200); + const disposition = res.headers.get("content-disposition"); + expect(disposition).toContain('filename="document.pdf"'); + }); + it("rejects invalid protocol (ftp)", async () => { const res = await fetch(`${BASE}/v1/convert/url`, { method: "POST", diff --git a/src/index.ts b/src/index.ts index 2d8cd94..a0499b3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -422,6 +422,16 @@ async function start() { }; process.on("SIGTERM", () => shutdown("SIGTERM")); process.on("SIGINT", () => shutdown("SIGINT")); + + process.on("uncaughtException", (err) => { + logger.fatal({ err }, "Uncaught exception — shutting down"); + process.exit(1); + }); + + process.on("unhandledRejection", (reason) => { + logger.fatal({ err: reason }, "Unhandled rejection — shutting down"); + process.exit(1); + }); } if (process.env.NODE_ENV !== "test") { From 0a002f94efbb52743d77eec38ab4ed6ce2050189 Mon Sep 17 00:00:00 2001 From: Hoid Date: Wed, 25 Feb 2026 16:04:22 +0000 Subject: [PATCH 053/169] refactor: deduplicate sanitizeFilename, add template+sanitize unit tests, fix esc single-quote --- src/__tests__/sanitize.test.ts | 24 ++++++++++++++ src/__tests__/templates.test.ts | 57 +++++++++++++++++++++++++++++++++ src/routes/convert.ts | 5 +-- src/routes/templates.ts | 5 +-- src/services/templates.ts | 3 +- src/utils/sanitize.ts | 4 +++ 6 files changed, 89 insertions(+), 9 deletions(-) create mode 100644 src/__tests__/sanitize.test.ts create mode 100644 src/__tests__/templates.test.ts create mode 100644 src/utils/sanitize.ts diff --git a/src/__tests__/sanitize.test.ts b/src/__tests__/sanitize.test.ts new file mode 100644 index 0000000..550ecd0 --- /dev/null +++ b/src/__tests__/sanitize.test.ts @@ -0,0 +1,24 @@ +import { describe, it, expect } from "vitest"; +import { sanitizeFilename } from "../utils/sanitize.js"; + +describe("sanitizeFilename", () => { + it("passes normal filename through", () => { + expect(sanitizeFilename("report.pdf")).toBe("report.pdf"); + }); + it("replaces control characters", () => { + expect(sanitizeFilename("file\x00name.pdf")).toBe("file_name.pdf"); + }); + it("replaces quotes", () => { + expect(sanitizeFilename('file"name.pdf')).toBe("file_name.pdf"); + }); + it("returns default for empty string", () => { + expect(sanitizeFilename("")).toBe("document.pdf"); + }); + it("truncates to 200 characters", () => { + const long = "a".repeat(250) + ".pdf"; + expect(sanitizeFilename(long).length).toBeLessThanOrEqual(200); + }); + it("supports custom default name", () => { + expect(sanitizeFilename("", "invoice.pdf")).toBe("invoice.pdf"); + }); +}); diff --git a/src/__tests__/templates.test.ts b/src/__tests__/templates.test.ts new file mode 100644 index 0000000..52ca00b --- /dev/null +++ b/src/__tests__/templates.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect } from "vitest"; +import { renderTemplate, templates } from "../services/templates.js"; + +// Access esc via rendering — test that HTML entities are escaped in output +describe("Template rendering", () => { + it("throws for unknown template", () => { + expect(() => renderTemplate("nonexistent", {})).toThrow("not found"); + }); + + it("invoice renders with correct totals", () => { + const html = renderTemplate("invoice", { + invoiceNumber: "INV-001", + date: "2026-01-01", + from: { name: "Seller" }, + to: { name: "Buyer" }, + items: [{ description: "Widget", quantity: 2, unitPrice: 10, taxRate: 20 }], + }); + expect(html).toContain("INV-001"); + expect(html).toContain("€20.00"); // subtotal: 2*10 + expect(html).toContain("€4.00"); // tax: 20*0.2 + expect(html).toContain("€24.00"); // total + }); + + it("receipt renders with correct total", () => { + const html = renderTemplate("receipt", { + receiptNumber: "R-001", + date: "2026-01-01", + from: { name: "Shop" }, + items: [{ description: "Item A", amount: 15 }, { description: "Item B", amount: 25 }], + }); + expect(html).toContain("R-001"); + expect(html).toContain("€40.00"); + }); + + it("defaults currency to €", () => { + const html = renderTemplate("invoice", { + invoiceNumber: "X", date: "2026-01-01", + from: { name: "A" }, to: { name: "B" }, + items: [{ description: "Test", quantity: 1, unitPrice: 5 }], + }); + expect(html).toContain("€5.00"); + }); + + it("escapes HTML entities including single quotes", () => { + const html = renderTemplate("invoice", { + invoiceNumber: '', + date: "2026-01-01", + from: { name: "O'Brien & Co" }, + to: { name: "Bob" }, + items: [{ description: "Test", quantity: 1, unitPrice: 1 }], + }); + expect(html).not.toContain(" +`; } // Landing page const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -222,6 +235,11 @@ app.use((req, res, next) => { } next(); }); +// Landing page (explicit route to set Cache-Control header) +app.get("/", (_req, res) => { + res.setHeader('Cache-Control', 'public, max-age=3600'); + res.sendFile(path.join(__dirname, "../public/index.html")); +}); app.use(express.static(path.join(__dirname, "../public"), { etag: true, cacheControl: false, @@ -316,6 +334,16 @@ async function start() { await initBrowser(); logger.info(`Loaded ${getAllKeys().length} API keys`); const server = app.listen(PORT, () => logger.info(`DocFast API running on :${PORT}`)); + // Run database cleanup 30 seconds after startup (non-blocking) + setTimeout(async () => { + try { + logger.info("Running scheduled database cleanup..."); + await cleanupStaleData(); + } + catch (err) { + logger.error({ err }, "Startup cleanup failed (non-fatal)"); + } + }, 30_000); let shuttingDown = false; const shutdown = async (signal) => { if (shuttingDown) @@ -355,9 +383,19 @@ async function start() { }; process.on("SIGTERM", () => shutdown("SIGTERM")); process.on("SIGINT", () => shutdown("SIGINT")); + process.on("uncaughtException", (err) => { + logger.fatal({ err }, "Uncaught exception — shutting down"); + process.exit(1); + }); + process.on("unhandledRejection", (reason) => { + logger.fatal({ err: reason }, "Unhandled rejection — shutting down"); + process.exit(1); + }); +} +if (process.env.NODE_ENV !== "test") { + start().catch((err) => { + logger.error({ err }, "Failed to start"); + process.exit(1); + }); } -start().catch((err) => { - logger.error({ err }, "Failed to start"); - process.exit(1); -}); export { app }; diff --git a/dist/middleware/pdfRateLimit.js b/dist/middleware/pdfRateLimit.js index 62bba72..d83c0ae 100644 --- a/dist/middleware/pdfRateLimit.js +++ b/dist/middleware/pdfRateLimit.js @@ -29,17 +29,33 @@ function checkRateLimit(apiKey) { const limit = getRateLimit(apiKey); const entry = rateLimitStore.get(apiKey); if (!entry || now >= entry.resetTime) { + const resetTime = now + RATE_WINDOW_MS; rateLimitStore.set(apiKey, { count: 1, - resetTime: now + RATE_WINDOW_MS + resetTime }); - return true; + return { + allowed: true, + limit, + remaining: limit - 1, + resetTime + }; } if (entry.count >= limit) { - return false; + return { + allowed: false, + limit, + remaining: 0, + resetTime: entry.resetTime + }; } entry.count++; - return true; + return { + allowed: true, + limit, + remaining: limit - entry.count, + resetTime: entry.resetTime + }; } function getQueuedCountForKey(apiKey) { return pdfQueue.filter(w => w.apiKey === apiKey).length; @@ -73,10 +89,16 @@ export function pdfRateLimitMiddleware(req, res, next) { const keyInfo = req.apiKeyInfo; const apiKey = keyInfo?.key || "unknown"; // Check rate limit first - if (!checkRateLimit(apiKey)) { - const limit = getRateLimit(apiKey); + const rateLimitResult = checkRateLimit(apiKey); + // Set rate limit headers on ALL responses + res.set('X-RateLimit-Limit', String(rateLimitResult.limit)); + res.set('X-RateLimit-Remaining', String(rateLimitResult.remaining)); + res.set('X-RateLimit-Reset', String(Math.ceil(rateLimitResult.resetTime / 1000))); + if (!rateLimitResult.allowed) { const tier = isProKey(apiKey) ? "pro" : "free"; - res.status(429).json({ error: `Rate limit exceeded: ${limit} PDFs/min allowed for ${tier} tier. Retry after 60s.` }); + const retryAfterSeconds = Math.ceil((rateLimitResult.resetTime - Date.now()) / 1000); + res.set('Retry-After', String(retryAfterSeconds)); + res.status(429).json({ error: `Rate limit exceeded: ${rateLimitResult.limit} PDFs/min allowed for ${tier} tier. Retry after ${retryAfterSeconds}s.` }); return; } // Add concurrency control to the request (pass apiKey for fairness) diff --git a/dist/routes/billing.js b/dist/routes/billing.js index 761fda1..096d291 100644 --- a/dist/routes/billing.js +++ b/dist/routes/billing.js @@ -3,9 +3,7 @@ import rateLimit from "express-rate-limit"; import Stripe from "stripe"; import { createProKey, downgradeByCustomer, updateEmailByCustomer, findKeyByCustomerId } from "../services/keys.js"; import logger from "../services/logger.js"; -function escapeHtml(s) { - return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """).replace(/'/g, "'"); -} +import { escapeHtml } from "../utils/html.js"; let _stripe = null; function getStripe() { if (!_stripe) { @@ -103,6 +101,36 @@ router.post("/checkout", checkoutLimiter, async (req, res) => { res.status(500).json({ error: "Failed to create checkout session" }); } }); +/** + * @openapi + * /v1/billing/success: + * get: + * tags: [Billing] + * summary: Checkout success page + * description: | + * Provisions a Pro API key after successful Stripe checkout and displays it in an HTML page. + * Called by Stripe redirect after payment completion. + * parameters: + * - in: query + * name: session_id + * required: true + * schema: + * type: string + * description: Stripe Checkout session ID + * responses: + * 200: + * description: HTML page displaying the new API key + * content: + * text/html: + * schema: + * type: string + * 400: + * description: Missing session_id or no customer found + * 409: + * description: Checkout session already used + * 500: + * description: Failed to retrieve session + */ // Success page — provision Pro API key after checkout router.get("/success", async (req, res) => { const sessionId = req.query.session_id; @@ -161,17 +189,60 @@ a { color: #4f9; }

    🎉 Welcome to Pro!

    Your API key:

    -
    ${escapeHtml(keyInfo.key)}
    +
    ${escapeHtml(keyInfo.key)}

    Save this key! It won't be shown again.

    5,000 PDFs/month • All endpoints • Priority support

    View API docs →

    -
    `); +
    + +`); } catch (err) { logger.error({ err }, "Success page error"); res.status(500).json({ error: "Failed to retrieve session" }); } }); +/** + * @openapi + * /v1/billing/webhook: + * post: + * tags: [Billing] + * summary: Stripe webhook endpoint + * description: | + * Receives Stripe webhook events for subscription lifecycle management. + * Requires the raw request body and a valid Stripe-Signature header for verification. + * Handles checkout.session.completed, customer.subscription.updated, + * customer.subscription.deleted, and customer.updated events. + * parameters: + * - in: header + * name: Stripe-Signature + * required: true + * schema: + * type: string + * description: Stripe webhook signature for payload verification + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * description: Raw Stripe event payload + * responses: + * 200: + * description: Webhook received + * content: + * application/json: + * schema: + * type: object + * properties: + * received: + * type: boolean + * example: true + * 400: + * description: Missing Stripe-Signature header or invalid signature + * 500: + * description: Webhook secret not configured + */ // Stripe webhook for subscription lifecycle events router.post("/webhook", async (req, res) => { const sig = req.headers["stripe-signature"]; diff --git a/dist/routes/convert.js b/dist/routes/convert.js index 0aa9c50..6d029de 100644 --- a/dist/routes/convert.js +++ b/dist/routes/convert.js @@ -3,43 +3,8 @@ import { renderPdf, renderUrlPdf } from "../services/browser.js"; import { markdownToHtml, wrapHtml } from "../services/markdown.js"; import dns from "node:dns/promises"; import logger from "../services/logger.js"; -import net from "node:net"; -function isPrivateIP(ip) { - // IPv6 loopback/unspecified - if (ip === "::1" || ip === "::") - return true; - // IPv6 link-local (fe80::/10) - if (ip.toLowerCase().startsWith("fe8") || ip.toLowerCase().startsWith("fe9") || - ip.toLowerCase().startsWith("fea") || ip.toLowerCase().startsWith("feb")) - return true; - // IPv6 unique local (fc00::/7) - const lower = ip.toLowerCase(); - if (lower.startsWith("fc") || lower.startsWith("fd")) - return true; - // IPv4-mapped IPv6 - if (ip.startsWith("::ffff:")) - ip = ip.slice(7); - if (!net.isIPv4(ip)) - return false; - const parts = ip.split(".").map(Number); - if (parts[0] === 0) - return true; // 0.0.0.0/8 - if (parts[0] === 10) - return true; // 10.0.0.0/8 - if (parts[0] === 127) - return true; // 127.0.0.0/8 - if (parts[0] === 169 && parts[1] === 254) - return true; // 169.254.0.0/16 - if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) - return true; // 172.16.0.0/12 - if (parts[0] === 192 && parts[1] === 168) - return true; // 192.168.0.0/16 - return false; -} -function sanitizeFilename(name) { - // Strip characters dangerous in Content-Disposition headers - return name.replace(/[\x00-\x1f"\\\r\n]/g, "").trim() || "document.pdf"; -} +import { isPrivateIP } from "../utils/network.js"; +import { sanitizeFilename } from "../utils/sanitize.js"; export const convertRouter = Router(); /** * @openapi @@ -118,6 +83,14 @@ convertRouter.post("/html", async (req, res) => { landscape: body.landscape, margin: body.margin, printBackground: body.printBackground, + headerTemplate: body.headerTemplate, + footerTemplate: body.footerTemplate, + displayHeaderFooter: body.displayHeaderFooter, + scale: body.scale, + pageRanges: body.pageRanges, + preferCSSPageSize: body.preferCSSPageSize, + width: body.width, + height: body.height, }); const filename = sanitizeFilename(body.filename || "document.pdf"); res.setHeader("Content-Type", "application/pdf"); @@ -211,6 +184,14 @@ convertRouter.post("/markdown", async (req, res) => { landscape: body.landscape, margin: body.margin, printBackground: body.printBackground, + headerTemplate: body.headerTemplate, + footerTemplate: body.footerTemplate, + displayHeaderFooter: body.displayHeaderFooter, + scale: body.scale, + pageRanges: body.pageRanges, + preferCSSPageSize: body.preferCSSPageSize, + width: body.width, + height: body.height, }); const filename = sanitizeFilename(body.filename || "document.pdf"); res.setHeader("Content-Type", "application/pdf"); @@ -335,6 +316,14 @@ convertRouter.post("/url", async (req, res) => { landscape: body.landscape, margin: body.margin, printBackground: body.printBackground, + headerTemplate: body.headerTemplate, + footerTemplate: body.footerTemplate, + displayHeaderFooter: body.displayHeaderFooter, + scale: body.scale, + pageRanges: body.pageRanges, + preferCSSPageSize: body.preferCSSPageSize, + width: body.width, + height: body.height, waitUntil: body.waitUntil, hostResolverRules: `MAP ${parsed.hostname} ${resolvedAddress}`, }); diff --git a/dist/routes/signup.js b/dist/routes/signup.js index bfa34df..0eb745f 100644 --- a/dist/routes/signup.js +++ b/dist/routes/signup.js @@ -51,6 +51,60 @@ router.post("/free", rejectDuplicateEmail, signupLimiter, async (req, res) => { message: "Check your email for the verification code.", }); }); +/** + * @openapi + * /v1/signup/verify: + * post: + * tags: [Account] + * summary: Verify email and get API key + * description: | + * Verifies the 6-digit code sent to the user's email and provisions a free API key. + * Rate limited to 15 attempts per 15 minutes. + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [email, code] + * properties: + * email: + * type: string + * format: email + * description: Email address used during signup + * example: user@example.com + * code: + * type: string + * description: 6-digit verification code from email + * example: "123456" + * responses: + * 200: + * description: Email verified, API key issued + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: string + * example: verified + * message: + * type: string + * apiKey: + * type: string + * description: The provisioned API key + * tier: + * type: string + * example: free + * 400: + * description: Missing fields or invalid verification code + * 409: + * description: Email already verified + * 410: + * description: Verification code expired + * 429: + * description: Too many failed attempts + */ // Step 2: Verify code — creates API key router.post("/verify", verifyLimiter, async (req, res) => { const { email, code } = req.body || {}; diff --git a/dist/routes/templates.js b/dist/routes/templates.js index dae4e9d..22dd769 100644 --- a/dist/routes/templates.js +++ b/dist/routes/templates.js @@ -2,9 +2,7 @@ import { Router } from "express"; import { renderPdf } from "../services/browser.js"; import logger from "../services/logger.js"; import { templates, renderTemplate } from "../services/templates.js"; -function sanitizeFilename(name) { - return name.replace(/["\r\n\x00-\x1f]/g, "_").substring(0, 200); -} +import { sanitizeFilename } from "../utils/sanitize.js"; export const templatesRouter = Router(); /** * @openapi diff --git a/dist/services/browser.js b/dist/services/browser.js index 2ec7521..923b501 100644 --- a/dist/services/browser.js +++ b/dist/services/browser.js @@ -209,6 +209,11 @@ export async function renderPdf(html, options = {}) { headerTemplate: options.headerTemplate, footerTemplate: options.footerTemplate, displayHeaderFooter: options.displayHeaderFooter || false, + ...(options.scale !== undefined && { scale: options.scale }), + ...(options.pageRanges && { pageRanges: options.pageRanges }), + ...(options.preferCSSPageSize !== undefined && { preferCSSPageSize: options.preferCSSPageSize }), + ...(options.width && { width: options.width }), + ...(options.height && { height: options.height }), }); return Buffer.from(pdf); })(), @@ -270,6 +275,14 @@ export async function renderUrlPdf(url, options = {}) { landscape: options.landscape || false, printBackground: options.printBackground !== false, margin: options.margin || { top: "0", right: "0", bottom: "0", left: "0" }, + ...(options.headerTemplate && { headerTemplate: options.headerTemplate }), + ...(options.footerTemplate && { footerTemplate: options.footerTemplate }), + ...(options.displayHeaderFooter !== undefined && { displayHeaderFooter: options.displayHeaderFooter }), + ...(options.scale !== undefined && { scale: options.scale }), + ...(options.pageRanges && { pageRanges: options.pageRanges }), + ...(options.preferCSSPageSize !== undefined && { preferCSSPageSize: options.preferCSSPageSize }), + ...(options.width && { width: options.width }), + ...(options.height && { height: options.height }), }); return Buffer.from(pdf); })(), diff --git a/dist/services/db.js b/dist/services/db.js index 35af8bb..fde5e35 100644 --- a/dist/services/db.js +++ b/dist/services/db.js @@ -1,20 +1,7 @@ import pg from "pg"; import logger from "./logger.js"; +import { isTransientError } from "../utils/errors.js"; const { Pool } = pg; -// Transient error codes from PgBouncer / PostgreSQL that warrant retry -const TRANSIENT_ERRORS = new Set([ - "ECONNRESET", - "ECONNREFUSED", - "EPIPE", - "ETIMEDOUT", - "CONNECTION_LOST", - "57P01", // admin_shutdown - "57P02", // crash_shutdown - "57P03", // cannot_connect_now - "08006", // connection_failure - "08003", // connection_does_not_exist - "08001", // sqlclient_unable_to_establish_sqlconnection -]); const pool = new Pool({ host: process.env.DATABASE_HOST || "172.17.0.1", port: parseInt(process.env.DATABASE_PORT || "5432", 10), @@ -33,28 +20,7 @@ const pool = new Pool({ pool.on("error", (err, client) => { logger.error({ err }, "Unexpected error on idle PostgreSQL client — evicted from pool"); }); -/** - * Determine if an error is transient (PgBouncer failover, network blip) - */ -export function isTransientError(err) { - if (!err) - return false; - const code = err.code || ""; - const msg = (err.message || "").toLowerCase(); - if (TRANSIENT_ERRORS.has(code)) - return true; - if (msg.includes("no available server")) - return true; // PgBouncer specific - if (msg.includes("connection terminated")) - return true; - if (msg.includes("connection refused")) - return true; - if (msg.includes("server closed the connection")) - return true; - if (msg.includes("timeout expired")) - return true; - return false; -} +export { isTransientError } from "../utils/errors.js"; /** * Execute a query with automatic retry on transient errors. * @@ -180,5 +146,36 @@ export async function initDatabase() { client.release(); } } +/** + * Clean up stale database entries: + * - Expired pending verifications + * - Unverified free-tier API keys (never completed verification) + * - Orphaned usage rows (key no longer exists) + */ +export async function cleanupStaleData() { + const results = { expiredVerifications: 0, staleKeys: 0, orphanedUsage: 0 }; + // 1. Delete expired pending verifications + const pv = await queryWithRetry("DELETE FROM pending_verifications WHERE expires_at < NOW() RETURNING email"); + results.expiredVerifications = pv.rowCount || 0; + // 2. Delete unverified free-tier keys (email not in verified verifications) + const sk = await queryWithRetry(` + DELETE FROM api_keys + WHERE tier = 'free' + AND email NOT IN ( + SELECT DISTINCT email FROM verifications WHERE verified_at IS NOT NULL + ) + RETURNING key + `); + results.staleKeys = sk.rowCount || 0; + // 3. Delete orphaned usage rows + const ou = await queryWithRetry(` + DELETE FROM usage + WHERE key NOT IN (SELECT key FROM api_keys) + RETURNING key + `); + results.orphanedUsage = ou.rowCount || 0; + logger.info({ ...results }, `Database cleanup complete: ${results.expiredVerifications} expired verifications, ${results.staleKeys} stale keys, ${results.orphanedUsage} orphaned usage rows removed`); + return results; +} export { pool }; export default pool; diff --git a/dist/services/email.js b/dist/services/email.js index ce66697..3dc4d46 100644 --- a/dist/services/email.js +++ b/dist/services/email.js @@ -25,7 +25,34 @@ export async function sendVerificationEmail(email, code) { from: smtpFrom, to: 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.\n\n---\nDocFast — HTML to PDF API\nhttps://docfast.dev`, + html: ` + + + +
    + + + + + + + +
    +

    DocFast

    +
    +

    Your verification code

    +
    +
    ${code}
    +
    +

    This code expires in 15 minutes.

    +
    +

    If you didn't request this, ignore this email.

    +
    +

    DocFast — HTML to PDF API
    docfast.dev

    +
    +
    +`, }); logger.info({ email, messageId: info.messageId }, "Verification email sent"); return true; diff --git a/dist/services/templates.js b/dist/services/templates.js index 585387e..c1376bd 100644 --- a/dist/services/templates.js +++ b/dist/services/templates.js @@ -35,7 +35,8 @@ function esc(s) { .replace(/&/g, "&") .replace(//g, ">") - .replace(/"/g, """); + .replace(/"/g, """) + .replace(/'/g, "'"); } function renderInvoice(d) { const cur = esc(d.currency || "€"); diff --git a/public/openapi.json b/public/openapi.json index 9e26dfe..aa1977b 100644 --- a/public/openapi.json +++ b/public/openapi.json @@ -1 +1,1225 @@ -{} \ No newline at end of file +{ + "openapi": "3.0.3", + "info": { + "title": "DocFast API", + "version": "1.0.0", + "description": "Convert HTML, Markdown, and URLs to pixel-perfect PDFs. Built-in invoice & receipt templates.\n\n## Authentication\nAll conversion and template endpoints require an API key via `Authorization: Bearer ` or `X-API-Key: ` header.\n\n## Demo Endpoints\nTry the API without signing up! Demo endpoints are public (no API key needed) but rate-limited to 5 requests/hour per IP and produce watermarked PDFs.\n\n## Rate Limits\n- Demo: 5 PDFs/hour per IP (watermarked)\n- Pro tier: 5,000 PDFs/month, 30 req/min\n\n## Getting Started\n1. Try the demo at `POST /v1/demo/html` — no signup needed\n2. Subscribe to Pro at [docfast.dev](https://docfast.dev/#pricing) for clean PDFs\n3. Use your API key to convert documents", + "contact": { + "name": "DocFast", + "url": "https://docfast.dev", + "email": "support@docfast.dev" + } + }, + "servers": [ + { + "url": "https://docfast.dev", + "description": "Production" + } + ], + "tags": [ + { + "name": "Demo", + "description": "Try the API without signing up — watermarked PDFs, rate-limited" + }, + { + "name": "Conversion", + "description": "Convert HTML, Markdown, or URLs to PDF (requires API key)" + }, + { + "name": "Templates", + "description": "Built-in document templates" + }, + { + "name": "Account", + "description": "Key recovery and email management" + }, + { + "name": "Billing", + "description": "Stripe-powered subscription management" + }, + { + "name": "System", + "description": "Health checks and usage stats" + } + ], + "components": { + "securitySchemes": { + "BearerAuth": { + "type": "http", + "scheme": "bearer", + "description": "API key as Bearer token" + }, + "ApiKeyHeader": { + "type": "apiKey", + "in": "header", + "name": "X-API-Key", + "description": "API key via X-API-Key header" + } + }, + "schemas": { + "PdfOptions": { + "type": "object", + "properties": { + "format": { + "type": "string", + "enum": [ + "A4", + "Letter", + "Legal", + "A3", + "A5", + "Tabloid" + ], + "default": "A4", + "description": "Page size" + }, + "landscape": { + "type": "boolean", + "default": false, + "description": "Landscape orientation" + }, + "margin": { + "type": "object", + "properties": { + "top": { + "type": "string", + "description": "Top margin (e.g. \"10mm\", \"1in\")", + "default": "0" + }, + "right": { + "type": "string", + "description": "Right margin", + "default": "0" + }, + "bottom": { + "type": "string", + "description": "Bottom margin", + "default": "0" + }, + "left": { + "type": "string", + "description": "Left margin", + "default": "0" + } + }, + "description": "Page margins" + }, + "printBackground": { + "type": "boolean", + "default": true, + "description": "Print background colors and images" + }, + "filename": { + "type": "string", + "description": "Custom filename for Content-Disposition header", + "default": "document.pdf" + } + } + }, + "Error": { + "type": "object", + "properties": { + "error": { + "type": "string", + "description": "Error message" + } + }, + "required": [ + "error" + ] + } + } + }, + "paths": { + "/v1/billing/checkout": { + "post": { + "tags": [ + "Billing" + ], + "summary": "Create a Stripe checkout session", + "description": "Creates a Stripe Checkout session for a Pro subscription (€9/month).\nReturns a URL to redirect the user to Stripe's hosted payment page.\nRate limited to 3 requests per hour per IP.\n", + "responses": { + "200": { + "description": "Checkout session created", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri", + "description": "Stripe Checkout URL to redirect the user to" + } + } + } + } + } + }, + "413": { + "description": "Request body too large" + }, + "429": { + "description": "Too many checkout requests" + }, + "500": { + "description": "Failed to create checkout session" + } + } + } + }, + "/v1/billing/success": { + "get": { + "tags": [ + "Billing" + ], + "summary": "Checkout success page", + "description": "Provisions a Pro API key after successful Stripe checkout and displays it in an HTML page.\nCalled by Stripe redirect after payment completion.\n", + "parameters": [ + { + "in": "query", + "name": "session_id", + "required": true, + "schema": { + "type": "string" + }, + "description": "Stripe Checkout session ID" + } + ], + "responses": { + "200": { + "description": "HTML page displaying the new API key", + "content": { + "text/html": { + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "Missing session_id or no customer found" + }, + "409": { + "description": "Checkout session already used" + }, + "500": { + "description": "Failed to retrieve session" + } + } + } + }, + "/v1/billing/webhook": { + "post": { + "tags": [ + "Billing" + ], + "summary": "Stripe webhook endpoint", + "description": "Receives Stripe webhook events for subscription lifecycle management.\nRequires the raw request body and a valid Stripe-Signature header for verification.\nHandles checkout.session.completed, customer.subscription.updated,\ncustomer.subscription.deleted, and customer.updated events.\n", + "parameters": [ + { + "in": "header", + "name": "Stripe-Signature", + "required": true, + "schema": { + "type": "string" + }, + "description": "Stripe webhook signature for payload verification" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Raw Stripe event payload" + } + } + } + }, + "responses": { + "200": { + "description": "Webhook received", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "received": { + "type": "boolean", + "example": true + } + } + } + } + } + }, + "400": { + "description": "Missing Stripe-Signature header or invalid signature" + }, + "500": { + "description": "Webhook secret not configured" + } + } + } + }, + "/v1/convert/html": { + "post": { + "tags": [ + "Conversion" + ], + "summary": "Convert HTML to PDF", + "description": "Converts HTML content to a PDF document. Bare HTML fragments are automatically wrapped in a full HTML document.", + "security": [ + { + "BearerAuth": [] + }, + { + "ApiKeyHeader": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "type": "object", + "required": [ + "html" + ], + "properties": { + "html": { + "type": "string", + "description": "HTML content to convert. Can be a full document or a fragment.", + "example": "

    Hello World

    My first PDF

    " + }, + "css": { + "type": "string", + "description": "Optional CSS to inject (only used when html is a fragment, not a full document)", + "example": "body { font-family: sans-serif; padding: 40px; }" + } + } + }, + { + "$ref": "#/components/schemas/PdfOptions" + } + ] + } + } + } + }, + "responses": { + "200": { + "description": "PDF document", + "content": { + "application/pdf": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "400": { + "description": "Missing html field" + }, + "401": { + "description": "Missing API key" + }, + "403": { + "description": "Invalid API key" + }, + "415": { + "description": "Unsupported Content-Type (must be application/json)" + }, + "429": { + "description": "Rate limit or usage limit exceeded" + }, + "500": { + "description": "PDF generation failed" + } + } + } + }, + "/v1/convert/markdown": { + "post": { + "tags": [ + "Conversion" + ], + "summary": "Convert Markdown to PDF", + "description": "Converts Markdown content to HTML and then to a PDF document.", + "security": [ + { + "BearerAuth": [] + }, + { + "ApiKeyHeader": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "type": "object", + "required": [ + "markdown" + ], + "properties": { + "markdown": { + "type": "string", + "description": "Markdown content to convert", + "example": "# Hello World\\n\\nThis is **bold** and *italic*." + }, + "css": { + "type": "string", + "description": "Optional CSS to inject into the rendered HTML" + } + } + }, + { + "$ref": "#/components/schemas/PdfOptions" + } + ] + } + } + } + }, + "responses": { + "200": { + "description": "PDF document", + "content": { + "application/pdf": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "400": { + "description": "Missing markdown field" + }, + "401": { + "description": "Missing API key" + }, + "403": { + "description": "Invalid API key" + }, + "415": { + "description": "Unsupported Content-Type" + }, + "429": { + "description": "Rate limit or usage limit exceeded" + }, + "500": { + "description": "PDF generation failed" + } + } + } + }, + "/v1/convert/url": { + "post": { + "tags": [ + "Conversion" + ], + "summary": "Convert URL to PDF", + "description": "Fetches a URL and converts the rendered page to PDF. JavaScript is disabled for security.\nPrivate/internal IP addresses are blocked (SSRF protection). DNS is pinned to prevent rebinding.\n", + "security": [ + { + "BearerAuth": [] + }, + { + "ApiKeyHeader": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "type": "object", + "required": [ + "url" + ], + "properties": { + "url": { + "type": "string", + "format": "uri", + "description": "URL to convert (http or https only)", + "example": "https://example.com" + }, + "waitUntil": { + "type": "string", + "enum": [ + "load", + "domcontentloaded", + "networkidle0", + "networkidle2" + ], + "default": "domcontentloaded", + "description": "When to consider navigation finished" + } + } + }, + { + "$ref": "#/components/schemas/PdfOptions" + } + ] + } + } + } + }, + "responses": { + "200": { + "description": "PDF document", + "content": { + "application/pdf": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "400": { + "description": "Missing/invalid URL or URL resolves to private IP" + }, + "401": { + "description": "Missing API key" + }, + "403": { + "description": "Invalid API key" + }, + "415": { + "description": "Unsupported Content-Type" + }, + "429": { + "description": "Rate limit or usage limit exceeded" + }, + "500": { + "description": "PDF generation failed" + } + } + } + }, + "/v1/demo/html": { + "post": { + "tags": [ + "Demo" + ], + "summary": "Convert HTML to PDF (demo)", + "description": "Public endpoint — no API key required. Rate limited to 5 requests per hour per IP.\nOutput PDFs include a DocFast watermark. Upgrade to Pro for clean output.\n", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "type": "object", + "required": [ + "html" + ], + "properties": { + "html": { + "type": "string", + "description": "HTML content to convert", + "example": "

    Hello World

    My first PDF

    " + }, + "css": { + "type": "string", + "description": "Optional CSS to inject (used when html is a fragment)" + } + } + }, + { + "$ref": "#/components/schemas/PdfOptions" + } + ] + } + } + } + }, + "responses": { + "200": { + "description": "Watermarked PDF document", + "content": { + "application/pdf": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "400": { + "description": "Missing html field", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "415": { + "description": "Unsupported Content-Type" + }, + "429": { + "description": "Demo rate limit exceeded (5/hour)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Server busy" + }, + "504": { + "description": "PDF generation timed out" + } + } + } + }, + "/v1/demo/markdown": { + "post": { + "tags": [ + "Demo" + ], + "summary": "Convert Markdown to PDF (demo)", + "description": "Public endpoint — no API key required. Rate limited to 5 requests per hour per IP.\nMarkdown is converted to HTML then rendered to PDF with a DocFast watermark.\n", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "type": "object", + "required": [ + "markdown" + ], + "properties": { + "markdown": { + "type": "string", + "description": "Markdown content to convert", + "example": "# Hello World\\n\\nThis is **bold** and *italic*." + }, + "css": { + "type": "string", + "description": "Optional CSS to inject" + } + } + }, + { + "$ref": "#/components/schemas/PdfOptions" + } + ] + } + } + } + }, + "responses": { + "200": { + "description": "Watermarked PDF document", + "content": { + "application/pdf": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "400": { + "description": "Missing markdown field" + }, + "415": { + "description": "Unsupported Content-Type" + }, + "429": { + "description": "Demo rate limit exceeded (5/hour)" + }, + "503": { + "description": "Server busy" + }, + "504": { + "description": "PDF generation timed out" + } + } + } + }, + "/health": { + "get": { + "tags": [ + "System" + ], + "summary": "Health check", + "description": "Returns service health status including database connectivity and browser pool stats.", + "responses": { + "200": { + "description": "Service is healthy", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "ok", + "degraded" + ] + }, + "version": { + "type": "string", + "example": "0.4.0" + }, + "database": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "ok", + "error" + ] + }, + "version": { + "type": "string", + "example": "PostgreSQL 17.4" + } + } + }, + "pool": { + "type": "object", + "properties": { + "size": { + "type": "integer" + }, + "active": { + "type": "integer" + }, + "available": { + "type": "integer" + }, + "queueDepth": { + "type": "integer" + }, + "pdfCount": { + "type": "integer" + }, + "restarting": { + "type": "boolean" + }, + "uptimeSeconds": { + "type": "integer" + } + } + } + } + } + } + } + }, + "503": { + "description": "Service is degraded (database issue)" + } + } + } + }, + "/v1/recover": { + "post": { + "tags": [ + "Account" + ], + "summary": "Request API key recovery", + "description": "Sends a 6-digit verification code to the email address if an account exists.\nResponse is always the same regardless of whether the email exists (to prevent enumeration).\nRate limited to 3 requests per hour.\n", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "email" + ], + "properties": { + "email": { + "type": "string", + "format": "email", + "description": "Email address associated with the API key" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Recovery code sent (or no-op if email not found)", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "recovery_sent" + }, + "message": { + "type": "string" + } + } + } + } + } + }, + "400": { + "description": "Invalid email format" + }, + "429": { + "description": "Too many recovery attempts" + } + } + } + }, + "/v1/recover/verify": { + "post": { + "tags": [ + "Account" + ], + "summary": "Verify recovery code and retrieve API key", + "description": "Verifies the 6-digit code sent via email and returns the API key if valid. Code expires after 15 minutes.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "email", + "code" + ], + "properties": { + "email": { + "type": "string", + "format": "email" + }, + "code": { + "type": "string", + "pattern": "^\\d{6}$", + "description": "6-digit verification code" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "API key recovered", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "recovered" + }, + "apiKey": { + "type": "string", + "description": "The recovered API key" + }, + "tier": { + "type": "string", + "enum": [ + "free", + "pro" + ] + } + } + } + } + } + }, + "400": { + "description": "Invalid verification code or missing fields" + }, + "410": { + "description": "Verification code expired" + }, + "429": { + "description": "Too many failed attempts" + } + } + } + }, + "/v1/signup/verify": { + "post": { + "tags": [ + "Account" + ], + "summary": "Verify email and get API key", + "description": "Verifies the 6-digit code sent to the user's email and provisions a free API key.\nRate limited to 15 attempts per 15 minutes.\n", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "email", + "code" + ], + "properties": { + "email": { + "type": "string", + "format": "email", + "description": "Email address used during signup", + "example": "user@example.com" + }, + "code": { + "type": "string", + "description": "6-digit verification code from email", + "example": "123456" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Email verified, API key issued", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "verified" + }, + "message": { + "type": "string" + }, + "apiKey": { + "type": "string", + "description": "The provisioned API key" + }, + "tier": { + "type": "string", + "example": "free" + } + } + } + } + } + }, + "400": { + "description": "Missing fields or invalid verification code" + }, + "409": { + "description": "Email already verified" + }, + "410": { + "description": "Verification code expired" + }, + "429": { + "description": "Too many failed attempts" + } + } + } + }, + "/v1/templates": { + "get": { + "tags": [ + "Templates" + ], + "summary": "List available templates", + "description": "Returns a list of all built-in document templates with their required fields.", + "security": [ + { + "BearerAuth": [] + }, + { + "ApiKeyHeader": [] + } + ], + "responses": { + "200": { + "description": "List of templates", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "templates": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "example": "invoice" + }, + "name": { + "type": "string", + "example": "Invoice" + }, + "description": { + "type": "string" + }, + "fields": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "required": { + "type": "boolean" + }, + "description": { + "type": "string" + } + } + } + } + } + } + } + } + } + } + } + }, + "401": { + "description": "Missing API key" + }, + "403": { + "description": "Invalid API key" + } + } + } + }, + "/v1/templates/{id}/render": { + "post": { + "tags": [ + "Templates" + ], + "summary": "Render a template to PDF", + "description": "Renders a built-in template with the provided data and returns a PDF.\nUse GET /v1/templates to see available templates and their required fields.\nSpecial fields: `_format` (page size), `_margin` (page margins), `_filename` (output filename).\n", + "security": [ + { + "BearerAuth": [] + }, + { + "ApiKeyHeader": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + }, + "description": "Template ID (e.g. \"invoice\", \"receipt\")" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "description": "Template data (fields depend on template). Can also be passed at root level." + }, + "_format": { + "type": "string", + "enum": [ + "A4", + "Letter", + "Legal", + "A3", + "A5", + "Tabloid" + ], + "default": "A4", + "description": "Page size override" + }, + "_margin": { + "type": "object", + "properties": { + "top": { + "type": "string" + }, + "right": { + "type": "string" + }, + "bottom": { + "type": "string" + }, + "left": { + "type": "string" + } + }, + "description": "Page margin override" + }, + "_filename": { + "type": "string", + "description": "Custom output filename" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "PDF document", + "content": { + "application/pdf": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "400": { + "description": "Missing required template fields" + }, + "401": { + "description": "Missing API key" + }, + "403": { + "description": "Invalid API key" + }, + "404": { + "description": "Template not found" + }, + "500": { + "description": "Template rendering failed" + } + } + } + }, + "/v1/signup/free": { + "post": { + "tags": [ + "Account" + ], + "summary": "Free signup (discontinued)", + "description": "Free accounts have been discontinued. Use the demo endpoint for testing\nor subscribe to Pro for production use.\n", + "deprecated": true, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "email": { + "type": "string", + "format": "email" + } + } + } + } + } + }, + "responses": { + "410": { + "description": "Free accounts discontinued", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "example": "Free accounts have been discontinued." + }, + "demo_endpoint": { + "type": "string", + "example": "/v1/demo/html" + }, + "pro_url": { + "type": "string", + "example": "https://docfast.dev/#pricing" + } + } + } + } + } + } + } + } + }, + "/v1/usage": { + "get": { + "tags": [ + "System" + ], + "summary": "Usage statistics (admin only)", + "description": "Returns usage statistics for the authenticated user. Requires admin API key.", + "security": [ + { + "BearerAuth": [] + }, + { + "ApiKeyHeader": [] + } + ], + "responses": { + "200": { + "description": "Usage statistics", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "month": { + "type": "string" + } + } + } + } + } + } + }, + "403": { + "description": "Admin access required" + }, + "503": { + "description": "Admin access not configured" + } + } + } + } + } +} \ No newline at end of file diff --git a/src/__tests__/app-routes.test.ts b/src/__tests__/app-routes.test.ts index bc670cd..27b7c96 100644 --- a/src/__tests__/app-routes.test.ts +++ b/src/__tests__/app-routes.test.ts @@ -92,6 +92,31 @@ describe("App-level routes", () => { }); }); + describe("OpenAPI spec completeness", () => { + let spec: any; + + beforeAll(async () => { + const res = await request(app).get("/openapi.json"); + expect(res.status).toBe(200); + spec = res.body; + }); + + it("includes POST /v1/signup/verify", () => { + expect(spec.paths["/v1/signup/verify"]).toBeDefined(); + expect(spec.paths["/v1/signup/verify"].post).toBeDefined(); + }); + + it("includes GET /v1/billing/success", () => { + expect(spec.paths["/v1/billing/success"]).toBeDefined(); + expect(spec.paths["/v1/billing/success"].get).toBeDefined(); + }); + + it("includes POST /v1/billing/webhook", () => { + expect(spec.paths["/v1/billing/webhook"]).toBeDefined(); + expect(spec.paths["/v1/billing/webhook"].post).toBeDefined(); + }); + }); + describe("Security headers", () => { it("includes helmet security headers", async () => { const res = await request(app).get("/api"); diff --git a/src/routes/billing.ts b/src/routes/billing.ts index 091689d..849de5c 100644 --- a/src/routes/billing.ts +++ b/src/routes/billing.ts @@ -112,6 +112,36 @@ router.post("/checkout", checkoutLimiter, async (req: Request, res: Response) => } }); +/** + * @openapi + * /v1/billing/success: + * get: + * tags: [Billing] + * summary: Checkout success page + * description: | + * Provisions a Pro API key after successful Stripe checkout and displays it in an HTML page. + * Called by Stripe redirect after payment completion. + * parameters: + * - in: query + * name: session_id + * required: true + * schema: + * type: string + * description: Stripe Checkout session ID + * responses: + * 200: + * description: HTML page displaying the new API key + * content: + * text/html: + * schema: + * type: string + * 400: + * description: Missing session_id or no customer found + * 409: + * description: Checkout session already used + * 500: + * description: Failed to retrieve session + */ // Success page — provision Pro API key after checkout router.get("/success", async (req: Request, res: Response) => { const sessionId = req.query.session_id as string; @@ -189,6 +219,47 @@ a { color: #4f9; } } }); +/** + * @openapi + * /v1/billing/webhook: + * post: + * tags: [Billing] + * summary: Stripe webhook endpoint + * description: | + * Receives Stripe webhook events for subscription lifecycle management. + * Requires the raw request body and a valid Stripe-Signature header for verification. + * Handles checkout.session.completed, customer.subscription.updated, + * customer.subscription.deleted, and customer.updated events. + * parameters: + * - in: header + * name: Stripe-Signature + * required: true + * schema: + * type: string + * description: Stripe webhook signature for payload verification + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * description: Raw Stripe event payload + * responses: + * 200: + * description: Webhook received + * content: + * application/json: + * schema: + * type: object + * properties: + * received: + * type: boolean + * example: true + * 400: + * description: Missing Stripe-Signature header or invalid signature + * 500: + * description: Webhook secret not configured + */ // Stripe webhook for subscription lifecycle events router.post("/webhook", async (req: Request, res: Response) => { const sig = req.headers["stripe-signature"] as string; diff --git a/src/routes/signup.ts b/src/routes/signup.ts index fd422eb..91a9ae6 100644 --- a/src/routes/signup.ts +++ b/src/routes/signup.ts @@ -63,6 +63,60 @@ router.post("/free", rejectDuplicateEmail, signupLimiter, async (req: Request, r }); }); +/** + * @openapi + * /v1/signup/verify: + * post: + * tags: [Account] + * summary: Verify email and get API key + * description: | + * Verifies the 6-digit code sent to the user's email and provisions a free API key. + * Rate limited to 15 attempts per 15 minutes. + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [email, code] + * properties: + * email: + * type: string + * format: email + * description: Email address used during signup + * example: user@example.com + * code: + * type: string + * description: 6-digit verification code from email + * example: "123456" + * responses: + * 200: + * description: Email verified, API key issued + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: string + * example: verified + * message: + * type: string + * apiKey: + * type: string + * description: The provisioned API key + * tier: + * type: string + * example: free + * 400: + * description: Missing fields or invalid verification code + * 409: + * description: Email already verified + * 410: + * description: Verification code expired + * 429: + * description: Too many failed attempts + */ // Step 2: Verify code — creates API key router.post("/verify", verifyLimiter, async (req: Request, res: Response) => { const { email, code } = req.body || {}; From 480c794a85b9eb6a8781928fea8bc0ac13adee4f Mon Sep 17 00:00:00 2001 From: OpenClaw Date: Fri, 27 Feb 2026 19:04:36 +0000 Subject: [PATCH 066/169] feat: add email change routes (BUG-090) --- src/__tests__/email-change.test.ts | 120 +++++++++++++++++ src/index.ts | 2 + src/routes/email-change.ts | 204 +++++++++++++++++++++++++++++ 3 files changed, 326 insertions(+) create mode 100644 src/__tests__/email-change.test.ts create mode 100644 src/routes/email-change.ts diff --git a/src/__tests__/email-change.test.ts b/src/__tests__/email-change.test.ts new file mode 100644 index 0000000..d07ad5d --- /dev/null +++ b/src/__tests__/email-change.test.ts @@ -0,0 +1,120 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import express from "express"; +import request from "supertest"; + +vi.mock("../services/verification.js"); +vi.mock("../services/email.js"); +vi.mock("../services/db.js"); +vi.mock("../services/logger.js", () => ({ + default: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() }, +})); + +let app: express.Express; + +beforeEach(async () => { + vi.clearAllMocks(); + vi.resetModules(); + + const { createPendingVerification, verifyCode } = await import("../services/verification.js"); + const { sendVerificationEmail } = await import("../services/email.js"); + const { queryWithRetry } = await import("../services/db.js"); + + vi.mocked(createPendingVerification).mockResolvedValue({ email: "new@example.com", code: "123456", createdAt: "", expiresAt: "", attempts: 0 }); + vi.mocked(verifyCode).mockResolvedValue({ status: "ok" }); + vi.mocked(sendVerificationEmail).mockResolvedValue(true); + // Default: apiKey exists, email not taken + vi.mocked(queryWithRetry).mockImplementation(async (sql: string, params?: any[]) => { + if (sql.includes("SELECT") && sql.includes("api_keys") && sql.includes("key =")) { + return { rows: [{ key: "df_pro_xxx", email: "old@example.com", tier: "pro" }], rowCount: 1 }; + } + if (sql.includes("SELECT") && sql.includes("api_keys") && sql.includes("email =")) { + return { rows: [], rowCount: 0 }; + } + if (sql.includes("UPDATE")) { + return { rows: [{ email: "new@example.com" }], rowCount: 1 }; + } + return { rows: [], rowCount: 0 }; + }); + + const { emailChangeRouter } = await import("../routes/email-change.js"); + app = express(); + app.use(express.json()); + app.use("/v1/email-change", emailChangeRouter); +}); + +describe("POST /v1/email-change", () => { + it("returns 400 for missing apiKey", async () => { + const res = await request(app).post("/v1/email-change").send({ newEmail: "new@example.com" }); + expect(res.status).toBe(400); + }); + + it("returns 400 for missing newEmail", async () => { + const res = await request(app).post("/v1/email-change").send({ apiKey: "df_pro_xxx" }); + expect(res.status).toBe(400); + }); + + it("returns 400 for invalid email format", async () => { + const res = await request(app).post("/v1/email-change").send({ apiKey: "df_pro_xxx", newEmail: "bad" }); + expect(res.status).toBe(400); + }); + + it("returns 403 for invalid API key", async () => { + const { queryWithRetry } = await import("../services/db.js"); + vi.mocked(queryWithRetry).mockImplementation(async (sql: string) => { + if (sql.includes("SELECT") && sql.includes("key =")) { + return { rows: [], rowCount: 0 }; + } + return { rows: [], rowCount: 0 }; + }); + const res = await request(app).post("/v1/email-change").send({ apiKey: "fake", newEmail: "new@example.com" }); + expect(res.status).toBe(403); + }); + + it("returns 409 when email already taken", async () => { + const { queryWithRetry } = await import("../services/db.js"); + vi.mocked(queryWithRetry).mockImplementation(async (sql: string) => { + if (sql.includes("SELECT") && sql.includes("key =")) { + return { rows: [{ key: "df_pro_xxx", email: "old@example.com" }], rowCount: 1 }; + } + if (sql.includes("SELECT") && sql.includes("email =")) { + return { rows: [{ key: "df_pro_other", email: "new@example.com" }], rowCount: 1 }; + } + return { rows: [], rowCount: 0 }; + }); + const res = await request(app).post("/v1/email-change").send({ apiKey: "df_pro_xxx", newEmail: "new@example.com" }); + expect(res.status).toBe(409); + }); + + it("returns 200 with verification_sent on success", async () => { + const res = await request(app).post("/v1/email-change").send({ apiKey: "df_pro_xxx", newEmail: "new@example.com" }); + expect(res.status).toBe(200); + expect(res.body.status).toBe("verification_sent"); + }); +}); + +describe("POST /v1/email-change/verify", () => { + it("returns 400 for missing fields", async () => { + const res = await request(app).post("/v1/email-change/verify").send({ apiKey: "df_pro_xxx" }); + expect(res.status).toBe(400); + }); + + it("returns 400 for invalid code", async () => { + const { verifyCode } = await import("../services/verification.js"); + vi.mocked(verifyCode).mockResolvedValue({ status: "invalid" }); + const res = await request(app).post("/v1/email-change/verify").send({ apiKey: "df_pro_xxx", newEmail: "new@example.com", code: "999999" }); + expect(res.status).toBe(400); + }); + + it("returns 200 and updates email on success", async () => { + const { queryWithRetry } = await import("../services/db.js"); + const res = await request(app).post("/v1/email-change/verify").send({ apiKey: "df_pro_xxx", newEmail: "new@example.com", code: "123456" }); + expect(res.status).toBe(200); + expect(res.body.status).toBe("ok"); + expect(res.body.newEmail).toBe("new@example.com"); + // Verify UPDATE was called + expect(queryWithRetry).toHaveBeenCalledWith( + expect.stringContaining("UPDATE"), + expect.arrayContaining(["new@example.com", "df_pro_xxx"]) + ); + }); +}); diff --git a/src/index.ts b/src/index.ts index a0499b3..7ba5b7e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,6 +15,7 @@ import { templatesRouter } from "./routes/templates.js"; import { healthRouter } from "./routes/health.js"; import { demoRouter } from "./routes/demo.js"; import { recoverRouter } from "./routes/recover.js"; +import { emailChangeRouter } from "./routes/email-change.js"; import { billingRouter } from "./routes/billing.js"; import { authMiddleware } from "./middleware/auth.js"; import { usageMiddleware, loadUsageData } from "./middleware/usage.js"; @@ -130,6 +131,7 @@ app.use("/v1/signup", (_req, res) => { }); }); app.use("/v1/recover", recoverRouter); +app.use("/v1/email-change", emailChangeRouter); app.use("/v1/billing", billingRouter); // Authenticated routes — conversion routes get tighter body limits (500KB) diff --git a/src/routes/email-change.ts b/src/routes/email-change.ts new file mode 100644 index 0000000..3041b15 --- /dev/null +++ b/src/routes/email-change.ts @@ -0,0 +1,204 @@ +import { Router, Request, Response } from "express"; +import rateLimit from "express-rate-limit"; +import { createPendingVerification, verifyCode } from "../services/verification.js"; +import { sendVerificationEmail } from "../services/email.js"; +import { queryWithRetry } from "../services/db.js"; +import logger from "../services/logger.js"; + +const router = Router(); + +const emailChangeLimiter = rateLimit({ + windowMs: 60 * 60 * 1000, + max: 3, + message: { error: "Too many email change attempts. Please try again in 1 hour." }, + standardHeaders: true, + legacyHeaders: false, + keyGenerator: (req: Request) => req.body?.apiKey || req.ip || "unknown", +}); + +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +async function validateApiKey(apiKey: string) { + const result = await queryWithRetry( + `SELECT key, email, tier FROM api_keys WHERE key = $1`, + [apiKey] + ); + return result.rows[0] || null; +} + +/** + * @openapi + * /v1/email-change: + * post: + * tags: [Account] + * summary: Request email change + * description: | + * Sends a 6-digit verification code to the new email address. + * Rate limited to 3 requests per hour per API key. + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [apiKey, newEmail] + * properties: + * apiKey: + * type: string + * newEmail: + * type: string + * format: email + * responses: + * 200: + * description: Verification code sent + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: string + * example: verification_sent + * message: + * type: string + * 400: + * description: Missing or invalid fields + * 403: + * description: Invalid API key + * 409: + * description: Email already taken + * 429: + * description: Too many attempts + */ +router.post("/", emailChangeLimiter, async (req: Request, res: Response) => { + const { apiKey, newEmail } = req.body || {}; + + if (!apiKey || typeof apiKey !== "string") { + res.status(400).json({ error: "apiKey is required." }); + return; + } + + if (!newEmail || typeof newEmail !== "string") { + res.status(400).json({ error: "newEmail is required." }); + return; + } + + const cleanEmail = newEmail.trim().toLowerCase(); + + if (!EMAIL_RE.test(cleanEmail)) { + res.status(400).json({ error: "Invalid email format." }); + return; + } + + const keyRow = await validateApiKey(apiKey); + if (!keyRow) { + res.status(403).json({ error: "Invalid API key." }); + return; + } + + // Check if email is already taken by another key + const existing = await queryWithRetry( + `SELECT key FROM api_keys WHERE email = $1 AND key != $2`, + [cleanEmail, apiKey] + ); + if (existing.rows.length > 0) { + res.status(409).json({ error: "This email is already associated with another account." }); + return; + } + + const pending = await createPendingVerification(cleanEmail); + + sendVerificationEmail(cleanEmail, pending.code).catch(err => { + logger.error({ err, email: cleanEmail }, "Failed to send email change verification"); + }); + + res.json({ status: "verification_sent", message: "A verification code has been sent to your new email address." }); +}); + +/** + * @openapi + * /v1/email-change/verify: + * post: + * tags: [Account] + * summary: Verify email change code + * description: Verifies the 6-digit code and updates the account email. + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [apiKey, newEmail, code] + * properties: + * apiKey: + * type: string + * newEmail: + * type: string + * format: email + * code: + * type: string + * pattern: '^\d{6}$' + * responses: + * 200: + * description: Email updated + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: string + * example: ok + * newEmail: + * type: string + * 400: + * description: Missing fields or invalid code + * 403: + * description: Invalid API key + * 410: + * description: Code expired + * 429: + * description: Too many failed attempts + */ +router.post("/verify", async (req: Request, res: Response) => { + const { apiKey, newEmail, code } = req.body || {}; + + if (!apiKey || !newEmail || !code) { + res.status(400).json({ error: "apiKey, newEmail, and code are required." }); + return; + } + + const cleanEmail = newEmail.trim().toLowerCase(); + const cleanCode = String(code).trim(); + + const keyRow = await validateApiKey(apiKey); + if (!keyRow) { + res.status(403).json({ error: "Invalid API key." }); + return; + } + + const result = await verifyCode(cleanEmail, cleanCode); + + switch (result.status) { + case "ok": { + await queryWithRetry( + `UPDATE api_keys SET email = $1 WHERE key = $2`, + [cleanEmail, apiKey] + ); + logger.info({ apiKey: apiKey.slice(0, 10) + "...", newEmail: cleanEmail }, "Email changed"); + res.json({ status: "ok", newEmail: cleanEmail }); + break; + } + case "expired": + res.status(410).json({ error: "Verification code has expired. Please request a new one." }); + break; + case "max_attempts": + res.status(429).json({ error: "Too many failed attempts. Please request a new code." }); + break; + case "invalid": + res.status(400).json({ error: "Invalid verification code." }); + break; + } +}); + +export { router as emailChangeRouter }; From 03f82a8d034789c995dc3e97068b81e1f98076d4 Mon Sep 17 00:00:00 2001 From: DocFast CEO Date: Sat, 28 Feb 2026 07:02:30 +0000 Subject: [PATCH 067/169] fix: update basic-ftp and rollup to resolve security vulnerabilities - basic-ftp: critical path traversal (GHSA-5rq4-664w-9x2c) - production dep via puppeteer - rollup: high path traversal (GHSA-mw96-cpmx-2vgc) - dev dep via vitest - npm audit now shows 0 vulnerabilities - All 291 tests pass --- package-lock.json | 212 +++++++++++++++++++++++----------------------- 1 file changed, 106 insertions(+), 106 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5ddfdda..30e5f96 100644 --- a/package-lock.json +++ b/package-lock.json @@ -675,9 +675,9 @@ "license": "MIT" }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", - "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", "cpu": [ "arm" ], @@ -689,9 +689,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", - "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", "cpu": [ "arm64" ], @@ -703,9 +703,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", - "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", "cpu": [ "arm64" ], @@ -717,9 +717,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", - "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", "cpu": [ "x64" ], @@ -731,9 +731,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", - "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", "cpu": [ "arm64" ], @@ -745,9 +745,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", - "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", "cpu": [ "x64" ], @@ -759,9 +759,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", - "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", "cpu": [ "arm" ], @@ -773,9 +773,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", - "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", "cpu": [ "arm" ], @@ -787,9 +787,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", - "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", "cpu": [ "arm64" ], @@ -801,9 +801,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", - "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", "cpu": [ "arm64" ], @@ -815,9 +815,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", - "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", "cpu": [ "loong64" ], @@ -829,9 +829,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", - "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", "cpu": [ "loong64" ], @@ -843,9 +843,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", - "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", "cpu": [ "ppc64" ], @@ -857,9 +857,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", - "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", "cpu": [ "ppc64" ], @@ -871,9 +871,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", - "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", "cpu": [ "riscv64" ], @@ -885,9 +885,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", - "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", "cpu": [ "riscv64" ], @@ -899,9 +899,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", - "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", "cpu": [ "s390x" ], @@ -913,9 +913,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", - "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", "cpu": [ "x64" ], @@ -927,9 +927,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", - "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", "cpu": [ "x64" ], @@ -941,9 +941,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", - "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", "cpu": [ "x64" ], @@ -955,9 +955,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", - "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", "cpu": [ "arm64" ], @@ -969,9 +969,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", - "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", "cpu": [ "arm64" ], @@ -983,9 +983,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", - "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", "cpu": [ "ia32" ], @@ -997,9 +997,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", - "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", "cpu": [ "x64" ], @@ -1011,9 +1011,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", - "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", "cpu": [ "x64" ], @@ -1593,9 +1593,9 @@ } }, "node_modules/basic-ftp": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.1.0.tgz", - "integrity": "sha512-RkaJzeJKDbaDWTIPiJwubyljaEPwpVWkm9Rt5h9Nd6h7tEXTJ3VB4qxdZBioV7JO5yLUaOKwz7vDOzlncUsegw==", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.2.0.tgz", + "integrity": "sha512-VoMINM2rqJwJgfdHq6RiUudKt2BV+FY5ZFezP/ypmwayk68+NzzAQy4XXLlqsGD4MCzq3DrmNFD/uUmBJuGoXw==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -3777,9 +3777,9 @@ } }, "node_modules/rollup": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", - "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", "dev": true, "license": "MIT", "dependencies": { @@ -3793,31 +3793,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.57.1", - "@rollup/rollup-android-arm64": "4.57.1", - "@rollup/rollup-darwin-arm64": "4.57.1", - "@rollup/rollup-darwin-x64": "4.57.1", - "@rollup/rollup-freebsd-arm64": "4.57.1", - "@rollup/rollup-freebsd-x64": "4.57.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", - "@rollup/rollup-linux-arm-musleabihf": "4.57.1", - "@rollup/rollup-linux-arm64-gnu": "4.57.1", - "@rollup/rollup-linux-arm64-musl": "4.57.1", - "@rollup/rollup-linux-loong64-gnu": "4.57.1", - "@rollup/rollup-linux-loong64-musl": "4.57.1", - "@rollup/rollup-linux-ppc64-gnu": "4.57.1", - "@rollup/rollup-linux-ppc64-musl": "4.57.1", - "@rollup/rollup-linux-riscv64-gnu": "4.57.1", - "@rollup/rollup-linux-riscv64-musl": "4.57.1", - "@rollup/rollup-linux-s390x-gnu": "4.57.1", - "@rollup/rollup-linux-x64-gnu": "4.57.1", - "@rollup/rollup-linux-x64-musl": "4.57.1", - "@rollup/rollup-openbsd-x64": "4.57.1", - "@rollup/rollup-openharmony-arm64": "4.57.1", - "@rollup/rollup-win32-arm64-msvc": "4.57.1", - "@rollup/rollup-win32-ia32-msvc": "4.57.1", - "@rollup/rollup-win32-x64-gnu": "4.57.1", - "@rollup/rollup-win32-x64-msvc": "4.57.1", + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" } }, From 0e03e39ec7fe0bfe888a3af31025d1e482d2440d Mon Sep 17 00:00:00 2001 From: DocFast CEO Date: Sat, 28 Feb 2026 11:09:59 +0100 Subject: [PATCH 068/169] docs: comprehensive README with all endpoints, options, and setup --- README.md | 149 ++++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 127 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 6cd4e54..4052ea8 100644 --- a/README.md +++ b/README.md @@ -1,38 +1,71 @@ # DocFast API -Fast, simple HTML/Markdown to PDF API with built-in invoice templates. +Fast, reliable HTML/Markdown/URL to PDF conversion API. EU-hosted, GDPR compliant. + +**Website:** https://docfast.dev +**Docs:** https://docfast.dev/docs +**Status:** https://docfast.dev/status + +## Features + +- **HTML → PDF** — Full documents or fragments with optional CSS +- **Markdown → PDF** — GitHub-flavored Markdown with syntax highlighting +- **URL → PDF** — Render any public webpage as PDF (SSRF-protected) +- **Invoice Templates** — Built-in professional invoice template +- **PDF Options** — Paper size, orientation, margins, headers/footers, page ranges, scaling ## Quick Start +### 1. Get an API Key + +Sign up at https://docfast.dev — free demo available, Pro plan at €9/month for 5,000 PDFs. + +### 2. Generate a PDF + ```bash -npm install -npm run build -API_KEYS=your-key-here npm start +curl -X POST https://docfast.dev/v1/convert/html \ + -H "Authorization: Bearer YOUR_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{"html": "

    Hello World

    Your first PDF.

    "}' \ + -o output.pdf ``` -## Endpoints +## API Endpoints ### Convert HTML to PDF + ```bash -curl -X POST http://localhost:3100/v1/convert/html \ +curl -X POST https://docfast.dev/v1/convert/html \ -H "Authorization: Bearer YOUR_KEY" \ -H "Content-Type: application/json" \ - -d '{"html": "

    Hello

    World

    "}' \ + -d '{"html": "

    Hello

    ", "format": "A4", "margin": {"top": "20mm"}}' \ -o output.pdf ``` ### Convert Markdown to PDF + ```bash -curl -X POST http://localhost:3100/v1/convert/markdown \ +curl -X POST https://docfast.dev/v1/convert/markdown \ -H "Authorization: Bearer YOUR_KEY" \ -H "Content-Type: application/json" \ - -d '{"markdown": "# Hello\n\nWorld"}' \ + -d '{"markdown": "# Hello\n\nWorld", "css": "body { font-family: sans-serif; }"}' \ + -o output.pdf +``` + +### Convert URL to PDF + +```bash +curl -X POST https://docfast.dev/v1/convert/url \ + -H "Authorization: Bearer YOUR_KEY" \ + -H "Content-Type: application/json" \ + -d '{"url": "https://example.com", "format": "A4", "landscape": true}' \ -o output.pdf ``` ### Invoice Template + ```bash -curl -X POST http://localhost:3100/v1/templates/invoice/render \ +curl -X POST https://docfast.dev/v1/templates/invoice/render \ -H "Authorization: Bearer YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ @@ -40,23 +73,95 @@ curl -X POST http://localhost:3100/v1/templates/invoice/render \ "date": "2026-02-14", "from": {"name": "Your Company", "email": "you@example.com"}, "to": {"name": "Client", "email": "client@example.com"}, - "items": [{"description": "Service", "quantity": 1, "unitPrice": 100, "taxRate": 20}] + "items": [{"description": "Consulting", "quantity": 10, "unitPrice": 150, "taxRate": 20}] }' \ -o invoice.pdf ``` -### Options -- `format`: Paper size (A4, Letter, Legal, etc.) -- `landscape`: true/false -- `margin`: `{top, right, bottom, left}` in CSS units -- `css`: Custom CSS (for markdown/html fragments) -- `filename`: Suggested filename in Content-Disposition header +### Demo (No Auth Required) -## Auth -Pass API key via `Authorization: Bearer `. Set `API_KEYS` env var (comma-separated for multiple keys). +Try the API without signing up: -## Docker ```bash -docker build -t docfast . -docker run -p 3100:3100 -e API_KEYS=your-key docfast +curl -X POST https://docfast.dev/v1/demo/html \ + -H "Content-Type: application/json" \ + -d '{"html": "

    Demo PDF

    No API key needed.

    "}' \ + -o demo.pdf ``` + +Demo PDFs include a watermark and are rate-limited. + +## PDF Options + +All conversion endpoints accept these options: + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `format` | string | `"A4"` | Paper size: A4, Letter, Legal, A3, etc. | +| `landscape` | boolean | `false` | Landscape orientation | +| `margin` | object | `{top:"0",right:"0",bottom:"0",left:"0"}` | Margins in CSS units (px, mm, in, cm) | +| `printBackground` | boolean | `true` | Include background colors/images | +| `filename` | string | `"document.pdf"` | Suggested filename in Content-Disposition | +| `css` | string | — | Custom CSS (for HTML fragments and Markdown) | +| `scale` | number | `1` | Scale (0.1–2.0) | +| `pageRanges` | string | — | Page ranges, e.g. `"1-3, 5"` | +| `width` | string | — | Custom page width (overrides format) | +| `height` | string | — | Custom page height (overrides format) | +| `headerTemplate` | string | — | HTML template for page header | +| `footerTemplate` | string | — | HTML template for page footer | +| `displayHeaderFooter` | boolean | `false` | Show header/footer | +| `preferCSSPageSize` | boolean | `false` | Use CSS `@page` size over format | + +## Authentication + +Pass your API key via either: +- `Authorization: Bearer ` header +- `X-API-Key: ` header + +## Development + +```bash +# Install dependencies +npm install + +# Run in development mode +npm run dev + +# Run tests +npm test + +# Build +npm run build + +# Start production server +npm start +``` + +### Environment Variables + +| Variable | Required | Description | +|----------|----------|-------------| +| `DATABASE_URL` | Yes | PostgreSQL connection string | +| `STRIPE_SECRET_KEY` | Yes | Stripe API key for billing | +| `STRIPE_WEBHOOK_SECRET` | Yes | Stripe webhook signature secret | +| `SMTP_HOST` | Yes | SMTP server hostname | +| `SMTP_PORT` | Yes | SMTP server port | +| `SMTP_USER` | Yes | SMTP username | +| `SMTP_PASS` | Yes | SMTP password | +| `BASE_URL` | No | Base URL (default: https://docfast.dev) | +| `PORT` | No | Server port (default: 3100) | +| `BROWSER_COUNT` | No | Puppeteer browser instances (default: 2) | +| `PAGES_PER_BROWSER` | No | Pages per browser (default: 8) | +| `LOG_LEVEL` | No | Pino log level (default: info) | + +### Architecture + +- **Runtime:** Node.js + Express +- **PDF Engine:** Puppeteer (Chromium) with browser pool +- **Database:** PostgreSQL (via pg) +- **Payments:** Stripe +- **Email:** SMTP (nodemailer) + +## License + +Proprietary — Cloonar Technologies GmbH From f89a3181f7f135d433cc81f12d2c170b99cb7692 Mon Sep 17 00:00:00 2001 From: OpenClaw Date: Sat, 28 Feb 2026 14:05:32 +0100 Subject: [PATCH 069/169] feat: validate PDF options with TDD tests --- src/__tests__/convert.test.ts | 63 ++++++++++++ src/__tests__/pdf-options.test.ts | 162 ++++++++++++++++++++++++++++++ src/routes/convert.ts | 22 ++++ src/utils/pdf-options.ts | 88 ++++++++++++++++ 4 files changed, 335 insertions(+) create mode 100644 src/__tests__/pdf-options.test.ts create mode 100644 src/utils/pdf-options.ts diff --git a/src/__tests__/convert.test.ts b/src/__tests__/convert.test.ts index 5cc7c1c..507107b 100644 --- a/src/__tests__/convert.test.ts +++ b/src/__tests__/convert.test.ts @@ -182,3 +182,66 @@ describe("POST /v1/convert/url", () => { expect(res.headers["content-type"]).toMatch(/application\/pdf/); }); }); + +describe("PDF option validation (all endpoints)", () => { + const endpoints = [ + { path: "/v1/convert/html", body: { html: "

    Hi

    " } }, + { path: "/v1/convert/markdown", body: { markdown: "# Hi" } }, + ]; + + for (const { path, body } of endpoints) { + it(`${path} returns 400 for invalid scale`, async () => { + const res = await request(app) + .post(path) + .set("content-type", "application/json") + .send({ ...body, scale: 5 }); + expect(res.status).toBe(400); + expect(res.body.error).toContain("scale"); + }); + + it(`${path} returns 400 for invalid format`, async () => { + const res = await request(app) + .post(path) + .set("content-type", "application/json") + .send({ ...body, format: "B5" }); + expect(res.status).toBe(400); + expect(res.body.error).toContain("format"); + }); + + it(`${path} returns 400 for non-boolean landscape`, async () => { + const res = await request(app) + .post(path) + .set("content-type", "application/json") + .send({ ...body, landscape: "yes" }); + expect(res.status).toBe(400); + expect(res.body.error).toContain("landscape"); + }); + + it(`${path} returns 400 for invalid pageRanges`, async () => { + const res = await request(app) + .post(path) + .set("content-type", "application/json") + .send({ ...body, pageRanges: "abc" }); + expect(res.status).toBe(400); + expect(res.body.error).toContain("pageRanges"); + }); + + it(`${path} returns 400 for invalid margin`, async () => { + const res = await request(app) + .post(path) + .set("content-type", "application/json") + .send({ ...body, margin: "1cm" }); + expect(res.status).toBe(400); + expect(res.body.error).toContain("margin"); + }); + } + + it("/v1/convert/url returns 400 for invalid scale", async () => { + const res = await request(app) + .post("/v1/convert/url") + .set("content-type", "application/json") + .send({ url: "https://example.com", scale: 5 }); + expect(res.status).toBe(400); + expect(res.body.error).toContain("scale"); + }); +}); diff --git a/src/__tests__/pdf-options.test.ts b/src/__tests__/pdf-options.test.ts new file mode 100644 index 0000000..224886f --- /dev/null +++ b/src/__tests__/pdf-options.test.ts @@ -0,0 +1,162 @@ +import { describe, it, expect } from "vitest"; +import { validatePdfOptions } from "../utils/pdf-options.js"; + +describe("validatePdfOptions", () => { + // --- Happy path --- + it("accepts empty options", () => { + const result = validatePdfOptions({}); + expect(result.valid).toBe(true); + }); + + it("accepts undefined", () => { + const result = validatePdfOptions(undefined as any); + expect(result.valid).toBe(true); + }); + + it("accepts all valid options together", () => { + const result = validatePdfOptions({ + scale: 1.5, + format: "A4", + landscape: true, + printBackground: false, + displayHeaderFooter: true, + preferCSSPageSize: false, + width: "210mm", + height: "297mm", + margin: { top: "1cm", right: "1cm", bottom: "1cm", left: "1cm" }, + pageRanges: "1-5", + }); + expect(result.valid).toBe(true); + if (result.valid) { + expect(result.sanitized.scale).toBe(1.5); + expect(result.sanitized.format).toBe("A4"); + } + }); + + // --- scale --- + describe("scale", () => { + it("accepts 0.1", () => { + expect(validatePdfOptions({ scale: 0.1 }).valid).toBe(true); + }); + it("accepts 2.0", () => { + expect(validatePdfOptions({ scale: 2.0 }).valid).toBe(true); + }); + it("rejects 0.05", () => { + const r = validatePdfOptions({ scale: 0.05 }); + expect(r.valid).toBe(false); + if (!r.valid) expect(r.error).toContain("scale"); + }); + it("rejects 2.5", () => { + expect(validatePdfOptions({ scale: 2.5 }).valid).toBe(false); + }); + it("rejects non-number", () => { + expect(validatePdfOptions({ scale: "big" as any }).valid).toBe(false); + }); + }); + + // --- format --- + describe("format", () => { + const validFormats = ["Letter", "Legal", "Tabloid", "Ledger", "A0", "A1", "A2", "A3", "A4", "A5", "A6"]; + for (const f of validFormats) { + it(`accepts ${f}`, () => { + expect(validatePdfOptions({ format: f }).valid).toBe(true); + }); + } + it("accepts case-insensitive (a4)", () => { + const r = validatePdfOptions({ format: "a4" }); + expect(r.valid).toBe(true); + if (r.valid) expect(r.sanitized.format).toBe("A4"); + }); + it("accepts case-insensitive (letter)", () => { + const r = validatePdfOptions({ format: "letter" }); + expect(r.valid).toBe(true); + if (r.valid) expect(r.sanitized.format).toBe("Letter"); + }); + it("rejects invalid format", () => { + const r = validatePdfOptions({ format: "B5" }); + expect(r.valid).toBe(false); + if (!r.valid) expect(r.error).toContain("format"); + }); + }); + + // --- booleans --- + for (const field of ["landscape", "printBackground", "displayHeaderFooter", "preferCSSPageSize"] as const) { + describe(field, () => { + it("accepts true", () => { + expect(validatePdfOptions({ [field]: true }).valid).toBe(true); + }); + it("accepts false", () => { + expect(validatePdfOptions({ [field]: false }).valid).toBe(true); + }); + it("rejects string", () => { + const r = validatePdfOptions({ [field]: "yes" as any }); + expect(r.valid).toBe(false); + if (!r.valid) expect(r.error).toContain(field); + }); + it("rejects number", () => { + expect(validatePdfOptions({ [field]: 1 as any }).valid).toBe(false); + }); + }); + } + + // --- width/height --- + for (const field of ["width", "height"] as const) { + describe(field, () => { + it("accepts string", () => { + expect(validatePdfOptions({ [field]: "210mm" }).valid).toBe(true); + }); + it("rejects number", () => { + expect(validatePdfOptions({ [field]: 210 as any }).valid).toBe(false); + const r = validatePdfOptions({ [field]: 210 as any }); + if (!r.valid) expect(r.error).toContain(field); + }); + }); + } + + // --- margin --- + describe("margin", () => { + it("accepts valid margin object", () => { + expect(validatePdfOptions({ margin: { top: "1cm", bottom: "2cm" } }).valid).toBe(true); + }); + it("accepts empty margin object", () => { + expect(validatePdfOptions({ margin: {} }).valid).toBe(true); + }); + it("rejects non-object margin", () => { + expect(validatePdfOptions({ margin: "1cm" as any }).valid).toBe(false); + }); + it("rejects margin with non-string values", () => { + expect(validatePdfOptions({ margin: { top: 10 } as any }).valid).toBe(false); + }); + it("rejects margin with unknown keys", () => { + expect(validatePdfOptions({ margin: { top: "1cm", padding: "2cm" } as any }).valid).toBe(false); + }); + }); + + // --- pageRanges --- + describe("pageRanges", () => { + it("accepts '1-5'", () => { + expect(validatePdfOptions({ pageRanges: "1-5" }).valid).toBe(true); + }); + it("accepts '1,3,5'", () => { + expect(validatePdfOptions({ pageRanges: "1,3,5" }).valid).toBe(true); + }); + it("accepts '2-'", () => { + expect(validatePdfOptions({ pageRanges: "2-" }).valid).toBe(true); + }); + it("accepts '1-3,5,7-9'", () => { + expect(validatePdfOptions({ pageRanges: "1-3,5,7-9" }).valid).toBe(true); + }); + it("accepts single page '3'", () => { + expect(validatePdfOptions({ pageRanges: "3" }).valid).toBe(true); + }); + it("rejects non-string", () => { + expect(validatePdfOptions({ pageRanges: 5 as any }).valid).toBe(false); + }); + it("rejects invalid pattern", () => { + expect(validatePdfOptions({ pageRanges: "abc" }).valid).toBe(false); + }); + it("rejects 'all'", () => { + expect(validatePdfOptions({ pageRanges: "all" }).valid).toBe(false); + }); + }); +}); diff --git a/src/routes/convert.ts b/src/routes/convert.ts index bdfb3eb..8e46885 100644 --- a/src/routes/convert.ts +++ b/src/routes/convert.ts @@ -6,6 +6,7 @@ import logger from "../services/logger.js"; import { isPrivateIP } from "../utils/network.js"; import { sanitizeFilename } from "../utils/sanitize.js"; +import { validatePdfOptions } from "../utils/pdf-options.js"; export const convertRouter = Router(); @@ -94,6 +95,13 @@ convertRouter.post("/html", async (req: Request & { acquirePdfSlot?: () => Promi return; } + // Validate PDF options + const validation = validatePdfOptions(body); + if (!validation.valid) { + res.status(400).json({ error: validation.error }); + return; + } + // Acquire concurrency slot if (req.acquirePdfSlot) { await req.acquirePdfSlot(); @@ -203,6 +211,13 @@ convertRouter.post("/markdown", async (req: Request & { acquirePdfSlot?: () => P return; } + // Validate PDF options + const validation = validatePdfOptions(body); + if (!validation.valid) { + res.status(400).json({ error: validation.error }); + return; + } + // Acquire concurrency slot if (req.acquirePdfSlot) { await req.acquirePdfSlot(); @@ -339,6 +354,13 @@ convertRouter.post("/url", async (req: Request & { acquirePdfSlot?: () => Promis return; } + // Validate PDF options + const validation = validatePdfOptions(body); + if (!validation.valid) { + res.status(400).json({ error: validation.error }); + return; + } + // Acquire concurrency slot if (req.acquirePdfSlot) { await req.acquirePdfSlot(); diff --git a/src/utils/pdf-options.ts b/src/utils/pdf-options.ts new file mode 100644 index 0000000..ec7fd05 --- /dev/null +++ b/src/utils/pdf-options.ts @@ -0,0 +1,88 @@ +const VALID_FORMATS = ["Letter", "Legal", "Tabloid", "Ledger", "A0", "A1", "A2", "A3", "A4", "A5", "A6"]; +const FORMAT_MAP = new Map(VALID_FORMATS.map(f => [f.toLowerCase(), f])); +const PAGE_RANGES_RE = /^\d+(-\d*)?(\s*,\s*\d+(-\d*)?)*$/; +const MARGIN_KEYS = new Set(["top", "right", "bottom", "left"]); + +type PdfInput = Record; +type ValidResult = { valid: true; sanitized: Record }; +type InvalidResult = { valid: false; error: string }; + +export function validatePdfOptions(opts: PdfInput): ValidResult | InvalidResult { + if (!opts || typeof opts !== "object") return { valid: true, sanitized: {} }; + + const sanitized: Record = {}; + + // scale + if (opts.scale !== undefined) { + if (typeof opts.scale !== "number" || opts.scale < 0.1 || opts.scale > 2.0) { + return { valid: false, error: "scale must be a number between 0.1 and 2.0" }; + } + sanitized.scale = opts.scale; + } + + // format + if (opts.format !== undefined) { + if (typeof opts.format !== "string") { + return { valid: false, error: "format must be a string" }; + } + const canonical = FORMAT_MAP.get(opts.format.toLowerCase()); + if (!canonical) { + return { valid: false, error: `format must be one of: ${VALID_FORMATS.join(", ")}` }; + } + sanitized.format = canonical; + } + + // booleans + for (const field of ["landscape", "printBackground", "displayHeaderFooter", "preferCSSPageSize"]) { + if (opts[field] !== undefined) { + if (typeof opts[field] !== "boolean") { + return { valid: false, error: `${field} must be a boolean` }; + } + sanitized[field] = opts[field]; + } + } + + // width/height + for (const field of ["width", "height"]) { + if (opts[field] !== undefined) { + if (typeof opts[field] !== "string") { + return { valid: false, error: `${field} must be a string (CSS dimension)` }; + } + sanitized[field] = opts[field]; + } + } + + // margin + if (opts.margin !== undefined) { + if (typeof opts.margin !== "object" || opts.margin === null || Array.isArray(opts.margin)) { + return { valid: false, error: "margin must be an object with top/right/bottom/left string fields" }; + } + for (const key of Object.keys(opts.margin)) { + if (!MARGIN_KEYS.has(key)) { + return { valid: false, error: `margin contains unknown key: ${key}` }; + } + if (typeof opts.margin[key] !== "string") { + return { valid: false, error: `margin.${key} must be a string` }; + } + } + sanitized.margin = { ...opts.margin }; + } + + // pageRanges + if (opts.pageRanges !== undefined) { + if (typeof opts.pageRanges !== "string") { + return { valid: false, error: "pageRanges must be a string" }; + } + if (!PAGE_RANGES_RE.test(opts.pageRanges.trim())) { + return { valid: false, error: "pageRanges must match pattern like '1-5', '1,3,5', or '2-'" }; + } + sanitized.pageRanges = opts.pageRanges; + } + + // Pass through non-validated fields + for (const key of ["headerTemplate", "footerTemplate"]) { + if (opts[key] !== undefined) sanitized[key] = opts[key]; + } + + return { valid: true, sanitized }; +} From 597be6bcae78bf87ff72a74660d8c19e7c57693c Mon Sep 17 00:00:00 2001 From: DocFast CEO Date: Sat, 28 Feb 2026 17:05:47 +0100 Subject: [PATCH 070/169] fix: resolve TypeScript errors in email-change tests (broken Docker build) --- src/__tests__/email-change.test.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/__tests__/email-change.test.ts b/src/__tests__/email-change.test.ts index d07ad5d..48b0377 100644 --- a/src/__tests__/email-change.test.ts +++ b/src/__tests__/email-change.test.ts @@ -23,7 +23,7 @@ beforeEach(async () => { vi.mocked(verifyCode).mockResolvedValue({ status: "ok" }); vi.mocked(sendVerificationEmail).mockResolvedValue(true); // Default: apiKey exists, email not taken - vi.mocked(queryWithRetry).mockImplementation(async (sql: string, params?: any[]) => { + vi.mocked(queryWithRetry).mockImplementation((async (sql: string, params?: any[]) => { if (sql.includes("SELECT") && sql.includes("api_keys") && sql.includes("key =")) { return { rows: [{ key: "df_pro_xxx", email: "old@example.com", tier: "pro" }], rowCount: 1 }; } @@ -34,7 +34,7 @@ beforeEach(async () => { return { rows: [{ email: "new@example.com" }], rowCount: 1 }; } return { rows: [], rowCount: 0 }; - }); + }) as any); const { emailChangeRouter } = await import("../routes/email-change.js"); app = express(); @@ -60,19 +60,19 @@ describe("POST /v1/email-change", () => { it("returns 403 for invalid API key", async () => { const { queryWithRetry } = await import("../services/db.js"); - vi.mocked(queryWithRetry).mockImplementation(async (sql: string) => { + vi.mocked(queryWithRetry).mockImplementation((async (sql: string) => { if (sql.includes("SELECT") && sql.includes("key =")) { return { rows: [], rowCount: 0 }; } return { rows: [], rowCount: 0 }; - }); + }) as any); const res = await request(app).post("/v1/email-change").send({ apiKey: "fake", newEmail: "new@example.com" }); expect(res.status).toBe(403); }); it("returns 409 when email already taken", async () => { const { queryWithRetry } = await import("../services/db.js"); - vi.mocked(queryWithRetry).mockImplementation(async (sql: string) => { + vi.mocked(queryWithRetry).mockImplementation((async (sql: string) => { if (sql.includes("SELECT") && sql.includes("key =")) { return { rows: [{ key: "df_pro_xxx", email: "old@example.com" }], rowCount: 1 }; } @@ -80,7 +80,7 @@ describe("POST /v1/email-change", () => { return { rows: [{ key: "df_pro_other", email: "new@example.com" }], rowCount: 1 }; } return { rows: [], rowCount: 0 }; - }); + }) as any); const res = await request(app).post("/v1/email-change").send({ apiKey: "df_pro_xxx", newEmail: "new@example.com" }); expect(res.status).toBe(409); }); From a91b4c53a95e4d60bb1edaddadf7fb5aa75c9166 Mon Sep 17 00:00:00 2001 From: Hoid Date: Sat, 28 Feb 2026 20:03:14 +0100 Subject: [PATCH 071/169] test: add comprehensive tests for isTransientError utility --- src/__tests__/errors.test.ts | 199 +++++++++++++++++++++++++++++++++++ 1 file changed, 199 insertions(+) create mode 100644 src/__tests__/errors.test.ts diff --git a/src/__tests__/errors.test.ts b/src/__tests__/errors.test.ts new file mode 100644 index 0000000..7d42e79 --- /dev/null +++ b/src/__tests__/errors.test.ts @@ -0,0 +1,199 @@ +import { describe, it, expect } from "vitest"; +import { isTransientError } from "../utils/errors.js"; + +describe("isTransientError", () => { + describe("null/undefined/empty input", () => { + it("returns false for null", () => { + expect(isTransientError(null)).toBe(false); + }); + + it("returns false for undefined", () => { + expect(isTransientError(undefined)).toBe(false); + }); + + it("returns false for empty object", () => { + expect(isTransientError({})).toBe(false); + }); + }); + + describe("error codes from TRANSIENT_ERRORS set", () => { + it("returns true for ECONNRESET", () => { + expect(isTransientError({ code: "ECONNRESET" })).toBe(true); + }); + + it("returns true for ECONNREFUSED", () => { + expect(isTransientError({ code: "ECONNREFUSED" })).toBe(true); + }); + + it("returns true for EPIPE", () => { + expect(isTransientError({ code: "EPIPE" })).toBe(true); + }); + + it("returns true for ETIMEDOUT", () => { + expect(isTransientError({ code: "ETIMEDOUT" })).toBe(true); + }); + + it("returns true for CONNECTION_LOST", () => { + expect(isTransientError({ code: "CONNECTION_LOST" })).toBe(true); + }); + + it("returns true for 57P01 (admin_shutdown)", () => { + expect(isTransientError({ code: "57P01" })).toBe(true); + }); + + it("returns true for 57P02 (crash_shutdown)", () => { + expect(isTransientError({ code: "57P02" })).toBe(true); + }); + + it("returns true for 57P03 (cannot_connect_now)", () => { + expect(isTransientError({ code: "57P03" })).toBe(true); + }); + + it("returns true for 08006 (connection_failure)", () => { + expect(isTransientError({ code: "08006" })).toBe(true); + }); + + it("returns true for 08003 (connection_does_not_exist)", () => { + expect(isTransientError({ code: "08003" })).toBe(true); + }); + + it("returns true for 08001 (sqlclient_unable_to_establish_sqlconnection)", () => { + expect(isTransientError({ code: "08001" })).toBe(true); + }); + }); + + describe("message substring matching", () => { + it("returns true for 'no available server'", () => { + expect(isTransientError({ message: "no available server" })).toBe(true); + }); + + it("returns true for 'connection terminated'", () => { + expect(isTransientError({ message: "connection terminated unexpectedly" })).toBe(true); + }); + + it("returns true for 'connection refused'", () => { + expect(isTransientError({ message: "connection refused by server" })).toBe(true); + }); + + it("returns true for 'server closed the connection'", () => { + expect(isTransientError({ message: "server closed the connection unexpectedly" })).toBe(true); + }); + + it("returns true for 'timeout expired'", () => { + expect(isTransientError({ message: "timeout expired waiting for connection" })).toBe(true); + }); + }); + + describe("case-insensitive message matching", () => { + it("returns true for 'No Available Server' (mixed case)", () => { + expect(isTransientError({ message: "No Available Server" })).toBe(true); + }); + + it("returns true for 'CONNECTION TERMINATED' (uppercase)", () => { + expect(isTransientError({ message: "CONNECTION TERMINATED" })).toBe(true); + }); + + it("returns true for 'Connection Refused' (title case)", () => { + expect(isTransientError({ message: "Connection Refused" })).toBe(true); + }); + + it("returns true for 'SERVER CLOSED THE CONNECTION' (uppercase)", () => { + expect(isTransientError({ message: "SERVER CLOSED THE CONNECTION" })).toBe(true); + }); + + it("returns true for 'Timeout Expired' (title case)", () => { + expect(isTransientError({ message: "Timeout Expired" })).toBe(true); + }); + }); + + describe("non-transient errors", () => { + it("returns false for syntax error", () => { + expect(isTransientError({ + code: "42601", + message: "syntax error at or near SELECT" + })).toBe(false); + }); + + it("returns false for unique constraint violation", () => { + expect(isTransientError({ + code: "23505", + message: "duplicate key value violates unique constraint" + })).toBe(false); + }); + + it("returns false for foreign key violation", () => { + expect(isTransientError({ + code: "23503", + message: "foreign key constraint violation" + })).toBe(false); + }); + + it("returns false for not null violation", () => { + expect(isTransientError({ + code: "23502", + message: "null value in column violates not-null constraint" + })).toBe(false); + }); + + it("returns false for permission denied", () => { + expect(isTransientError({ + code: "42501", + message: "permission denied for table users" + })).toBe(false); + }); + }); + + describe("unrelated codes and messages", () => { + it("returns false for unrelated error code", () => { + expect(isTransientError({ code: "UNKNOWN_ERROR" })).toBe(false); + }); + + it("returns false for unrelated error message", () => { + expect(isTransientError({ message: "Something went wrong" })).toBe(false); + }); + + it("returns false for generic database error", () => { + expect(isTransientError({ + code: "P0001", + message: "Database operation failed" + })).toBe(false); + }); + + it("returns false for application error", () => { + expect(isTransientError({ + message: "Invalid user input" + })).toBe(false); + }); + }); + + describe("edge cases", () => { + it("returns true when both code and message match", () => { + expect(isTransientError({ + code: "ECONNRESET", + message: "connection terminated" + })).toBe(true); + }); + + it("returns true when only code matches", () => { + expect(isTransientError({ + code: "ETIMEDOUT", + message: "some other message" + })).toBe(true); + }); + + it("returns true when only message matches", () => { + expect(isTransientError({ + code: "SOME_CODE", + message: "no available server to connect" + })).toBe(true); + }); + + it("returns false for error with only unrelated code", () => { + expect(isTransientError({ code: "NOTFOUND" })).toBe(false); + }); + + it("returns false for error with empty message", () => { + expect(isTransientError({ message: "" })).toBe(false); + }); + }); +}); From ecc7b9640c5149bdee12636e06e64cae32982dea Mon Sep 17 00:00:00 2001 From: Hoid Date: Sun, 1 Mar 2026 08:06:55 +0100 Subject: [PATCH 072/169] feat: add PDF options validation to demo route (TDD) --- src/__tests__/demo.test.ts | 54 ++++++++++++++++++++++++++++++++++++++ src/routes/demo.ts | 18 ++++++++++--- 2 files changed, 68 insertions(+), 4 deletions(-) diff --git a/src/__tests__/demo.test.ts b/src/__tests__/demo.test.ts index e613bfe..295f90c 100644 --- a/src/__tests__/demo.test.ts +++ b/src/__tests__/demo.test.ts @@ -83,6 +83,42 @@ describe("POST /v1/demo/html", () => { expect(calledHtml).toContain("DEMO"); expect(calledHtml).toContain("docfast.dev"); }); + + it("returns 400 for invalid scale", async () => { + const res = await request(app) + .post("/v1/demo/html") + .set("content-type", "application/json") + .send({ html: "

    Hello

    ", scale: 99 }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/scale/); + }); + + it("returns 400 for invalid format", async () => { + const res = await request(app) + .post("/v1/demo/html") + .set("content-type", "application/json") + .send({ html: "

    Hello

    ", format: "INVALID" }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/format/); + }); + + it("returns 400 for non-boolean landscape", async () => { + const res = await request(app) + .post("/v1/demo/html") + .set("content-type", "application/json") + .send({ html: "

    Hello

    ", landscape: "yes" }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/landscape/); + }); + + it("returns 400 for invalid margin", async () => { + const res = await request(app) + .post("/v1/demo/html") + .set("content-type", "application/json") + .send({ html: "

    Hello

    ", margin: "10px" }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/margin/); + }); }); describe("POST /v1/demo/markdown", () => { @@ -145,4 +181,22 @@ describe("POST /v1/demo/markdown", () => { expect(calledHtml).toContain("DEMO"); expect(calledHtml).toContain("docfast.dev"); }); + + it("returns 400 for invalid scale", async () => { + const res = await request(app) + .post("/v1/demo/markdown") + .set("content-type", "application/json") + .send({ markdown: "# Hello", scale: 99 }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/scale/); + }); + + it("returns 400 for invalid format", async () => { + const res = await request(app) + .post("/v1/demo/markdown") + .set("content-type", "application/json") + .send({ markdown: "# Hello", format: "INVALID" }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/format/); + }); }); diff --git a/src/routes/demo.ts b/src/routes/demo.ts index fb8a094..8e5a28f 100644 --- a/src/routes/demo.ts +++ b/src/routes/demo.ts @@ -3,6 +3,8 @@ import rateLimit from "express-rate-limit"; import { renderPdf } from "../services/browser.js"; import { markdownToHtml, wrapHtml } from "../services/markdown.js"; import logger from "../services/logger.js"; +import { sanitizeFilename } from "../utils/sanitize.js"; +import { validatePdfOptions } from "../utils/pdf-options.js"; const router = Router(); @@ -42,10 +44,6 @@ interface DemoBody { filename?: string; } -function sanitizeFilename(name: string): string { - return name.replace(/[\x00-\x1f"\\\r\n]/g, "").trim() || "document.pdf"; -} - /** * @openapi * /v1/demo/html: @@ -114,6 +112,12 @@ router.post("/html", async (req: Request & { acquirePdfSlot?: () => Promise Promise< return; } + const validation = validatePdfOptions(body); + if (!validation.valid) { + res.status(400).json({ error: validation.error }); + return; + } + if (req.acquirePdfSlot) { await req.acquirePdfSlot(); slotAcquired = true; From d976afebc5ed6c970927ea1a720a36fd7eb3c262 Mon Sep 17 00:00:00 2001 From: Hoid Date: Sun, 1 Mar 2026 11:03:18 +0100 Subject: [PATCH 073/169] test: add escapeHtml utility tests --- src/__tests__/html.test.ts | 49 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 src/__tests__/html.test.ts diff --git a/src/__tests__/html.test.ts b/src/__tests__/html.test.ts new file mode 100644 index 0000000..45f264b --- /dev/null +++ b/src/__tests__/html.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect } from 'vitest'; +import { escapeHtml } from '../utils/html'; + +describe('escapeHtml', () => { + it('escapes ampersands', () => { + expect(escapeHtml('foo & bar')).toBe('foo & bar'); + }); + + it('escapes less-than', () => { + expect(escapeHtml('a < b')).toBe('a < b'); + }); + + it('escapes greater-than', () => { + expect(escapeHtml('a > b')).toBe('a > b'); + }); + + it('escapes double quotes', () => { + expect(escapeHtml('say "hello"')).toBe('say "hello"'); + }); + + it('escapes single quotes', () => { + expect(escapeHtml("it's")).toBe('it's'); + }); + + it('returns empty string unchanged', () => { + expect(escapeHtml('')).toBe(''); + }); + + it('passes through strings with no special chars', () => { + expect(escapeHtml('hello world 123')).toBe('hello world 123'); + }); + + it('escapes multiple special chars combined', () => { + expect(escapeHtml('
    &
    ')).toBe('<div class="x">&</div>'); + }); + + it('escapes XSS payload', () => { + expect(escapeHtml('')).toBe('<script>alert("xss")</script>'); + }); + + it('double-escapes existing entities', () => { + expect(escapeHtml('&')).toBe('&amp;'); + expect(escapeHtml('<')).toBe('&lt;'); + }); + + it('escapes single quotes in attributes', () => { + expect(escapeHtml("data-x='val'")).toBe('data-x='val''); + }); +}); From 7808d85ddef5662461ef60588ff9605fa61af14f Mon Sep 17 00:00:00 2001 From: Hoid Date: Sun, 1 Mar 2026 11:05:08 +0100 Subject: [PATCH 074/169] fix: add .js extension to html test import (TypeScript moduleResolution) --- src/__tests__/html.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/__tests__/html.test.ts b/src/__tests__/html.test.ts index 45f264b..412ebf2 100644 --- a/src/__tests__/html.test.ts +++ b/src/__tests__/html.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { escapeHtml } from '../utils/html'; +import { escapeHtml } from '../utils/html.js'; describe('escapeHtml', () => { it('escapes ampersands', () => { From 4887e8ffbed1cd5eb86efeac91d815ae545e2954 Mon Sep 17 00:00:00 2001 From: Hoid Date: Sun, 1 Mar 2026 14:05:43 +0100 Subject: [PATCH 075/169] test: add missing email-change verify edge cases (expired, max_attempts) --- src/__tests__/email-change.test.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/__tests__/email-change.test.ts b/src/__tests__/email-change.test.ts index 48b0377..e87b23c 100644 --- a/src/__tests__/email-change.test.ts +++ b/src/__tests__/email-change.test.ts @@ -105,6 +105,20 @@ describe("POST /v1/email-change/verify", () => { expect(res.status).toBe(400); }); + it("returns 410 for expired code", async () => { + const { verifyCode } = await import("../services/verification.js"); + vi.mocked(verifyCode).mockResolvedValue({ status: "expired" }); + const res = await request(app).post("/v1/email-change/verify").send({ apiKey: "df_pro_xxx", newEmail: "new@example.com", code: "999999" }); + expect(res.status).toBe(410); + }); + + it("returns 429 for max attempts", async () => { + const { verifyCode } = await import("../services/verification.js"); + vi.mocked(verifyCode).mockResolvedValue({ status: "max_attempts" }); + const res = await request(app).post("/v1/email-change/verify").send({ apiKey: "df_pro_xxx", newEmail: "new@example.com", code: "999999" }); + expect(res.status).toBe(429); + }); + it("returns 200 and updates email on success", async () => { const { queryWithRetry } = await import("../services/db.js"); const res = await request(app).post("/v1/email-change/verify").send({ apiKey: "df_pro_xxx", newEmail: "new@example.com", code: "123456" }); From bb0a17a6f3c1b1fffb54eab4863d6d42a627add7 Mon Sep 17 00:00:00 2001 From: Hoid Date: Sun, 1 Mar 2026 17:03:50 +0100 Subject: [PATCH 076/169] test: add 14 comprehensive template service tests Cover edge cases for invoice and receipt rendering: - Custom currency (invoice + receipt) - Multiple items with different tax rates - Zero tax rate - Missing optional fields - All optional fields present - Receipt with/without to field - Receipt paymentMethod - Empty items array (invoice + receipt) - Missing quantity (defaults to 1) - Missing unitPrice (defaults to 0) - Template list completeness check Total tests: 428 (was 414) --- src/__tests__/templates.test.ts | 182 ++++++++++++++++++++++++++++++++ 1 file changed, 182 insertions(+) diff --git a/src/__tests__/templates.test.ts b/src/__tests__/templates.test.ts index 52ca00b..9c5147b 100644 --- a/src/__tests__/templates.test.ts +++ b/src/__tests__/templates.test.ts @@ -54,4 +54,186 @@ describe("Template rendering", () => { expect(html).toContain("'"); expect(html).toContain("&"); }); + + // --- New tests --- + + it("invoice with custom currency uses $ instead of €", () => { + const html = renderTemplate("invoice", { + invoiceNumber: "INV-100", date: "2026-02-01", + from: { name: "US Corp" }, to: { name: "Client" }, + items: [{ description: "Service", quantity: 1, unitPrice: 50 }], + currency: "$", + }); + expect(html).toContain("$50.00"); + expect(html).not.toContain("€"); + }); + + it("invoice with multiple items calculates correct subtotal, tax, and total", () => { + const html = renderTemplate("invoice", { + invoiceNumber: "INV-200", date: "2026-02-01", + from: { name: "Seller" }, to: { name: "Buyer" }, + items: [ + { description: "Item A", quantity: 2, unitPrice: 100, taxRate: 20 }, // 200 + 40 tax + { description: "Item B", quantity: 1, unitPrice: 50, taxRate: 10 }, // 50 + 5 tax + { description: "Item C", quantity: 3, unitPrice: 30, taxRate: 0 }, // 90 + 0 tax + ], + }); + // Subtotal: 200 + 50 + 90 = 340 + expect(html).toContain("€340.00"); + // Tax: 40 + 5 + 0 = 45 + expect(html).toContain("€45.00"); + // Total: 385 + expect(html).toContain("€385.00"); + }); + + it("invoice with zero tax rate shows 0% and no tax amount", () => { + const html = renderTemplate("invoice", { + invoiceNumber: "INV-300", date: "2026-02-01", + from: { name: "Seller" }, to: { name: "Buyer" }, + items: [{ description: "Tax-free item", quantity: 1, unitPrice: 100, taxRate: 0 }], + }); + expect(html).toContain("0%"); + // Subtotal and total should be the same + expect(html).toContain("Subtotal: €100.00"); + expect(html).toContain("Tax: €0.00"); + expect(html).toContain("Total: €100.00"); + }); + + it("invoice with missing optional fields renders without errors", () => { + const html = renderTemplate("invoice", { + invoiceNumber: "INV-400", date: "2026-02-01", + from: { name: "Seller" }, to: { name: "Buyer" }, + items: [{ description: "Basic", quantity: 1, unitPrice: 10 }], + // no dueDate, no notes, no paymentDetails + }); + expect(html).toContain("INVOICE"); + expect(html).toContain("INV-400"); + expect(html).not.toContain("Due:"); + expect(html).not.toContain("Payment Details"); + expect(html).not.toContain("Notes"); + }); + + it("invoice with all optional fields renders them all", () => { + const html = renderTemplate("invoice", { + invoiceNumber: "INV-500", date: "2026-02-01", + dueDate: "2026-03-01", + from: { name: "Full Seller", address: "123 Main St", email: "seller@test.com", phone: "+1234", vatId: "AT123" }, + to: { name: "Full Buyer", address: "456 Oak Ave", email: "buyer@test.com", vatId: "DE456" }, + items: [{ description: "Premium", quantity: 1, unitPrice: 200, taxRate: 10 }], + currency: "€", + notes: "Please pay promptly", + paymentDetails: "IBAN: AT123456", + }); + expect(html).toContain("Due: 2026-03-01"); + expect(html).toContain("123 Main St"); + expect(html).toContain("seller@test.com"); + expect(html).toContain("VAT: AT123"); + expect(html).toContain("456 Oak Ave"); + expect(html).toContain("buyer@test.com"); + expect(html).toContain("VAT: DE456"); + expect(html).toContain("Please pay promptly"); + expect(html).toContain("IBAN: AT123456"); + }); + + it("receipt with custom currency uses £", () => { + const html = renderTemplate("receipt", { + receiptNumber: "R-100", date: "2026-02-01", + from: { name: "UK Shop" }, + items: [{ description: "Tea", amount: 3.50 }], + currency: "£", + }); + expect(html).toContain("£3.50"); + expect(html).not.toContain("€"); + }); + + it("receipt with paymentMethod shows it", () => { + const html = renderTemplate("receipt", { + receiptNumber: "R-200", date: "2026-02-01", + from: { name: "Shop" }, + items: [{ description: "Item", amount: 10 }], + paymentMethod: "Credit Card", + }); + expect(html).toContain("Paid via: Credit Card"); + }); + + it("receipt with to field shows customer name", () => { + const html = renderTemplate("receipt", { + receiptNumber: "R-300", date: "2026-02-01", + from: { name: "Shop" }, + to: { name: "Jane Doe" }, + items: [{ description: "Item", amount: 5 }], + }); + expect(html).toContain("Customer: Jane Doe"); + }); + + it("receipt without to field renders without error", () => { + const html = renderTemplate("receipt", { + receiptNumber: "R-400", date: "2026-02-01", + from: { name: "Shop" }, + items: [{ description: "Item", amount: 5 }], + }); + expect(html).toContain("Shop"); + expect(html).not.toContain("Customer:"); + }); + + it("invoice with empty items array renders with €0.00 total", () => { + const html = renderTemplate("invoice", { + invoiceNumber: "INV-600", date: "2026-02-01", + from: { name: "Seller" }, to: { name: "Buyer" }, + items: [], + }); + expect(html).toContain("Total: €0.00"); + }); + + it("receipt with empty items array renders with €0.00 total", () => { + const html = renderTemplate("receipt", { + receiptNumber: "R-500", date: "2026-02-01", + from: { name: "Shop" }, + items: [], + }); + expect(html).toContain("€0.00"); + }); + + it("invoice items with missing quantity defaults to 1", () => { + const html = renderTemplate("invoice", { + invoiceNumber: "INV-700", date: "2026-02-01", + from: { name: "Seller" }, to: { name: "Buyer" }, + items: [{ description: "Widget", unitPrice: 25 }], + }); + // quantity defaults to 1, so line total = 25 + expect(html).toContain("€25.00"); + }); + + it("invoice items with missing unitPrice defaults to 0", () => { + const html = renderTemplate("invoice", { + invoiceNumber: "INV-800", date: "2026-02-01", + from: { name: "Seller" }, to: { name: "Buyer" }, + items: [{ description: "Free item", quantity: 5 }], + }); + // unitPrice defaults to 0, line total = 0 + expect(html).toContain("Total: €0.00"); + }); + + it("template list contains both invoice and receipt with correct field definitions", () => { + expect(templates).toHaveProperty("invoice"); + expect(templates).toHaveProperty("receipt"); + expect(templates.invoice.name).toBe("Invoice"); + expect(templates.receipt.name).toBe("Receipt"); + // Invoice required fields + const invoiceRequired = templates.invoice.fields.filter(f => f.required).map(f => f.name); + expect(invoiceRequired).toContain("invoiceNumber"); + expect(invoiceRequired).toContain("date"); + expect(invoiceRequired).toContain("from"); + expect(invoiceRequired).toContain("to"); + expect(invoiceRequired).toContain("items"); + // Receipt required fields + const receiptRequired = templates.receipt.fields.filter(f => f.required).map(f => f.name); + expect(receiptRequired).toContain("receiptNumber"); + expect(receiptRequired).toContain("date"); + expect(receiptRequired).toContain("from"); + expect(receiptRequired).toContain("items"); + // Receipt 'to' is optional + const receiptTo = templates.receipt.fields.find(f => f.name === "to"); + expect(receiptTo?.required).toBe(false); + }); }); From 82946ffcf060aa0284b3e17346a69413e97a2ff0 Mon Sep 17 00:00:00 2001 From: Hoid Date: Sun, 1 Mar 2026 20:03:55 +0100 Subject: [PATCH 077/169] fix(BUG-092): add Change Email link to footer on landing and sub-pages --- public/examples.html | 1 + public/impressum.html | 1 + public/index.html | 1 + public/partials/_footer.html | 1 + public/privacy.html | 1 + public/src/index.html | 1 + public/status.html | 1 + public/terms.html | 1 + src/__tests__/app-routes.test.ts | 21 +++++++++++++++++++++ 9 files changed, 29 insertions(+) diff --git a/public/examples.html b/public/examples.html index 595559c..fb5a2a7 100644 --- a/public/examples.html +++ b/public/examples.html @@ -408,6 +408,7 @@ $pdf = DocFast::html(view('invoice')->render()); Docs Examples API Status + Change Email Impressum Privacy Policy Terms of Service diff --git a/public/impressum.html b/public/impressum.html index 310ab15..d76f919 100644 --- a/public/impressum.html +++ b/public/impressum.html @@ -110,6 +110,7 @@ footer .container { display: flex; justify-content: space-between; align-items: Docs Examples API Status + Change Email Impressum Privacy Policy Terms of Service diff --git a/public/index.html b/public/index.html index 9f776a0..629c9d3 100644 --- a/public/index.html +++ b/public/index.html @@ -586,6 +586,7 @@ html, body { Examples API Status Support + Change Email Impressum Privacy Policy Terms of Service diff --git a/public/partials/_footer.html b/public/partials/_footer.html index 9585632..b64c902 100644 --- a/public/partials/_footer.html +++ b/public/partials/_footer.html @@ -6,6 +6,7 @@ Docs Examples API Status + Change Email Impressum Privacy Policy Terms of Service diff --git a/public/privacy.html b/public/privacy.html index ba5d1ee..47caaed 100644 --- a/public/privacy.html +++ b/public/privacy.html @@ -192,6 +192,7 @@ footer .container { display: flex; justify-content: space-between; align-items: Docs Examples API Status + Change Email Impressum Privacy Policy Terms of Service diff --git a/public/src/index.html b/public/src/index.html index 9f776a0..629c9d3 100644 --- a/public/src/index.html +++ b/public/src/index.html @@ -586,6 +586,7 @@ html, body { Examples API Status Support + Change Email Impressum Privacy Policy Terms of Service diff --git a/public/status.html b/public/status.html index 996f100..1a79802 100644 --- a/public/status.html +++ b/public/status.html @@ -106,6 +106,7 @@ footer .container { display: flex; justify-content: space-between; align-items: Docs Examples API Status + Change Email Impressum Privacy Policy Terms of Service diff --git a/public/terms.html b/public/terms.html index f240aff..203b61e 100644 --- a/public/terms.html +++ b/public/terms.html @@ -264,6 +264,7 @@ footer .container { display: flex; justify-content: space-between; align-items: Docs Examples API Status + Change Email Impressum Privacy Policy Terms of Service diff --git a/src/__tests__/app-routes.test.ts b/src/__tests__/app-routes.test.ts index 27b7c96..c551053 100644 --- a/src/__tests__/app-routes.test.ts +++ b/src/__tests__/app-routes.test.ts @@ -129,4 +129,25 @@ describe("App-level routes", () => { expect(res.headers["permissions-policy"]).toContain("camera=()"); }); }); + + describe("BUG-092: Footer Change Email link", () => { + it("landing page footer contains Change Email link", async () => { + const res = await request(app).get("/"); + expect(res.status).toBe(200); + const html = res.text; + expect(html).toContain('class="open-email-change"'); + expect(html).toMatch(/footer-links[\s\S]*open-email-change[\s\S]*Change Email/); + }); + + it("sub-page footer partial contains Change Email link", async () => { + const fs = await import("fs"); + const path = await import("path"); + const footer = fs.readFileSync( + path.join(__dirname, "../../public/partials/_footer.html"), + "utf-8" + ); + expect(footer).toContain('class="open-email-change"'); + expect(footer).toContain('href="/#change-email"'); + }); + }); }); From 9eb9b4232b89acc21079fd741543d7207dd15d41 Mon Sep 17 00:00:00 2001 From: Hoid Date: Sun, 1 Mar 2026 20:05:01 +0100 Subject: [PATCH 078/169] test: add billing edge case tests (characterization) --- src/__tests__/billing.test.ts | 194 ++++++++++++++++++++++++++++++++++ 1 file changed, 194 insertions(+) diff --git a/src/__tests__/billing.test.ts b/src/__tests__/billing.test.ts index 07790dc..30701bc 100644 --- a/src/__tests__/billing.test.ts +++ b/src/__tests__/billing.test.ts @@ -137,6 +137,36 @@ describe("GET /v1/billing/success", () => { const res = await request(app).get("/v1/billing/success?session_id=cs_err"); expect(res.status).toBe(500); }); + + it("returns 400 when session has no customer", async () => { + mockStripe.checkout.sessions.retrieve.mockResolvedValue({ + id: "cs_no_cust", + customer: null, + customer_details: { email: "test@test.com" }, + }); + const res = await request(app).get("/v1/billing/success?session_id=cs_no_cust"); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/No customer found/); + }); + + it("escapes HTML in displayed key to prevent XSS", async () => { + mockStripe.checkout.sessions.retrieve.mockResolvedValue({ + id: "cs_xss", + customer: "cus_xss", + customer_details: { email: "xss@test.com" }, + }); + const { createProKey } = await import("../services/keys.js"); + vi.mocked(createProKey).mockResolvedValue({ + key: '', + tier: "pro", + email: "xss@test.com", + createdAt: new Date().toISOString(), + } as any); + const res = await request(app).get("/v1/billing/success?session_id=cs_xss"); + expect(res.status).toBe(200); + expect(res.text).not.toContain(''); + expect(res.text).toContain("<script>"); + }); }); describe("POST /v1/billing/webhook", () => { @@ -275,6 +305,170 @@ describe("POST /v1/billing/webhook", () => { expect(downgradeByCustomer).toHaveBeenCalledWith("cus_cancel"); }); + it("does not provision key when checkout.session.completed has missing customer", async () => { + mockStripe.webhooks.constructEvent.mockReturnValue({ + type: "checkout.session.completed", + data: { + object: { + id: "cs_no_cust", + customer: null, + customer_details: { email: "nocust@test.com" }, + }, + }, + }); + mockStripe.checkout.sessions.retrieve.mockResolvedValue({ + id: "cs_no_cust", + line_items: { data: [{ price: { product: "prod_TygeG8tQPtEAdE" } }] }, + }); + const { createProKey } = await import("../services/keys.js"); + const res = await request(app) + .post("/v1/billing/webhook") + .set("content-type", "application/json") + .set("stripe-signature", "valid_sig") + .send(JSON.stringify({ type: "checkout.session.completed" })); + expect(res.status).toBe(200); + expect(createProKey).not.toHaveBeenCalled(); + }); + + it("does not provision key when checkout.session.completed has missing email", async () => { + mockStripe.webhooks.constructEvent.mockReturnValue({ + type: "checkout.session.completed", + data: { + object: { + id: "cs_no_email", + customer: "cus_no_email", + customer_details: {}, + }, + }, + }); + mockStripe.checkout.sessions.retrieve.mockResolvedValue({ + id: "cs_no_email", + line_items: { data: [{ price: { product: "prod_TygeG8tQPtEAdE" } }] }, + }); + const { createProKey } = await import("../services/keys.js"); + const res = await request(app) + .post("/v1/billing/webhook") + .set("content-type", "application/json") + .set("stripe-signature", "valid_sig") + .send(JSON.stringify({ type: "checkout.session.completed" })); + expect(res.status).toBe(200); + expect(createProKey).not.toHaveBeenCalled(); + }); + + it("does not downgrade on customer.subscription.updated with non-DocFast product", async () => { + mockStripe.webhooks.constructEvent.mockReturnValue({ + type: "customer.subscription.updated", + data: { + object: { id: "sub_other", customer: "cus_other", status: "canceled", cancel_at_period_end: false }, + }, + }); + mockStripe.subscriptions.retrieve.mockResolvedValue({ + items: { data: [{ price: { product: { id: "prod_OTHER" } } }] }, + }); + const { downgradeByCustomer } = await import("../services/keys.js"); + const res = await request(app) + .post("/v1/billing/webhook") + .set("content-type", "application/json") + .set("stripe-signature", "valid_sig") + .send(JSON.stringify({ type: "customer.subscription.updated" })); + expect(res.status).toBe(200); + expect(downgradeByCustomer).not.toHaveBeenCalled(); + }); + + it("downgrades on customer.subscription.updated with past_due status", async () => { + mockStripe.webhooks.constructEvent.mockReturnValue({ + type: "customer.subscription.updated", + data: { + object: { id: "sub_past", customer: "cus_past", status: "past_due", cancel_at_period_end: false }, + }, + }); + mockStripe.subscriptions.retrieve.mockResolvedValue({ + items: { data: [{ price: { product: { id: "prod_TygeG8tQPtEAdE" } } }] }, + }); + const { downgradeByCustomer } = await import("../services/keys.js"); + const res = await request(app) + .post("/v1/billing/webhook") + .set("content-type", "application/json") + .set("stripe-signature", "valid_sig") + .send(JSON.stringify({ type: "customer.subscription.updated" })); + expect(res.status).toBe(200); + expect(downgradeByCustomer).toHaveBeenCalledWith("cus_past"); + }); + + it("does not downgrade on customer.subscription.updated with active status and no cancel", async () => { + mockStripe.webhooks.constructEvent.mockReturnValue({ + type: "customer.subscription.updated", + data: { + object: { id: "sub_ok", customer: "cus_ok", status: "active", cancel_at_period_end: false }, + }, + }); + const { downgradeByCustomer } = await import("../services/keys.js"); + const res = await request(app) + .post("/v1/billing/webhook") + .set("content-type", "application/json") + .set("stripe-signature", "valid_sig") + .send(JSON.stringify({ type: "customer.subscription.updated" })); + expect(res.status).toBe(200); + expect(downgradeByCustomer).not.toHaveBeenCalled(); + }); + + it("does not downgrade on customer.subscription.deleted with non-DocFast product", async () => { + mockStripe.webhooks.constructEvent.mockReturnValue({ + type: "customer.subscription.deleted", + data: { + object: { id: "sub_del_other", customer: "cus_del_other" }, + }, + }); + mockStripe.subscriptions.retrieve.mockResolvedValue({ + items: { data: [{ price: { product: { id: "prod_OTHER" } } }] }, + }); + const { downgradeByCustomer } = await import("../services/keys.js"); + const res = await request(app) + .post("/v1/billing/webhook") + .set("content-type", "application/json") + .set("stripe-signature", "valid_sig") + .send(JSON.stringify({ type: "customer.subscription.deleted" })); + expect(res.status).toBe(200); + expect(downgradeByCustomer).not.toHaveBeenCalled(); + }); + + it("returns 200 for unknown event type", async () => { + mockStripe.webhooks.constructEvent.mockReturnValue({ + type: "invoice.payment_failed", + data: { object: {} }, + }); + const res = await request(app) + .post("/v1/billing/webhook") + .set("content-type", "application/json") + .set("stripe-signature", "valid_sig") + .send(JSON.stringify({ type: "invoice.payment_failed" })); + expect(res.status).toBe(200); + expect(res.body.received).toBe(true); + }); + + it("returns 200 when session retrieve fails on checkout.session.completed", async () => { + mockStripe.webhooks.constructEvent.mockReturnValue({ + type: "checkout.session.completed", + data: { + object: { + id: "cs_fail_retrieve", + customer: "cus_fail", + customer_details: { email: "fail@test.com" }, + }, + }, + }); + mockStripe.checkout.sessions.retrieve.mockRejectedValue(new Error("Stripe retrieve failed")); + const { createProKey } = await import("../services/keys.js"); + const res = await request(app) + .post("/v1/billing/webhook") + .set("content-type", "application/json") + .set("stripe-signature", "valid_sig") + .send(JSON.stringify({ type: "checkout.session.completed" })); + expect(res.status).toBe(200); + expect(res.body.received).toBe(true); + expect(createProKey).not.toHaveBeenCalled(); + }); + it("syncs email on customer.updated", async () => { mockStripe.webhooks.constructEvent.mockReturnValue({ type: "customer.updated", From cf1a589a47d852b820f51c5e431d264e70f3af04 Mon Sep 17 00:00:00 2001 From: DocFast CEO Date: Mon, 2 Mar 2026 08:12:30 +0100 Subject: [PATCH 079/169] chore: bump to v0.5.2, update sitemap dates, add .dockerignore, update deps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Version bump 0.5.1 → 0.5.2 (24 commits since last tag) - Update sitemap lastmod dates to 2026-03-02 - Add .dockerignore to exclude node_modules, .git, tests from build context - Update minor deps: pg, puppeteer, stripe, swagger-ui-dist, @types/* - npm audit: 0 vulnerabilities, 440 tests passing --- .dockerignore | 10 ++++ package-lock.json | 142 ++++++++++++++++++++++++--------------------- package.json | 2 +- public/sitemap.xml | 14 ++--- 4 files changed, 93 insertions(+), 75 deletions(-) create mode 100644 .dockerignore diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..0eb4c73 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +node_modules +.git +.gitignore +*.md +src/__tests__ +vitest.config.ts +.env* +.credentials +memory +dist diff --git a/package-lock.json b/package-lock.json index 30e5f96..7838e6a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "docfast-api", - "version": "0.5.1", + "version": "0.5.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "docfast-api", - "version": "0.5.1", + "version": "0.5.2", "dependencies": { "compression": "^1.8.1", "express": "^4.21.0", @@ -631,9 +631,9 @@ "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==" }, "node_modules/@puppeteer/browsers": { - "version": "2.12.1", - "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.12.1.tgz", - "integrity": "sha512-fXa6uXLxfslBlus3MEpW8S6S9fe5RwmAE5Gd8u3krqOwnkZJV3/lQJiY3LaFdTctLLqJtyMgEUGkbDnRNf6vbQ==", + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.13.0.tgz", + "integrity": "sha512-46BZJYJjc/WwmKjsvDFykHtXrtomsCIrwYQPOP7VfMJoZY2bsDF9oROBABR3paDjDcmkUye1Pb1BqdcdiipaWA==", "license": "Apache-2.0", "dependencies": { "debug": "^4.4.3", @@ -1155,18 +1155,19 @@ } }, "node_modules/@types/nodemailer": { - "version": "7.0.9", - "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.9.tgz", - "integrity": "sha512-vI8oF1M+8JvQhsId0Pc38BdUP2evenIIys7c7p+9OZXSPOH5c1dyINP1jT8xQ2xPuBUXmIC87s+91IZMDjH8Ow==", + "version": "7.0.11", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.11.tgz", + "integrity": "sha512-E+U4RzR2dKrx+u3N4DlsmLaDC6mMZOM/TPROxA0UAPiTgI0y4CEFBmZE+coGWTjakDriRsXG368lNk1u9Q0a2g==", "dev": true, + "license": "MIT", "dependencies": { "@types/node": "*" } }, "node_modules/@types/pg": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.16.0.tgz", - "integrity": "sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.18.0.tgz", + "integrity": "sha512-gT+oueVQkqnj6ajGJXblFR4iavIXWsGAFCk3dP4Kki5+a9R4NMt0JARdk6s8cUKcfUoqP5dAtDSLU8xYUTFV+Q==", "dev": true, "license": "MIT", "dependencies": { @@ -1482,9 +1483,9 @@ } }, "node_modules/b4a": { - "version": "1.7.4", - "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.4.tgz", - "integrity": "sha512-u20zJLDaSWpxaZ+zaAkEIB2dZZ1o+DF4T/MRbmsvGp9nletHOyiai19OzX1fF8xUBYsO1bPXxODvcd0978pnug==", + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.0.tgz", + "integrity": "sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg==", "license": "Apache-2.0", "peerDependencies": { "react-native-b4a": "*" @@ -1516,11 +1517,10 @@ } }, "node_modules/bare-fs": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.5.4.tgz", - "integrity": "sha512-POK4oplfA7P7gqvetNmCs4CNtm9fNsx+IAh7jH7GgU0OJdge2rso0R20TNWVq6VoWcCvsTdlNDaleLHGaKx8CA==", + "version": "4.5.5", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.5.5.tgz", + "integrity": "sha512-XvwYM6VZqKoqDll8BmSww5luA5eflDzY0uEFfBJtFKe4PAAtxBjU3YIxzIBzhyaEQBy1VXEQBto4cpN5RZJw+w==", "license": "Apache-2.0", - "optional": true, "dependencies": { "bare-events": "^2.5.4", "bare-path": "^3.0.0", @@ -1541,11 +1541,10 @@ } }, "node_modules/bare-os": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.6.2.tgz", - "integrity": "sha512-T+V1+1srU2qYNBmJCXZkUY5vQ0B4FSlL3QDROnKQYOqeiQR8UbjNHlPa+TIbM4cuidiN9GaTaOZgSEgsvPbh5A==", + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.7.0.tgz", + "integrity": "sha512-64Rcwj8qlnTZU8Ps6JJEdSmxBEUGgI7g8l+lMtsJLl4IsfTcHMTfJ188u2iGV6P6YPRZrtv72B2kjn+hp+Yv3g==", "license": "Apache-2.0", - "optional": true, "engines": { "bare": ">=1.14.0" } @@ -1555,19 +1554,18 @@ "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", "license": "Apache-2.0", - "optional": true, "dependencies": { "bare-os": "^3.0.1" } }, "node_modules/bare-stream": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.7.0.tgz", - "integrity": "sha512-oyXQNicV1y8nc2aKffH+BUHFRXmx6VrPzlnaEvMhram0nPBrKcEdcyBg5r08D0i8VxngHFAiVyn1QKXpSG0B8A==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.8.0.tgz", + "integrity": "sha512-reUN0M2sHRqCdG4lUK3Fw8w98eeUIZHL5c3H7Mbhk2yVBL+oofgaIp0ieLfD5QXwPCypBpmEEKU2WZKzbAk8GA==", "license": "Apache-2.0", - "optional": true, "dependencies": { - "streamx": "^2.21.0" + "streamx": "^2.21.0", + "teex": "^1.0.1" }, "peerDependencies": { "bare-buffer": "*", @@ -1587,7 +1585,6 @@ "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.3.2.tgz", "integrity": "sha512-ZMq4gd9ngV5aTMa5p9+UfY0b3skwhHELaDkhEHetMdX0LRkW9kzaym4oo/Eh+Ghm0CCDuMTsRIGM/ytUc1ZYmw==", "license": "Apache-2.0", - "optional": true, "dependencies": { "bare-path": "^3.0.0" } @@ -3311,14 +3308,14 @@ "license": "MIT" }, "node_modules/pg": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/pg/-/pg-8.18.0.tgz", - "integrity": "sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ==", + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.19.0.tgz", + "integrity": "sha512-QIcLGi508BAHkQ3pJNptsFz5WQMlpGbuBGBaIaXsWK8mel2kQ/rThYI+DbgjUvZrIr7MiuEuc9LcChJoEZK1xQ==", "license": "MIT", "dependencies": { "pg-connection-string": "^2.11.0", - "pg-pool": "^3.11.0", - "pg-protocol": "^1.11.0", + "pg-pool": "^3.12.0", + "pg-protocol": "^1.12.0", "pg-types": "2.2.0", "pgpass": "1.0.5" }, @@ -3360,18 +3357,18 @@ } }, "node_modules/pg-pool": { - "version": "3.11.0", - "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.11.0.tgz", - "integrity": "sha512-MJYfvHwtGp870aeusDh+hg9apvOe2zmpZJpyt+BMtzUWlVqbhFmMK6bOBXLBUPd7iRtIF9fZplDc7KrPN3PN7w==", + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.12.0.tgz", + "integrity": "sha512-eIJ0DES8BLaziFHW7VgJEBPi5hg3Nyng5iKpYtj3wbcAUV9A1wLgWiY7ajf/f/oO1wfxt83phXPY8Emztg7ITg==", "license": "MIT", "peerDependencies": { "pg": ">=8.0" } }, "node_modules/pg-protocol": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.11.0.tgz", - "integrity": "sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g==", + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.12.0.tgz", + "integrity": "sha512-uOANXNRACNdElMXJ0tPz6RBM0XQ61nONGAwlt8da5zs/iUOOCLBQOHSXnrC6fMsvtjxbOJrZZl5IScGv+7mpbg==", "license": "MIT" }, "node_modules/pg-types": { @@ -3625,9 +3622,9 @@ "license": "MIT" }, "node_modules/pump": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", - "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", "license": "MIT", "dependencies": { "end-of-stream": "^1.1.0", @@ -3635,17 +3632,17 @@ } }, "node_modules/puppeteer": { - "version": "24.37.3", - "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-24.37.3.tgz", - "integrity": "sha512-AUGGWq0BhPM+IOS2U9A+ZREH3HDFkV1Y5HERYGDg5cbGXjoGsTCT7/A6VZRfNU0UJJdCclyEimZICkZW6pqJyw==", + "version": "24.37.5", + "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-24.37.5.tgz", + "integrity": "sha512-3PAOIQLceyEmn1Fi76GkGO2EVxztv5OtdlB1m8hMUZL3f8KDHnlvXbvCXv+Ls7KzF1R0KdKBqLuT/Hhrok12hQ==", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "@puppeteer/browsers": "2.12.1", + "@puppeteer/browsers": "2.13.0", "chromium-bidi": "14.0.0", "cosmiconfig": "^9.0.0", "devtools-protocol": "0.0.1566079", - "puppeteer-core": "24.37.3", + "puppeteer-core": "24.37.5", "typed-query-selector": "^2.12.0" }, "bin": { @@ -3656,12 +3653,12 @@ } }, "node_modules/puppeteer-core": { - "version": "24.37.3", - "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.37.3.tgz", - "integrity": "sha512-fokQ8gv+hNgsRWqVuP5rUjGp+wzV5aMTP3fcm8ekNabmLGlJdFHas1OdMscAH9Gzq4Qcf7cfI/Pe6wEcAqQhqg==", + "version": "24.37.5", + "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.37.5.tgz", + "integrity": "sha512-ybL7iE78YPN4T6J+sPLO7r0lSByp/0NN6PvfBEql219cOnttoTFzCWKiBOjstXSqi/OKpwae623DWAsL7cn2MQ==", "license": "Apache-2.0", "dependencies": { - "@puppeteer/browsers": "2.12.1", + "@puppeteer/browsers": "2.13.0", "chromium-bidi": "14.0.0", "debug": "^4.4.3", "devtools-protocol": "0.0.1566079", @@ -4187,9 +4184,9 @@ "license": "MIT" }, "node_modules/stripe": { - "version": "20.3.1", - "resolved": "https://registry.npmjs.org/stripe/-/stripe-20.3.1.tgz", - "integrity": "sha512-k990yOT5G5rhX3XluRPw5Y8RLdJDW4dzQ29wWT66piHrbnM2KyamJ1dKgPsw4HzGHRWjDiSSdcI2WdxQUPV3aQ==", + "version": "20.4.0", + "resolved": "https://registry.npmjs.org/stripe/-/stripe-20.4.0.tgz", + "integrity": "sha512-F/aN1IQ9vHmlyLNi3DkiIbyzQb6gyBG0uYFd/VrEVQSc9BLtlgknPUx0EvzZdBMRLFuRaPFIFd7Mxwtg7Pbwzw==", "license": "MIT", "engines": { "node": ">=16" @@ -4338,9 +4335,10 @@ } }, "node_modules/swagger-ui-dist": { - "version": "5.31.0", - "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.31.0.tgz", - "integrity": "sha512-zSUTIck02fSga6rc0RZP3b7J7wgHXwLea8ZjgLA3Vgnb8QeOl3Wou2/j5QkzSGeoz6HusP/coYuJl33aQxQZpg==", + "version": "5.32.0", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.32.0.tgz", + "integrity": "sha512-nKZB0OuDvacB0s/lC2gbge+RigYvGRGpLLMWMFxaTUwfM+CfndVk9Th2IaTinqXiz6Mn26GK2zriCpv6/+5m3Q==", + "license": "Apache-2.0", "dependencies": { "@scarf/scarf": "=1.4.0" } @@ -4360,16 +4358,26 @@ } }, "node_modules/tar-stream": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", - "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.8.tgz", + "integrity": "sha512-U6QpVRyCGHva435KoNWy9PRoi2IFYCgtEhq9nmrPPpbRacPs9IH4aJ3gbrFC8dPcXvdSZ4XXfXT5Fshbp2MtlQ==", "license": "MIT", "dependencies": { "b4a": "^1.6.4", + "bare-fs": "^4.5.5", "fast-fifo": "^1.2.0", "streamx": "^2.15.0" } }, + "node_modules/teex": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", + "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", + "license": "MIT", + "dependencies": { + "streamx": "^2.12.5" + } + }, "node_modules/terser": { "version": "5.46.0", "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.0.tgz", @@ -4390,9 +4398,9 @@ } }, "node_modules/text-decoder": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.6.tgz", - "integrity": "sha512-27FeW5GQFDfw0FpwMQhMagB7BztOOlmjcSRi97t2oplhKVTZtp0DZbSegSaXS5IIC6mxMvBG4AR1Sgc6BX3CQg==", + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", + "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", "license": "Apache-2.0", "dependencies": { "b4a": "^1.6.4" @@ -4519,9 +4527,9 @@ } }, "node_modules/typed-query-selector": { - "version": "2.12.0", - "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.0.tgz", - "integrity": "sha512-SbklCd1F0EiZOyPiW192rrHZzZ5sBijB6xM+cpmrwDqObvdtunOHHIk9fCGsoK5JVIYXoyEp4iEdE3upFH3PAg==", + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.1.tgz", + "integrity": "sha512-uzR+FzI8qrUEIu96oaeBJmd9E7CFEiQ3goA5qCVgc4s5llSubcfGHq9yUstZx/k4s9dXHVKsE35YWoFyvEqEHA==", "license": "MIT" }, "node_modules/typescript": { diff --git a/package.json b/package.json index 64440f2..6acc74b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "docfast-api", - "version": "0.5.1", + "version": "0.5.2", "description": "Markdown/HTML to PDF API with built-in invoice templates", "main": "dist/index.js", "scripts": { diff --git a/public/sitemap.xml b/public/sitemap.xml index 6749df1..0f9e41a 100644 --- a/public/sitemap.xml +++ b/public/sitemap.xml @@ -1,10 +1,10 @@ - https://docfast.dev/2026-02-20weekly1.0 - https://docfast.dev/docs2026-02-20weekly0.8 - https://docfast.dev/examples2026-02-20monthly0.7 - https://docfast.dev/impressum2026-02-20monthly0.3 - https://docfast.dev/privacy2026-02-20monthly0.3 - https://docfast.dev/terms2026-02-20monthly0.3 - https://docfast.dev/status2026-02-20always0.2 + https://docfast.dev/2026-03-02weekly1.0 + https://docfast.dev/docs2026-03-02weekly0.8 + https://docfast.dev/examples2026-03-02monthly0.7 + https://docfast.dev/impressum2026-03-02monthly0.3 + https://docfast.dev/privacy2026-03-02monthly0.3 + https://docfast.dev/terms2026-03-02monthly0.3 + https://docfast.dev/status2026-03-02always0.2 From 6290c3eb976652f16096de4dfb4d94e44a3d18e2 Mon Sep 17 00:00:00 2001 From: OpenClaw Date: Mon, 2 Mar 2026 14:11:13 +0100 Subject: [PATCH 080/169] fix(BUG-095,BUG-097): add Support link to footer partial, expand docs.html footer --- public/docs.html | 6 +++++ public/examples.html | 1 + public/impressum.html | 1 + public/partials/_footer.html | 1 + public/privacy.html | 1 + public/status.html | 1 + public/terms.html | 1 + src/__tests__/app-routes.test.ts | 40 ++++++++++++++++++++++++++++++++ 8 files changed, 52 insertions(+) diff --git a/public/docs.html b/public/docs.html index e99db2a..7d4ba24 100644 --- a/public/docs.html +++ b/public/docs.html @@ -120,6 +120,12 @@