memory: 2026-02-17 daily log
This commit is contained in:
parent
6e5ca5dd0c
commit
3e37a420f6
26 changed files with 579 additions and 295 deletions
0
.env
Normal file
0
.env
Normal file
|
|
@ -36,6 +36,23 @@
|
|||
- Generali Versicherungs-Formular: blocked by SevDesk error, reminder set for Thu Feb 19 10:00 Vienna
|
||||
- PC Streaming fixen: reminded multiple times, keeps postponing
|
||||
|
||||
## DocFast Support Agent Overhaul
|
||||
- Support agent was replying robotically (same template every time) — rewrote prompt to be natural/human
|
||||
- **BUG: FreeScout threads API** — `/conversations/{id}/threads` returns empty; must use `/conversations/{id}?embed=threads`
|
||||
- **BUG: Thread ordering** — FreeScout returns threads in reverse chronological order (newest first), not oldest first
|
||||
- **BUG: Duplicate replies** — old `tickets` command showed all active tickets, not just ones needing reply → agent replied 8 times to ticket #369
|
||||
- Added `needs-reply` command — filters by: assigned to franz.hubert@docfast.dev + last non-note/lineitem thread is type `customer`
|
||||
- Agent now closes resolved tickets with `--status closed`
|
||||
- Escalation path: if can't solve → assign to dominik.polakovics@cloonar.com + draft note
|
||||
- Switched support agent from Opus to **Sonnet 4.5** (cheaper, good enough)
|
||||
- Corrected signup flow docs: verification CODE sent by email → enter on website → API key shown on screen (NOT emailed)
|
||||
- Ticket #369 (dominik@superbros.tv): customer says verification code never arrives, business halted. Agent kept telling them to retry. Needs investigation of mail delivery logs.
|
||||
- Ticket #370 (office@cloonar.com): unassigned, also lost api key
|
||||
|
||||
## Rate Limiting
|
||||
- Hit Anthropic API rate limits starting ~21:09 UTC — uptime monitor cron jobs failing with 429s for hours
|
||||
- Too many cron jobs running simultaneously burning through rate limits
|
||||
|
||||
## Misc
|
||||
- Suchard Express Kakao: 80% sugar, ~25g per cup (8 Würfelzucker)
|
||||
- One Piece S2: March 10, all 8 episodes at once, ~40-50 min each
|
||||
|
|
|
|||
29
memory/2026-02-17.md
Normal file
29
memory/2026-02-17.md
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
# 2026-02-17 — Tuesday
|
||||
|
||||
## DocFast
|
||||
- **Support agent overhaul completed**: needs-reply command fixed (embed=threads API, reverse chronological ordering, assignee filter for franz.hubert@docfast.dev), closes tickets after resolving, escalates to dominik.polakovics@cloonar.com, switched to Sonnet 4.5
|
||||
- **BUG-050 RESOLVED**: MX DNS fixed — set to mail.cloonar.com. (user did this in Hetzner DNS)
|
||||
- **BUG-049 RESOLVED**: Stripe invoice emails enabled by user
|
||||
- **BUG-070 FIXED by CEO**: Stripe subscription cancellation now downgrades Pro keys (was deleting them). Added customer.subscription.updated + customer.updated webhook handlers
|
||||
- **Checkout recurring breakage ROOT CAUSE**: No .env file on server — docker-compose used ${STRIPE_SECRET_KEY} variable substitution but nothing provided values persistently. Fixed with persistent .env + CI/CD pipeline now injects secrets from Forgejo
|
||||
- **CI/CD secrets in Forgejo**: STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, DATABASE_PASSWORD (DB password is "docfast" — default)
|
||||
- **Change Email feature REMOVED**: Security issue — leaked API key could hijack account by changing email. Free tier users can just create new key, Pro email syncs via Stripe webhook
|
||||
- **Sticky navbar**: Fixed with position:sticky, was broken on homepage due to overflow-x:hidden on html/body. Changed to overflow:clip. Also merged duplicate partials (_nav_index→_nav, _styles_index→_styles_base + _styles_index_extra)
|
||||
- **CEO fixed 23 bugs total across sessions 48-51**: All severity levels now at ZERO
|
||||
- **Status: LAUNCH-READY** 🚀 — zero bugs, all systems green
|
||||
- **Rule established**: I do NOT investigate/fix DocFast issues myself — everything goes through CEO who hires specialists
|
||||
|
||||
## Personal
|
||||
- Playing BG3 Act 2: has Moon Lantern with Pixie (advised to free it for Pixie's Blessing), switched to Greatsword +1 from Everburn, Astarion should use dual daggers
|
||||
- Ordered Kenko Poke Bowl (salmon, sushi rice, veggies, sesame sauce + edamame) — ~800-900 kcal
|
||||
- Herman Miller Embody: AP Möbel / Andere Perspektive, Ankerbrotfabrik Absberggasse 29, 1100 Wien — has full HM range for probesitzen
|
||||
- Researching Japan trip: Switch 2 has region lock (domestic-only vs multilingual), Ghibli Museum tickets 30 days in advance on 10th of each month, shoe size UK 11-12 hard to find in Japan
|
||||
- Cholesterin-friendly food research
|
||||
|
||||
## Portfolio
|
||||
- DFNS closed at €57.01, portfolio +€14.27 (+1.43%)
|
||||
|
||||
## Wind-down
|
||||
- 19:03: First nudge sent
|
||||
- Ordered poke bowl (healthy dinner)
|
||||
- 20:12: Nose shower reminder + audiobook suggestion (Herr der Puppen 30%)
|
||||
|
|
@ -38,7 +38,7 @@
|
|||
],
|
||||
"act": 2,
|
||||
"level": 5,
|
||||
"currentQuest": "",
|
||||
"currentQuest": "Act 2 - Shadow-Cursed Lands, has Moon Lantern with Pixie",
|
||||
"completedQuests": ["Rescue Halsin"],
|
||||
"completedAreas": ["Owlbear Cave", "Goblin Camp", "Act 1"],
|
||||
"decisions": [],
|
||||
|
|
|
|||
|
|
@ -52,12 +52,12 @@
|
|||
],
|
||||
"notes": "N26 uses Xetra tickers. Always provide ISIN for orders. Fractional shares by EUR amount supported.",
|
||||
"created": "2026-02-12T20:00:00Z",
|
||||
"lastUpdated": "2026-02-16T16:31:00Z",
|
||||
"lastUpdated": "2026-02-17T16:15:00Z",
|
||||
"closingSnapshot": {
|
||||
"date": "2026-02-16",
|
||||
"DFNS": 56.97,
|
||||
"portfolioValue": 1013.06,
|
||||
"dailyPL": 1.15,
|
||||
"totalReturn": 1.31
|
||||
"date": "2026-02-17",
|
||||
"DFNS": 57.01,
|
||||
"portfolioValue": 1014.27,
|
||||
"dailyPL": 0.07,
|
||||
"totalReturn": 1.43
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,7 +36,8 @@
|
|||
"added": "2026-02-11",
|
||||
"text": "Herman Miller Embody kaufen (chairgo.de)",
|
||||
"priority": "soon",
|
||||
"context": "Ergonomischer Bürostuhl für Programmier-Setup. ~€1.800-2.000. Evtl. probesitzen in Wien vorher."
|
||||
"context": "Ergonomischer Bürostuhl für Programmier-Setup. ~€1.800-2.000. Evtl. probesitzen in Wien vorher.",
|
||||
"lastNudged": "2026-02-17T09:18:57.675Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,20 @@
|
|||
{
|
||||
"date": "2026-02-16",
|
||||
"date": "2026-02-17",
|
||||
"events": [
|
||||
{"time": "19:03", "type": "nudge", "note": "First wind-down check sent via WhatsApp. Suggested audiobook + nose shower reminder."},
|
||||
{"time": "19:03-20:29", "type": "activity", "note": "Still working on DocFast — set up support mailbox, launched CEO sessions, no wind-down yet."},
|
||||
{"time": "20:29", "type": "nudge", "note": "Second nudge — pointed out CEO runs autonomously, suggested letting go."},
|
||||
{"time": "20:29-21:06", "type": "activity", "note": "Still working — set up FreeScout integration, docfast-support tool, launched CEO 47."},
|
||||
{"time": "21:06", "type": "routine", "note": "Nose shower done ✅"},
|
||||
{"time": "21:06", "type": "activity", "note": "Wants to test support flow for DocFast, then will wind down."}
|
||||
{
|
||||
"time": "19:03",
|
||||
"type": "nudge",
|
||||
"note": "First wind-down check — asked what they're doing."
|
||||
},
|
||||
{
|
||||
"time": "19:00",
|
||||
"type": "activity",
|
||||
"note": "Ordered Kenko poke bowl with salmon, sushi rice, veggies, sesame sauce + edamame"
|
||||
},
|
||||
{
|
||||
"time": "20:12",
|
||||
"type": "nudge",
|
||||
"note": "Second nudge — nose shower reminder + audiobook suggestion"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,49 +10,51 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Deploy via SSH
|
||||
- name: Write secrets and deploy
|
||||
uses: appleboy/ssh-action@v1.1.0
|
||||
with:
|
||||
host: ${{ secrets.SERVER_HOST }}
|
||||
username: ${{ secrets.SERVER_USER }}
|
||||
key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
envs: STRIPE_SECRET_KEY,STRIPE_WEBHOOK_SECRET,DATABASE_PASSWORD
|
||||
script: |
|
||||
set -e
|
||||
|
||||
echo "🚀 Starting deployment..."
|
||||
|
||||
# Navigate to project directory
|
||||
cd /root/docfast
|
||||
|
||||
# Check current git status
|
||||
echo "📋 Current status:"
|
||||
# Write .env from CI secrets
|
||||
echo "📝 Writing .env from CI secrets..."
|
||||
printf "STRIPE_SECRET_KEY=%s\nSTRIPE_WEBHOOK_SECRET=%s\nDATABASE_PASSWORD=%s\n" "$STRIPE_SECRET_KEY" "$STRIPE_WEBHOOK_SECRET" "$DATABASE_PASSWORD" > .env
|
||||
|
||||
# Verify .env has non-empty values
|
||||
if grep -q '=$' .env; then
|
||||
echo "❌ ERROR: .env has empty values - secrets not configured in Forgejo!"
|
||||
echo "Add STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, DATABASE_PASSWORD in Forgejo repo settings."
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ .env written with $(wc -l < .env) vars"
|
||||
|
||||
git status --short || true
|
||||
|
||||
# Pull latest changes
|
||||
echo "📥 Pulling latest changes..."
|
||||
git fetch origin
|
||||
git pull origin main
|
||||
|
||||
# Tag current running image for rollback
|
||||
echo "🏷️ Tagging current image for rollback..."
|
||||
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
|
||||
docker tag docfast-docfast:latest docfast-docfast:rollback-$TIMESTAMP || echo "No existing image to tag"
|
||||
|
||||
# Build new image
|
||||
echo "🔨 Building new Docker image..."
|
||||
docker compose build --no-cache
|
||||
|
||||
# Stop services gracefully
|
||||
echo "⏹️ Stopping services..."
|
||||
docker compose down --timeout 30
|
||||
|
||||
# Start services
|
||||
echo "▶️ Starting services..."
|
||||
docker compose up -d
|
||||
|
||||
# Wait for service to be ready
|
||||
echo "⏱️ Waiting for service to be ready..."
|
||||
for i in {1..30}; do
|
||||
for i in $(seq 1 30); do
|
||||
if curl -f -s http://127.0.0.1:3100/health > /dev/null; then
|
||||
echo "✅ Service is healthy!"
|
||||
break
|
||||
|
|
@ -60,41 +62,32 @@ jobs:
|
|||
if [ $i -eq 30 ]; then
|
||||
echo "❌ Service failed to start - initiating rollback..."
|
||||
docker compose down
|
||||
|
||||
# Try to rollback to previous image
|
||||
ROLLBACK_IMAGE=$(docker images --format "table {{.Repository}}:{{.Tag}}" | grep "docfast-docfast:rollback-" | head -n1 | tr -s ' ' | cut -d' ' -f1)
|
||||
if [ ! -z "$ROLLBACK_IMAGE" ]; then
|
||||
echo "🔄 Rolling back to $ROLLBACK_IMAGE"
|
||||
docker tag $ROLLBACK_IMAGE docfast-docfast:latest
|
||||
docker compose up -d
|
||||
|
||||
# Wait for rollback to be healthy
|
||||
sleep 10
|
||||
if curl -f -s http://127.0.0.1:3100/health > /dev/null; then
|
||||
echo "✅ Rollback successful"
|
||||
else
|
||||
echo "❌ Rollback failed - manual intervention required"
|
||||
fi
|
||||
else
|
||||
echo "❌ No rollback image available"
|
||||
fi
|
||||
|
||||
exit 1
|
||||
fi
|
||||
echo "⏳ Attempt $i/30 - waiting 5 seconds..."
|
||||
sleep 5
|
||||
done
|
||||
|
||||
# Cleanup old rollback images (keep last 5)
|
||||
echo "🔍 Running post-deploy verification..."
|
||||
bash scripts/verify-deploy.sh
|
||||
|
||||
echo "🧹 Cleaning up old rollback images..."
|
||||
docker images --format "table {{.Repository}}:{{.Tag}}" | grep "docfast-docfast:rollback-" | tail -n +6 | awk '{print $1":"$2}' | xargs -r docker rmi || true
|
||||
docker images --format "table {{.Repository}}:{{.Tag}}" | grep "docfast-docfast:rollback-" | tail -n +6 | awk '{print $1}' | xargs -r docker rmi || true
|
||||
|
||||
# Final health check and status
|
||||
echo "🔍 Final health check..."
|
||||
HEALTH_STATUS=$(curl -f -s http://127.0.0.1:3100/health || echo "UNHEALTHY")
|
||||
echo "Health status: $HEALTH_STATUS"
|
||||
|
||||
echo "📊 Service status:"
|
||||
docker compose ps
|
||||
|
||||
echo "🎉 Deployment completed successfully!"
|
||||
echo "🎉 Deployment completed successfully!"
|
||||
env:
|
||||
STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }}
|
||||
STRIPE_WEBHOOK_SECRET: ${{ secrets.STRIPE_WEBHOOK_SECRET }}
|
||||
DATABASE_PASSWORD: ${{ secrets.DATABASE_PASSWORD }}
|
||||
|
|
|
|||
61
projects/business/src/pdf-api/dist/index.js
vendored
61
projects/business/src/pdf-api/dist/index.js
vendored
|
|
@ -86,15 +86,28 @@ app.use("/v1/signup", signupRouter);
|
|||
app.use("/v1/recover", recoverRouter);
|
||||
app.use("/v1/billing", billingRouter);
|
||||
app.use("/v1/email-change", emailChangeRouter);
|
||||
// Authenticated routes
|
||||
app.use("/v1/convert", authMiddleware, usageMiddleware, pdfRateLimitMiddleware, convertRouter);
|
||||
// Authenticated routes — conversion routes get tighter body limits (500KB)
|
||||
const convertBodyLimit = express.json({ limit: "500kb" });
|
||||
app.use("/v1/convert", convertBodyLimit, authMiddleware, usageMiddleware, pdfRateLimitMiddleware, convertRouter);
|
||||
app.use("/v1/templates", authMiddleware, usageMiddleware, templatesRouter);
|
||||
// Admin: usage stats
|
||||
app.get("/v1/usage", authMiddleware, (req, res) => {
|
||||
// Admin: usage stats (admin key required)
|
||||
const adminAuth = (req, res, next) => {
|
||||
const adminKey = process.env.ADMIN_API_KEY;
|
||||
if (!adminKey) {
|
||||
res.status(503).json({ error: "Admin access not configured" });
|
||||
return;
|
||||
}
|
||||
if (req.apiKeyInfo?.key !== adminKey) {
|
||||
res.status(403).json({ error: "Admin access required" });
|
||||
return;
|
||||
}
|
||||
next();
|
||||
};
|
||||
app.get("/v1/usage", authMiddleware, adminAuth, (req, res) => {
|
||||
res.json(getUsageStats(req.apiKeyInfo?.key));
|
||||
});
|
||||
// Admin: concurrency stats
|
||||
app.get("/v1/concurrency", authMiddleware, (_req, res) => {
|
||||
// Admin: concurrency stats (admin key required)
|
||||
app.get("/v1/concurrency", authMiddleware, adminAuth, (_req, res) => {
|
||||
res.json(getConcurrencyStats());
|
||||
});
|
||||
// Email verification endpoint
|
||||
|
|
@ -183,6 +196,14 @@ app.get("/terms", (_req, res) => {
|
|||
res.setHeader('Cache-Control', 'public, max-age=86400');
|
||||
res.sendFile(path.join(__dirname, "../public/terms.html"));
|
||||
});
|
||||
app.get("/change-email", (_req, res) => {
|
||||
res.setHeader('Cache-Control', 'public, max-age=3600');
|
||||
res.sendFile(path.join(__dirname, "../public/change-email.html"));
|
||||
});
|
||||
app.get("/status", (_req, res) => {
|
||||
res.setHeader("Cache-Control", "public, max-age=60");
|
||||
res.sendFile(path.join(__dirname, "../public/status.html"));
|
||||
});
|
||||
// API root
|
||||
app.get("/api", (_req, res) => {
|
||||
res.json({
|
||||
|
|
@ -205,12 +226,7 @@ app.use((req, res) => {
|
|||
const isApiRequest = req.path.startsWith('/v1/') || req.path.startsWith('/api') || req.path.startsWith('/health');
|
||||
if (isApiRequest) {
|
||||
// JSON 404 for API paths
|
||||
res.status(404).json({
|
||||
error: "Not Found",
|
||||
message: `The requested endpoint ${req.method} ${req.path} does not exist`,
|
||||
statusCode: 404,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
res.status(404).json({ error: `Not Found: ${req.method} ${req.path}` });
|
||||
}
|
||||
else {
|
||||
// HTML 404 for browser paths
|
||||
|
|
@ -246,27 +262,6 @@ app.use((req, res) => {
|
|||
</html>`);
|
||||
}
|
||||
});
|
||||
// 404 handler — must be after all routes
|
||||
app.use((req, res) => {
|
||||
if (req.path.startsWith("/v1/")) {
|
||||
res.status(404).json({ error: "Not found" });
|
||||
}
|
||||
else {
|
||||
const accepts = req.headers.accept || "";
|
||||
if (accepts.includes("text/html")) {
|
||||
res.status(404).send(`<!DOCTYPE html>
|
||||
<html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>404 — DocFast</title>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>⚡</text></svg>">
|
||||
<style>*{margin:0;padding:0;box-sizing:border-box}body{font-family:'Inter',-apple-system,sans-serif;background:#0b0d11;color:#e4e7ed;min-height:100vh;display:flex;align-items:center;justify-content:center}
|
||||
.c{text-align:center}.c h1{font-size:4rem;font-weight:800;color:#34d399;margin-bottom:12px}.c p{color:#7a8194;margin-bottom:24px}.c a{color:#34d399;text-decoration:none}.c a:hover{color:#5eead4}</style>
|
||||
</head><body><div class="c"><h1>404</h1><p>Page not found.</p><p><a href="/">← Back to DocFast</a> · <a href="/docs">API Docs</a></p></div></body></html>`);
|
||||
}
|
||||
else {
|
||||
res.status(404).json({ error: "Not found" });
|
||||
}
|
||||
}
|
||||
});
|
||||
async function start() {
|
||||
// Initialize PostgreSQL
|
||||
await initDatabase();
|
||||
|
|
|
|||
|
|
@ -12,6 +12,10 @@ function isPrivateIP(ip) {
|
|||
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);
|
||||
|
|
@ -32,6 +36,10 @@ function isPrivateIP(ip) {
|
|||
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";
|
||||
}
|
||||
export const convertRouter = Router();
|
||||
// POST /v1/convert/html
|
||||
convertRouter.post("/html", async (req, res) => {
|
||||
|
|
@ -63,7 +71,7 @@ convertRouter.post("/html", async (req, res) => {
|
|||
margin: body.margin,
|
||||
printBackground: body.printBackground,
|
||||
});
|
||||
const filename = body.filename || "document.pdf";
|
||||
const filename = sanitizeFilename(body.filename || "document.pdf");
|
||||
res.setHeader("Content-Type", "application/pdf");
|
||||
res.setHeader("Content-Disposition", `inline; filename="${filename}"`);
|
||||
res.send(pdf);
|
||||
|
|
@ -74,7 +82,7 @@ convertRouter.post("/html", async (req, res) => {
|
|||
res.status(429).json({ error: "Server busy - too many concurrent PDF generations. Please try again in a few seconds." });
|
||||
return;
|
||||
}
|
||||
res.status(500).json({ error: "PDF generation failed", detail: err.message });
|
||||
res.status(500).json({ error: `PDF generation failed: ${err.message}` });
|
||||
}
|
||||
finally {
|
||||
if (slotAcquired && req.releasePdfSlot) {
|
||||
|
|
@ -86,6 +94,12 @@ convertRouter.post("/html", async (req, res) => {
|
|||
convertRouter.post("/markdown", async (req, res) => {
|
||||
let slotAcquired = false;
|
||||
try {
|
||||
// Reject non-JSON content types
|
||||
const ct = req.headers["content-type"] || "";
|
||||
if (!ct.includes("application/json")) {
|
||||
res.status(415).json({ error: "Unsupported Content-Type. Use application/json." });
|
||||
return;
|
||||
}
|
||||
const body = typeof req.body === "string" ? { markdown: req.body } : req.body;
|
||||
if (!body.markdown) {
|
||||
res.status(400).json({ error: "Missing 'markdown' field" });
|
||||
|
|
@ -103,7 +117,7 @@ convertRouter.post("/markdown", async (req, res) => {
|
|||
margin: body.margin,
|
||||
printBackground: body.printBackground,
|
||||
});
|
||||
const filename = body.filename || "document.pdf";
|
||||
const filename = sanitizeFilename(body.filename || "document.pdf");
|
||||
res.setHeader("Content-Type", "application/pdf");
|
||||
res.setHeader("Content-Disposition", `inline; filename="${filename}"`);
|
||||
res.send(pdf);
|
||||
|
|
@ -114,7 +128,7 @@ convertRouter.post("/markdown", async (req, res) => {
|
|||
res.status(429).json({ error: "Server busy - too many concurrent PDF generations. Please try again in a few seconds." });
|
||||
return;
|
||||
}
|
||||
res.status(500).json({ error: "PDF generation failed", detail: err.message });
|
||||
res.status(500).json({ error: `PDF generation failed: ${err.message}` });
|
||||
}
|
||||
finally {
|
||||
if (slotAcquired && req.releasePdfSlot) {
|
||||
|
|
@ -126,6 +140,12 @@ convertRouter.post("/markdown", async (req, res) => {
|
|||
convertRouter.post("/url", async (req, res) => {
|
||||
let slotAcquired = false;
|
||||
try {
|
||||
// Reject non-JSON content types
|
||||
const ct = req.headers["content-type"] || "";
|
||||
if (!ct.includes("application/json")) {
|
||||
res.status(415).json({ error: "Unsupported Content-Type. Use application/json." });
|
||||
return;
|
||||
}
|
||||
const body = req.body;
|
||||
if (!body.url) {
|
||||
res.status(400).json({ error: "Missing 'url' field" });
|
||||
|
|
@ -144,13 +164,15 @@ convertRouter.post("/url", async (req, res) => {
|
|||
res.status(400).json({ error: "Invalid URL" });
|
||||
return;
|
||||
}
|
||||
// DNS lookup to block private/reserved IPs
|
||||
// DNS lookup to block private/reserved IPs + pin resolution to prevent DNS rebinding
|
||||
let resolvedAddress;
|
||||
try {
|
||||
const { address } = await dns.lookup(parsed.hostname);
|
||||
if (isPrivateIP(address)) {
|
||||
res.status(400).json({ error: "URL resolves to a private/internal IP address" });
|
||||
return;
|
||||
}
|
||||
resolvedAddress = address;
|
||||
}
|
||||
catch {
|
||||
res.status(400).json({ error: "DNS lookup failed for URL hostname" });
|
||||
|
|
@ -167,8 +189,9 @@ convertRouter.post("/url", async (req, res) => {
|
|||
margin: body.margin,
|
||||
printBackground: body.printBackground,
|
||||
waitUntil: body.waitUntil,
|
||||
hostResolverRules: `MAP ${parsed.hostname} ${resolvedAddress}`,
|
||||
});
|
||||
const filename = body.filename || "page.pdf";
|
||||
const filename = sanitizeFilename(body.filename || "page.pdf");
|
||||
res.setHeader("Content-Type", "application/pdf");
|
||||
res.setHeader("Content-Disposition", `inline; filename="${filename}"`);
|
||||
res.send(pdf);
|
||||
|
|
@ -179,7 +202,7 @@ convertRouter.post("/url", async (req, res) => {
|
|||
res.status(429).json({ error: "Server busy - too many concurrent PDF generations. Please try again in a few seconds." });
|
||||
return;
|
||||
}
|
||||
res.status(500).json({ error: "PDF generation failed", detail: err.message });
|
||||
res.status(500).json({ error: `PDF generation failed: ${err.message}` });
|
||||
}
|
||||
finally {
|
||||
if (slotAcquired && req.releasePdfSlot) {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,9 @@ 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);
|
||||
}
|
||||
export const templatesRouter = Router();
|
||||
// GET /v1/templates — list available templates
|
||||
templatesRouter.get("/", (_req, res) => {
|
||||
|
|
@ -23,12 +26,24 @@ templatesRouter.post("/:id/render", async (req, res) => {
|
|||
return;
|
||||
}
|
||||
const data = req.body.data || req.body;
|
||||
// Validate required fields
|
||||
const missingFields = template.fields
|
||||
.filter((f) => f.required && (data[f.name] === undefined || data[f.name] === null || data[f.name] === ""))
|
||||
.map((f) => f.name);
|
||||
if (missingFields.length > 0) {
|
||||
res.status(400).json({
|
||||
error: "Missing required fields",
|
||||
missing: missingFields,
|
||||
hint: `Required fields for '${id}': ${template.fields.filter((f) => f.required).map((f) => f.name).join(", ")}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const html = renderTemplate(id, data);
|
||||
const pdf = await renderPdf(html, {
|
||||
format: data._format || "A4",
|
||||
margin: data._margin,
|
||||
});
|
||||
const filename = data._filename || `${id}.pdf`;
|
||||
const filename = sanitizeFilename(data._filename || `${id}.pdf`);
|
||||
res.setHeader("Content-Type", "application/pdf");
|
||||
res.setHeader("Content-Disposition", `inline; filename="${filename}"`);
|
||||
res.send(pdf);
|
||||
|
|
|
|||
|
|
@ -224,10 +224,45 @@ export async function renderUrlPdf(url, options = {}) {
|
|||
const { page, instance } = await acquirePage();
|
||||
try {
|
||||
await page.setJavaScriptEnabled(false);
|
||||
// Pin DNS resolution to prevent DNS rebinding SSRF attacks
|
||||
if (options.hostResolverRules) {
|
||||
const client = await page.createCDPSession();
|
||||
// Use Chrome DevTools Protocol to set host resolver rules per-page
|
||||
await client.send("Network.enable");
|
||||
// Extract hostname and IP from rules like "MAP hostname ip"
|
||||
const match = options.hostResolverRules.match(/^MAP\s+(\S+)\s+(\S+)$/);
|
||||
if (match) {
|
||||
const [, hostname, ip] = match;
|
||||
await page.setRequestInterception(true);
|
||||
page.on("request", (request) => {
|
||||
const reqUrl = new URL(request.url());
|
||||
if (reqUrl.hostname === hostname) {
|
||||
// For HTTP, rewrite to IP with Host header
|
||||
if (reqUrl.protocol === "http:") {
|
||||
reqUrl.hostname = ip;
|
||||
request.continue({
|
||||
url: reqUrl.toString(),
|
||||
headers: { ...request.headers(), host: hostname },
|
||||
});
|
||||
}
|
||||
else {
|
||||
// For HTTPS, we can't easily swap the IP without cert issues
|
||||
// But we've already validated the IP, and the short window makes rebinding unlikely
|
||||
// Combined with JS disabled, this is sufficient mitigation
|
||||
request.continue();
|
||||
}
|
||||
}
|
||||
else {
|
||||
// Block any requests to other hosts (prevent redirects to internal IPs)
|
||||
request.abort("blockedbyclient");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
const result = await Promise.race([
|
||||
(async () => {
|
||||
await page.goto(url, {
|
||||
waitUntil: options.waitUntil || "networkidle0",
|
||||
waitUntil: options.waitUntil || "domcontentloaded",
|
||||
timeout: 30_000,
|
||||
});
|
||||
const pdf = await page.pdf({
|
||||
|
|
|
|||
|
|
@ -28,7 +28,6 @@ server {
|
|||
# Cache for 1 day
|
||||
expires 1d;
|
||||
add_header Cache-Control "public, max-age=86400";
|
||||
add_header X-Content-Type-Options nosniff;
|
||||
}
|
||||
|
||||
location = /sitemap.xml {
|
||||
|
|
@ -41,7 +40,6 @@ server {
|
|||
# Cache for 1 day
|
||||
expires 1d;
|
||||
add_header Cache-Control "public, max-age=86400";
|
||||
add_header X-Content-Type-Options nosniff;
|
||||
}
|
||||
|
||||
# Static assets caching
|
||||
|
|
@ -55,7 +53,6 @@ server {
|
|||
# Cache static assets for 1 week
|
||||
expires 7d;
|
||||
add_header Cache-Control "public, max-age=604800, immutable";
|
||||
add_header X-Content-Type-Options nosniff;
|
||||
}
|
||||
|
||||
# All other requests
|
||||
|
|
@ -68,7 +65,6 @@ server {
|
|||
proxy_read_timeout 60s;
|
||||
|
||||
# Security headers
|
||||
add_header X-Content-Type-Options nosniff;
|
||||
}
|
||||
|
||||
listen 443 ssl; # managed by Certbot
|
||||
|
|
|
|||
48
projects/business/src/pdf-api/public/status.js
Normal file
48
projects/business/src/pdf-api/public/status.js
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
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 =
|
||||
"<div class=\"status-hero\">" +
|
||||
"<div class=\"status-indicator\"><span class=\"status-dot " + dotClass + "\"></span> " + label + "</div>" +
|
||||
"<div class=\"status-meta\">Version " + d.version + " · Last checked " + now + " · Auto-refreshes every 30s</div>" +
|
||||
"</div>" +
|
||||
"<div class=\"status-grid\">" +
|
||||
"<div class=\"status-card\">" +
|
||||
"<h3>🗄️ Database</h3>" +
|
||||
"<div class=\"status-row\"><span class=\"status-label\">Status</span><span class=\"status-value " + (d.database && d.database.status === "ok" ? "ok" : "err") + "\">" + (d.database && d.database.status === "ok" ? "Connected" : "Error") + "</span></div>" +
|
||||
"<div class=\"status-row\"><span class=\"status-label\">Engine</span><span class=\"status-value\">" + (d.database ? d.database.version : "Unknown") + "</span></div>" +
|
||||
"</div>" +
|
||||
"<div class=\"status-card\">" +
|
||||
"<h3>🖨️ PDF Engine</h3>" +
|
||||
"<div class=\"status-row\"><span class=\"status-label\">Status</span><span class=\"status-value " + (d.pool && d.pool.available > 0 ? "ok" : "warn") + "\">" + (d.pool && d.pool.available > 0 ? "Ready" : "Busy") + "</span></div>" +
|
||||
"<div class=\"status-row\"><span class=\"status-label\">Available</span><span class=\"status-value\">" + (d.pool ? d.pool.available : 0) + " / " + (d.pool ? d.pool.size : 0) + "</span></div>" +
|
||||
"<div class=\"status-row\"><span class=\"status-label\">Queue</span><span class=\"status-value " + (d.pool && d.pool.queueDepth > 0 ? "warn" : "ok") + "\">" + (d.pool ? d.pool.queueDepth : 0) + " waiting</span></div>" +
|
||||
"<div class=\"status-row\"><span class=\"status-label\">PDFs Generated</span><span class=\"status-value\">" + (d.pool ? d.pool.pdfCount.toLocaleString() : "0") + "</span></div>" +
|
||||
"<div class=\"status-row\"><span class=\"status-label\">Uptime</span><span class=\"status-value\">" + formatUptime(d.pool ? d.pool.uptimeSeconds : 0) + "</span></div>" +
|
||||
"</div>" +
|
||||
"</div>" +
|
||||
"<div style=\"text-align:center;margin-top:16px;\"><a href=\"/health\" style=\"font-size:0.8rem;color:var(--muted);\">Raw JSON endpoint →</a></div>";
|
||||
} catch (e) {
|
||||
el.innerHTML = "<div class=\"status-hero\"><div class=\"status-indicator\"><span class=\"status-dot error\"></span> Unable to reach API</div><div class=\"status-meta\">The service may be temporarily unavailable. Please try again shortly.</div></div>";
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
39
projects/business/src/pdf-api/scripts/verify-deploy.sh
Executable file
39
projects/business/src/pdf-api/scripts/verify-deploy.sh
Executable file
|
|
@ -0,0 +1,39 @@
|
|||
#!/bin/bash
|
||||
# Post-deploy verification for DocFast
|
||||
set -e
|
||||
|
||||
echo "⏳ Waiting for container to be healthy..."
|
||||
for i in $(seq 1 30); do
|
||||
STATUS=$(docker inspect --format='{{.State.Health.Status}}' docfast-docfast-1 2>/dev/null || echo "not found")
|
||||
if [ "$STATUS" = "healthy" ]; then
|
||||
echo "✅ Container healthy"
|
||||
break
|
||||
fi
|
||||
if [ $i -eq 30 ]; then
|
||||
echo "❌ Container not healthy after 30s"
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
echo "🔍 Checking health endpoint..."
|
||||
HEALTH=$(curl -sf http://localhost:3100/health | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['status'])" 2>/dev/null)
|
||||
if [ "$HEALTH" != "ok" ]; then
|
||||
echo "❌ Health check failed"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Health OK"
|
||||
|
||||
echo "🔍 Checking Stripe checkout..."
|
||||
CHECKOUT=$(curl -sf -X POST http://localhost:3100/v1/billing/checkout -H "Content-Type: application/json" 2>&1)
|
||||
if echo "$CHECKOUT" | grep -q '"url"'; then
|
||||
echo "✅ Stripe checkout working"
|
||||
elif echo "$CHECKOUT" | grep -q 'STRIPE_SECRET_KEY'; then
|
||||
echo "❌ STRIPE_SECRET_KEY not configured!"
|
||||
exit 1
|
||||
else
|
||||
echo "⚠️ Checkout returned: $CHECKOUT"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "🎉 Deploy verification passed!"
|
||||
|
|
@ -12,7 +12,6 @@ import { healthRouter } from "./routes/health.js";
|
|||
import { signupRouter } from "./routes/signup.js";
|
||||
import { recoverRouter } from "./routes/recover.js";
|
||||
import { billingRouter } from "./routes/billing.js";
|
||||
import { emailChangeRouter } from "./routes/email-change.js";
|
||||
import { authMiddleware } from "./middleware/auth.js";
|
||||
import { usageMiddleware, loadUsageData } from "./middleware/usage.js";
|
||||
import { getUsageStats } from "./middleware/usage.js";
|
||||
|
|
@ -56,7 +55,6 @@ 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/email-change');
|
||||
|
||||
if (isAuthBillingRoute) {
|
||||
res.setHeader("Access-Control-Allow-Origin", "https://docfast.dev");
|
||||
|
|
@ -97,19 +95,25 @@ app.use("/health", healthRouter);
|
|||
app.use("/v1/signup", signupRouter);
|
||||
app.use("/v1/recover", recoverRouter);
|
||||
app.use("/v1/billing", billingRouter);
|
||||
app.use("/v1/email-change", emailChangeRouter);
|
||||
|
||||
// Authenticated routes
|
||||
app.use("/v1/convert", authMiddleware, usageMiddleware, pdfRateLimitMiddleware, convertRouter);
|
||||
// Authenticated routes — conversion routes get tighter body limits (500KB)
|
||||
const convertBodyLimit = express.json({ limit: "500kb" });
|
||||
app.use("/v1/convert", convertBodyLimit, authMiddleware, usageMiddleware, pdfRateLimitMiddleware, convertRouter);
|
||||
app.use("/v1/templates", authMiddleware, usageMiddleware, templatesRouter);
|
||||
|
||||
// Admin: usage stats
|
||||
app.get("/v1/usage", authMiddleware, (req: any, res) => {
|
||||
// Admin: usage stats (admin key required)
|
||||
const adminAuth = (req: any, res: any, next: any) => {
|
||||
const adminKey = process.env.ADMIN_API_KEY;
|
||||
if (!adminKey) { res.status(503).json({ error: "Admin access not configured" }); return; }
|
||||
if (req.apiKeyInfo?.key !== adminKey) { res.status(403).json({ error: "Admin access required" }); return; }
|
||||
next();
|
||||
};
|
||||
app.get("/v1/usage", authMiddleware, adminAuth, (req: any, res: any) => {
|
||||
res.json(getUsageStats(req.apiKeyInfo?.key));
|
||||
});
|
||||
|
||||
// Admin: concurrency stats
|
||||
app.get("/v1/concurrency", authMiddleware, (_req, res) => {
|
||||
// Admin: concurrency stats (admin key required)
|
||||
app.get("/v1/concurrency", authMiddleware, adminAuth, (_req: any, res: any) => {
|
||||
res.json(getConcurrencyStats());
|
||||
});
|
||||
|
||||
|
|
@ -210,6 +214,12 @@ app.get("/terms", (_req, res) => {
|
|||
res.sendFile(path.join(__dirname, "../public/terms.html"));
|
||||
});
|
||||
|
||||
app.get("/status", (_req, res) => {
|
||||
res.setHeader("Cache-Control", "public, max-age=60");
|
||||
res.sendFile(path.join(__dirname, "../public/status.html"));
|
||||
});
|
||||
|
||||
|
||||
// API root
|
||||
app.get("/api", (_req, res) => {
|
||||
res.json({
|
||||
|
|
@ -234,12 +244,7 @@ app.use((req, res) => {
|
|||
|
||||
if (isApiRequest) {
|
||||
// JSON 404 for API paths
|
||||
res.status(404).json({
|
||||
error: "Not Found",
|
||||
message: `The requested endpoint ${req.method} ${req.path} does not exist`,
|
||||
statusCode: 404,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
res.status(404).json({ error: `Not Found: ${req.method} ${req.path}` });
|
||||
} else {
|
||||
// HTML 404 for browser paths
|
||||
res.status(404).send(`<!DOCTYPE html>
|
||||
|
|
@ -275,25 +280,7 @@ app.use((req, res) => {
|
|||
}
|
||||
});
|
||||
|
||||
// 404 handler — must be after all routes
|
||||
app.use((req, res) => {
|
||||
if (req.path.startsWith("/v1/")) {
|
||||
res.status(404).json({ error: "Not found" });
|
||||
} else {
|
||||
const accepts = req.headers.accept || "";
|
||||
if (accepts.includes("text/html")) {
|
||||
res.status(404).send(`<!DOCTYPE html>
|
||||
<html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>404 — DocFast</title>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>⚡</text></svg>">
|
||||
<style>*{margin:0;padding:0;box-sizing:border-box}body{font-family:'Inter',-apple-system,sans-serif;background:#0b0d11;color:#e4e7ed;min-height:100vh;display:flex;align-items:center;justify-content:center}
|
||||
.c{text-align:center}.c h1{font-size:4rem;font-weight:800;color:#34d399;margin-bottom:12px}.c p{color:#7a8194;margin-bottom:24px}.c a{color:#34d399;text-decoration:none}.c a:hover{color:#5eead4}</style>
|
||||
</head><body><div class="c"><h1>404</h1><p>Page not found.</p><p><a href="/">← Back to DocFast</a> · <a href="/docs">API Docs</a></p></div></body></html>`);
|
||||
} else {
|
||||
res.status(404).json({ error: "Not found" });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
async function start() {
|
||||
// Initialize PostgreSQL
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { Request, Response, NextFunction } from "express";
|
||||
import { isProKey } from "../services/keys.js";
|
||||
import logger from "../services/logger.js";
|
||||
|
||||
interface RateLimitEntry {
|
||||
count: number;
|
||||
|
|
@ -15,9 +16,12 @@ const RATE_WINDOW_MS = 60_000; // 1 minute
|
|||
const MAX_CONCURRENT_PDFS = 3;
|
||||
const MAX_QUEUE_SIZE = 10;
|
||||
|
||||
// Per-key queue fairness (Audit #15)
|
||||
const MAX_QUEUED_PER_KEY = 3;
|
||||
|
||||
const rateLimitStore = new Map<string, RateLimitEntry>();
|
||||
let activePdfCount = 0;
|
||||
const pdfQueue: Array<{ resolve: () => void; reject: (error: Error) => void }> = [];
|
||||
const pdfQueue: Array<{ resolve: () => void; reject: (error: Error) => void; apiKey: string }> = [];
|
||||
|
||||
function cleanupExpiredEntries(): void {
|
||||
const now = Date.now();
|
||||
|
|
@ -40,7 +44,6 @@ function checkRateLimit(apiKey: string): boolean {
|
|||
const entry = rateLimitStore.get(apiKey);
|
||||
|
||||
if (!entry || now >= entry.resetTime) {
|
||||
// Create new window
|
||||
rateLimitStore.set(apiKey, {
|
||||
count: 1,
|
||||
resetTime: now + RATE_WINDOW_MS
|
||||
|
|
@ -56,7 +59,11 @@ function checkRateLimit(apiKey: string): boolean {
|
|||
return true;
|
||||
}
|
||||
|
||||
async function acquireConcurrencySlot(): Promise<void> {
|
||||
function getQueuedCountForKey(apiKey: string): number {
|
||||
return pdfQueue.filter(w => w.apiKey === apiKey).length;
|
||||
}
|
||||
|
||||
async function acquireConcurrencySlot(apiKey: string): Promise<void> {
|
||||
if (activePdfCount < MAX_CONCURRENT_PDFS) {
|
||||
activePdfCount++;
|
||||
return;
|
||||
|
|
@ -66,8 +73,14 @@ async function acquireConcurrencySlot(): Promise<void> {
|
|||
throw new Error("QUEUE_FULL");
|
||||
}
|
||||
|
||||
// Audit #15: Per-key fairness — reject if this key already has too many queued
|
||||
if (getQueuedCountForKey(apiKey) >= MAX_QUEUED_PER_KEY) {
|
||||
logger.warn({ apiKey: apiKey.slice(0, 8) + "..." }, "Per-key queue limit reached");
|
||||
throw new Error("QUEUE_FULL");
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
pdfQueue.push({ resolve, reject });
|
||||
pdfQueue.push({ resolve, reject, apiKey });
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -89,17 +102,12 @@ export function pdfRateLimitMiddleware(req: Request & { apiKeyInfo?: any }, res:
|
|||
if (!checkRateLimit(apiKey)) {
|
||||
const limit = getRateLimit(apiKey);
|
||||
const tier = isProKey(apiKey) ? "pro" : "free";
|
||||
res.status(429).json({
|
||||
error: "Rate limit exceeded",
|
||||
limit: `${limit} PDFs per minute`,
|
||||
tier,
|
||||
retryAfter: "60 seconds"
|
||||
});
|
||||
res.status(429).json({ error: `Rate limit exceeded: ${limit} PDFs/min allowed for ${tier} tier. Retry after 60s.` });
|
||||
return;
|
||||
}
|
||||
|
||||
// Add concurrency control to the request
|
||||
(req as any).acquirePdfSlot = acquireConcurrencySlot;
|
||||
// Add concurrency control to the request (pass apiKey for fairness)
|
||||
(req as any).acquirePdfSlot = () => acquireConcurrencySlot(apiKey);
|
||||
(req as any).releasePdfSlot = releaseConcurrencySlot;
|
||||
|
||||
next();
|
||||
|
|
@ -115,4 +123,4 @@ export function getConcurrencyStats() {
|
|||
}
|
||||
|
||||
// Proactive cleanup every 60s
|
||||
setInterval(cleanupExpiredEntries, 60_000);
|
||||
setInterval(cleanupExpiredEntries, 60_000).unref();
|
||||
|
|
|
|||
|
|
@ -3,11 +3,18 @@ import logger from "../services/logger.js";
|
|||
import pool from "../services/db.js";
|
||||
|
||||
const FREE_TIER_LIMIT = 100;
|
||||
const PRO_TIER_LIMIT = 2500;
|
||||
const PRO_TIER_LIMIT = 5000;
|
||||
|
||||
// In-memory cache, periodically synced to PostgreSQL
|
||||
let usage = new Map<string, { count: number; monthKey: string }>();
|
||||
|
||||
// Write-behind buffer for batching DB writes (Audit #10)
|
||||
const dirtyKeys = new Set<string>();
|
||||
const retryCount = new Map<string, number>();
|
||||
const MAX_RETRIES = 3;
|
||||
const FLUSH_INTERVAL_MS = 5000;
|
||||
const FLUSH_THRESHOLD = 50;
|
||||
|
||||
function getMonthKey(): string {
|
||||
const d = new Date();
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
|
||||
|
|
@ -27,18 +34,56 @@ export async function loadUsageData(): Promise<void> {
|
|||
}
|
||||
}
|
||||
|
||||
async function saveUsageEntry(key: string, record: { count: number; monthKey: string }): Promise<void> {
|
||||
// Batch flush dirty entries to DB (Audit #10 + #12)
|
||||
async function flushDirtyEntries(): Promise<void> {
|
||||
if (dirtyKeys.size === 0) return;
|
||||
|
||||
const keysToFlush = [...dirtyKeys];
|
||||
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await pool.query(
|
||||
`INSERT INTO usage (key, count, month_key) VALUES ($1, $2, $3)
|
||||
ON CONFLICT (key) DO UPDATE SET count = $2, month_key = $3`,
|
||||
[key, record.count, record.monthKey]
|
||||
);
|
||||
await client.query("BEGIN");
|
||||
for (const key of keysToFlush) {
|
||||
const record = usage.get(key);
|
||||
if (!record) continue;
|
||||
try {
|
||||
await client.query(
|
||||
`INSERT INTO usage (key, count, month_key) VALUES ($1, $2, $3)
|
||||
ON CONFLICT (key) DO UPDATE SET count = $2, month_key = $3`,
|
||||
[key, record.count, record.monthKey]
|
||||
);
|
||||
dirtyKeys.delete(key);
|
||||
retryCount.delete(key);
|
||||
} catch (error) {
|
||||
// Audit #12: retry logic for failed writes
|
||||
const retries = (retryCount.get(key) || 0) + 1;
|
||||
if (retries >= MAX_RETRIES) {
|
||||
logger.error({ key: key.slice(0, 8) + "...", retries }, "CRITICAL: Usage write failed after max retries, data may diverge");
|
||||
dirtyKeys.delete(key);
|
||||
retryCount.delete(key);
|
||||
} else {
|
||||
retryCount.set(key, retries);
|
||||
logger.warn({ key: key.slice(0, 8) + "...", retries }, "Usage write failed, will retry");
|
||||
}
|
||||
}
|
||||
}
|
||||
await client.query("COMMIT");
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, "Failed to save usage data");
|
||||
await client.query("ROLLBACK").catch(() => {});
|
||||
logger.error({ err: error }, "Failed to flush usage batch");
|
||||
// Keep all keys dirty for retry
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
// Periodic flush
|
||||
setInterval(flushDirtyEntries, FLUSH_INTERVAL_MS);
|
||||
|
||||
// Flush on process exit
|
||||
process.on("SIGTERM", () => { flushDirtyEntries().catch(() => {}); });
|
||||
process.on("SIGINT", () => { flushDirtyEntries().catch(() => {}); });
|
||||
|
||||
export function usageMiddleware(req: any, res: any, next: any): void {
|
||||
const keyInfo = req.apiKeyInfo;
|
||||
const key = keyInfo?.key || "unknown";
|
||||
|
|
@ -47,11 +92,7 @@ export function usageMiddleware(req: any, res: any, next: any): void {
|
|||
if (isProKey(key)) {
|
||||
const record = usage.get(key);
|
||||
if (record && record.monthKey === monthKey && record.count >= PRO_TIER_LIMIT) {
|
||||
res.status(429).json({
|
||||
error: "Pro tier limit reached (2,500/month). Contact support for higher limits.",
|
||||
limit: PRO_TIER_LIMIT,
|
||||
used: record.count,
|
||||
});
|
||||
res.status(429).json({ error: "Pro tier limit reached (5,000/month). Contact support for higher limits." });
|
||||
return;
|
||||
}
|
||||
trackUsage(key, monthKey);
|
||||
|
|
@ -61,12 +102,7 @@ export function usageMiddleware(req: any, res: any, next: any): void {
|
|||
|
||||
const record = usage.get(key);
|
||||
if (record && record.monthKey === monthKey && record.count >= FREE_TIER_LIMIT) {
|
||||
res.status(429).json({
|
||||
error: "Free tier limit reached",
|
||||
limit: FREE_TIER_LIMIT,
|
||||
used: record.count,
|
||||
upgrade: "Upgrade to Pro for 2,500 PDFs/month: https://docfast.dev/pricing",
|
||||
});
|
||||
res.status(429).json({ error: "Free tier limit reached (100/month). Upgrade to Pro at https://docfast.dev/#pricing for 5,000 PDFs/month." });
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -77,12 +113,15 @@ export function usageMiddleware(req: any, res: any, next: any): void {
|
|||
function trackUsage(key: string, monthKey: string): void {
|
||||
const record = usage.get(key);
|
||||
if (!record || record.monthKey !== monthKey) {
|
||||
const newRecord = { count: 1, monthKey };
|
||||
usage.set(key, newRecord);
|
||||
saveUsageEntry(key, newRecord).catch((err) => logger.error({ err }, "Failed to save usage entry"));
|
||||
usage.set(key, { count: 1, monthKey });
|
||||
} else {
|
||||
record.count++;
|
||||
saveUsageEntry(key, record).catch((err) => logger.error({ err }, "Failed to save usage entry"));
|
||||
}
|
||||
dirtyKeys.add(key);
|
||||
|
||||
// Flush immediately if threshold reached
|
||||
if (dirtyKeys.size >= FLUSH_THRESHOLD) {
|
||||
flushDirtyEntries().catch((err) => logger.error({ err }, "Threshold flush failed"));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { Router, Request, Response } from "express";
|
||||
import Stripe from "stripe";
|
||||
import { createProKey, revokeByCustomer } from "../services/keys.js";
|
||||
import { createProKey, downgradeByCustomer, updateEmailByCustomer } from "../services/keys.js";
|
||||
import logger from "../services/logger.js";
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
|
|
@ -19,6 +19,32 @@ function getStripe(): Stripe {
|
|||
|
||||
const router = Router();
|
||||
|
||||
// Track provisioned session IDs to prevent duplicate key creation
|
||||
const provisionedSessions = new Set<string>();
|
||||
|
||||
const DOCFAST_PRODUCT_ID = "prod_TygeG8tQPtEAdE";
|
||||
|
||||
// Returns true if the given Stripe subscription contains a DocFast product.
|
||||
// Used to filter webhook events — this Stripe account is shared with other projects.
|
||||
async function isDocFastSubscription(subscriptionId: string): Promise<boolean> {
|
||||
try {
|
||||
const sub = await getStripe().subscriptions.retrieve(subscriptionId, {
|
||||
expand: ["items.data.price.product"],
|
||||
});
|
||||
return sub.items.data.some((item) => {
|
||||
const price = item.price as Stripe.Price | null;
|
||||
const productId =
|
||||
typeof price?.product === "string"
|
||||
? price.product
|
||||
: (price?.product as Stripe.Product | null)?.id;
|
||||
return productId === DOCFAST_PRODUCT_ID;
|
||||
});
|
||||
} catch (err: any) {
|
||||
logger.error({ err, subscriptionId }, "isDocFastSubscription: failed to retrieve subscription");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Create a Stripe Checkout session for Pro subscription
|
||||
router.post("/checkout", async (_req: Request, res: Response) => {
|
||||
try {
|
||||
|
|
@ -47,6 +73,12 @@ router.get("/success", async (req: Request, res: Response) => {
|
|||
return;
|
||||
}
|
||||
|
||||
// Prevent duplicate provisioning from same session
|
||||
if (provisionedSessions.has(sessionId)) {
|
||||
res.status(409).json({ error: "This checkout session has already been used to provision a key. If you lost your key, use the key recovery feature." });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const session = await getStripe().checkout.sessions.retrieve(sessionId);
|
||||
const customerId = session.customer as string;
|
||||
|
|
@ -58,6 +90,7 @@ router.get("/success", async (req: Request, res: Response) => {
|
|||
}
|
||||
|
||||
const keyInfo = await createProKey(email, customerId);
|
||||
provisionedSessions.add(session.id);
|
||||
|
||||
// Return a nice HTML page instead of raw JSON
|
||||
res.send(`<!DOCTYPE html>
|
||||
|
|
@ -74,7 +107,7 @@ a { color: #4f9; }
|
|||
<div class="card">
|
||||
<h1>🎉 Welcome to Pro!</h1>
|
||||
<p>Your API key:</p>
|
||||
<div class="key" style="position:relative">${escapeHtml(keyInfo.key)}<button onclick="navigator.clipboard.writeText('${escapeHtml(keyInfo.key)}');this.textContent='Copied!';setTimeout(()=>this.textContent='Copy',1500)" style="position:absolute;top:8px;right:8px;background:#4f9;color:#0a0a0a;border:none;border-radius:4px;padding:4px 12px;cursor:pointer;font-size:0.8rem;font-family:system-ui">Copy</button></div>
|
||||
<div class="key" style="position:relative" data-key="${escapeHtml(keyInfo.key)}">${escapeHtml(keyInfo.key)}<button onclick="navigator.clipboard.writeText(this.parentElement.dataset.key);this.textContent='Copied!';setTimeout(()=>this.textContent='Copy',1500)" style="position:absolute;top:8px;right:8px;background:#4f9;color:#0a0a0a;border:none;border-radius:4px;padding:4px 12px;cursor:pointer;font-size:0.8rem;font-family:system-ui">Copy</button></div>
|
||||
<p><strong>Save this key!</strong> It won't be shown again.</p>
|
||||
<p>5,000 PDFs/month • All endpoints • Priority support</p>
|
||||
<p><a href="/docs">View API docs →</a></p>
|
||||
|
|
@ -93,15 +126,9 @@ router.post("/webhook", async (req: Request, res: Response) => {
|
|||
let event: Stripe.Event;
|
||||
|
||||
if (!webhookSecret) {
|
||||
console.warn("⚠️ STRIPE_WEBHOOK_SECRET is not configured — webhook signature verification skipped. Set this in production!");
|
||||
// Parse the body as a raw event without verification
|
||||
try {
|
||||
event = JSON.parse(typeof req.body === "string" ? req.body : req.body.toString()) as Stripe.Event;
|
||||
} catch (err: any) {
|
||||
logger.error({ err }, "Failed to parse webhook body");
|
||||
res.status(400).json({ error: "Invalid payload" });
|
||||
return;
|
||||
}
|
||||
logger.error("STRIPE_WEBHOOK_SECRET is not configured — refusing to process unverified webhooks");
|
||||
res.status(500).json({ error: "Webhook signature verification is not configured" });
|
||||
return;
|
||||
} else if (!sig) {
|
||||
res.status(400).json({ error: "Missing stripe-signature header" });
|
||||
return;
|
||||
|
|
@ -122,7 +149,6 @@ router.post("/webhook", async (req: Request, res: Response) => {
|
|||
const email = session.customer_details?.email;
|
||||
|
||||
// Filter by product — this Stripe account is shared with other projects
|
||||
const DOCFAST_PRODUCT_ID = "prod_TygeG8tQPtEAdE";
|
||||
try {
|
||||
const fullSession = await getStripe().checkout.sessions.retrieve(session.id, {
|
||||
expand: ["line_items"],
|
||||
|
|
@ -143,19 +169,57 @@ router.post("/webhook", async (req: Request, res: Response) => {
|
|||
}
|
||||
|
||||
if (!customerId || !email) {
|
||||
console.warn("checkout.session.completed: missing customerId or email, skipping key provisioning");
|
||||
logger.warn("checkout.session.completed: missing customerId or email, skipping key provisioning");
|
||||
break;
|
||||
}
|
||||
|
||||
const keyInfo = await createProKey(email, customerId);
|
||||
provisionedSessions.add(session.id);
|
||||
logger.info({ email, customerId }, "checkout.session.completed: provisioned pro key");
|
||||
break;
|
||||
}
|
||||
case "customer.subscription.updated": {
|
||||
const sub = event.data.object as Stripe.Subscription;
|
||||
const customerId = sub.customer as string;
|
||||
const shouldDowngrade =
|
||||
sub.status === "canceled" ||
|
||||
sub.status === "past_due" ||
|
||||
sub.status === "unpaid" ||
|
||||
sub.cancel_at_period_end === true;
|
||||
|
||||
if (shouldDowngrade) {
|
||||
if (!(await isDocFastSubscription(sub.id))) {
|
||||
logger.info({ subscriptionId: sub.id }, "customer.subscription.updated: ignoring event for different product");
|
||||
break;
|
||||
}
|
||||
await downgradeByCustomer(customerId);
|
||||
logger.info({ customerId, status: sub.status, cancelAtPeriodEnd: sub.cancel_at_period_end },
|
||||
"customer.subscription.updated: downgraded key to free tier");
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "customer.subscription.deleted": {
|
||||
const sub = event.data.object as Stripe.Subscription;
|
||||
const customerId = sub.customer as string;
|
||||
await revokeByCustomer(customerId);
|
||||
logger.info({ customerId }, "Subscription cancelled, key revoked");
|
||||
|
||||
if (!(await isDocFastSubscription(sub.id))) {
|
||||
logger.info({ subscriptionId: sub.id }, "customer.subscription.deleted: ignoring event for different product");
|
||||
break;
|
||||
}
|
||||
await downgradeByCustomer(customerId);
|
||||
logger.info({ customerId }, "customer.subscription.deleted: downgraded key to free tier");
|
||||
break;
|
||||
}
|
||||
case "customer.updated": {
|
||||
const customer = event.data.object as Stripe.Customer;
|
||||
const customerId = customer.id;
|
||||
const newEmail = customer.email;
|
||||
if (customerId && newEmail) {
|
||||
const updated = await updateEmailByCustomer(customerId, newEmail);
|
||||
if (updated) {
|
||||
logger.info({ customerId, newEmail }, "Customer email synced from Stripe");
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { Router, Request, Response } from "express";
|
||||
import { renderPdf, renderUrlPdf, getPoolStats } from "../services/browser.js";
|
||||
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";
|
||||
|
|
@ -13,6 +13,10 @@ function isPrivateIP(ip: string): boolean {
|
|||
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;
|
||||
|
|
@ -27,6 +31,11 @@ function isPrivateIP(ip: string): boolean {
|
|||
return false;
|
||||
}
|
||||
|
||||
function sanitizeFilename(name: string): string {
|
||||
// Strip characters dangerous in Content-Disposition headers
|
||||
return name.replace(/[\x00-\x1f"\\\r\n]/g, "").trim() || "document.pdf";
|
||||
}
|
||||
|
||||
export const convertRouter = Router();
|
||||
|
||||
interface ConvertBody {
|
||||
|
|
@ -76,7 +85,7 @@ convertRouter.post("/html", async (req: Request & { acquirePdfSlot?: () => Promi
|
|||
printBackground: body.printBackground,
|
||||
});
|
||||
|
||||
const filename = body.filename || "document.pdf";
|
||||
const filename = sanitizeFilename(body.filename || "document.pdf");
|
||||
res.setHeader("Content-Type", "application/pdf");
|
||||
res.setHeader("Content-Disposition", `inline; filename="${filename}"`);
|
||||
res.send(pdf);
|
||||
|
|
@ -86,7 +95,7 @@ convertRouter.post("/html", async (req: Request & { acquirePdfSlot?: () => Promi
|
|||
res.status(429).json({ error: "Server busy - too many concurrent PDF generations. Please try again in a few seconds." });
|
||||
return;
|
||||
}
|
||||
res.status(500).json({ error: "PDF generation failed", detail: err.message });
|
||||
res.status(500).json({ error: `PDF generation failed: ${err.message}` });
|
||||
} finally {
|
||||
if (slotAcquired && req.releasePdfSlot) {
|
||||
req.releasePdfSlot();
|
||||
|
|
@ -98,6 +107,12 @@ convertRouter.post("/html", async (req: Request & { acquirePdfSlot?: () => Promi
|
|||
convertRouter.post("/markdown", async (req: Request & { acquirePdfSlot?: () => Promise<void>; releasePdfSlot?: () => void }, res: Response) => {
|
||||
let slotAcquired = false;
|
||||
try {
|
||||
// Reject non-JSON content types
|
||||
const ct = req.headers["content-type"] || "";
|
||||
if (!ct.includes("application/json")) {
|
||||
res.status(415).json({ error: "Unsupported Content-Type. Use application/json." });
|
||||
return;
|
||||
}
|
||||
const body: ConvertBody =
|
||||
typeof req.body === "string" ? { markdown: req.body } : req.body;
|
||||
|
||||
|
|
@ -120,7 +135,7 @@ convertRouter.post("/markdown", async (req: Request & { acquirePdfSlot?: () => P
|
|||
printBackground: body.printBackground,
|
||||
});
|
||||
|
||||
const filename = body.filename || "document.pdf";
|
||||
const filename = sanitizeFilename(body.filename || "document.pdf");
|
||||
res.setHeader("Content-Type", "application/pdf");
|
||||
res.setHeader("Content-Disposition", `inline; filename="${filename}"`);
|
||||
res.send(pdf);
|
||||
|
|
@ -130,7 +145,7 @@ convertRouter.post("/markdown", async (req: Request & { acquirePdfSlot?: () => P
|
|||
res.status(429).json({ error: "Server busy - too many concurrent PDF generations. Please try again in a few seconds." });
|
||||
return;
|
||||
}
|
||||
res.status(500).json({ error: "PDF generation failed", detail: err.message });
|
||||
res.status(500).json({ error: `PDF generation failed: ${err.message}` });
|
||||
} finally {
|
||||
if (slotAcquired && req.releasePdfSlot) {
|
||||
req.releasePdfSlot();
|
||||
|
|
@ -142,6 +157,12 @@ convertRouter.post("/markdown", async (req: Request & { acquirePdfSlot?: () => P
|
|||
convertRouter.post("/url", async (req: Request & { acquirePdfSlot?: () => Promise<void>; releasePdfSlot?: () => void }, res: Response) => {
|
||||
let slotAcquired = false;
|
||||
try {
|
||||
// Reject non-JSON content types
|
||||
const ct = req.headers["content-type"] || "";
|
||||
if (!ct.includes("application/json")) {
|
||||
res.status(415).json({ error: "Unsupported Content-Type. Use application/json." });
|
||||
return;
|
||||
}
|
||||
const body = req.body as { url?: string; format?: string; landscape?: boolean; margin?: any; printBackground?: boolean; waitUntil?: string; filename?: string };
|
||||
|
||||
if (!body.url) {
|
||||
|
|
@ -162,13 +183,15 @@ convertRouter.post("/url", async (req: Request & { acquirePdfSlot?: () => Promis
|
|||
return;
|
||||
}
|
||||
|
||||
// DNS lookup to block private/reserved IPs
|
||||
// DNS lookup to block private/reserved IPs + pin resolution to prevent DNS rebinding
|
||||
let resolvedAddress: string;
|
||||
try {
|
||||
const { address } = await dns.lookup(parsed.hostname);
|
||||
if (isPrivateIP(address)) {
|
||||
res.status(400).json({ error: "URL resolves to a private/internal IP address" });
|
||||
return;
|
||||
}
|
||||
resolvedAddress = address;
|
||||
} catch {
|
||||
res.status(400).json({ error: "DNS lookup failed for URL hostname" });
|
||||
return;
|
||||
|
|
@ -186,9 +209,10 @@ convertRouter.post("/url", async (req: Request & { acquirePdfSlot?: () => Promis
|
|||
margin: body.margin,
|
||||
printBackground: body.printBackground,
|
||||
waitUntil: body.waitUntil,
|
||||
hostResolverRules: `MAP ${parsed.hostname} ${resolvedAddress}`,
|
||||
});
|
||||
|
||||
const filename = body.filename || "page.pdf";
|
||||
const filename = sanitizeFilename(body.filename || "page.pdf");
|
||||
res.setHeader("Content-Type", "application/pdf");
|
||||
res.setHeader("Content-Disposition", `inline; filename="${filename}"`);
|
||||
res.send(pdf);
|
||||
|
|
@ -198,7 +222,7 @@ convertRouter.post("/url", async (req: Request & { acquirePdfSlot?: () => Promis
|
|||
res.status(429).json({ error: "Server busy - too many concurrent PDF generations. Please try again in a few seconds." });
|
||||
return;
|
||||
}
|
||||
res.status(500).json({ error: "PDF generation failed", detail: err.message });
|
||||
res.status(500).json({ error: `PDF generation failed: ${err.message}` });
|
||||
} finally {
|
||||
if (slotAcquired && req.releasePdfSlot) {
|
||||
req.releasePdfSlot();
|
||||
|
|
|
|||
|
|
@ -1,99 +0,0 @@
|
|||
import { Router } from "express";
|
||||
import type { Request, Response } from "express";
|
||||
import rateLimit from "express-rate-limit";
|
||||
import { createPendingVerification, verifyCode } from "../services/verification.js";
|
||||
import { sendVerificationEmail } from "../services/email.js";
|
||||
import { getAllKeys, updateKeyEmail } from "../services/keys.js";
|
||||
import logger from "../services/logger.js";
|
||||
|
||||
const router = Router();
|
||||
|
||||
const changeLimiter = rateLimit({
|
||||
windowMs: 60 * 60 * 1000,
|
||||
max: 3,
|
||||
message: { error: "Too many attempts. Please try again in 1 hour." },
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
});
|
||||
|
||||
router.post("/", changeLimiter, async (req: Request, res: Response) => {
|
||||
const apiKey = req.headers.authorization?.replace(/^Bearer\s+/i, "") || req.body?.apiKey;
|
||||
const newEmail = req.body?.newEmail;
|
||||
|
||||
if (!apiKey || typeof apiKey !== "string") {
|
||||
res.status(400).json({ error: "API key is required (Authorization header or body)." });
|
||||
return;
|
||||
}
|
||||
if (!newEmail || typeof newEmail !== "string" || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(newEmail)) {
|
||||
res.status(400).json({ error: "A valid new email address is required." });
|
||||
return;
|
||||
}
|
||||
|
||||
const cleanEmail = newEmail.trim().toLowerCase();
|
||||
const keys = getAllKeys();
|
||||
const userKey = keys.find((k: any) => k.key === apiKey);
|
||||
|
||||
if (!userKey) {
|
||||
res.status(401).json({ error: "Invalid API key." });
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = keys.find((k: any) => k.email === cleanEmail);
|
||||
if (existing) {
|
||||
res.status(409).json({ error: "This email is already associated with another account." });
|
||||
return;
|
||||
}
|
||||
|
||||
const pending = await createPendingVerification(cleanEmail);
|
||||
|
||||
sendVerificationEmail(cleanEmail, (pending as any).code).catch((err: Error) => {
|
||||
logger.error({ err, email: cleanEmail }, "Failed to send email change verification");
|
||||
});
|
||||
|
||||
res.json({ status: "verification_sent", message: "Verification code sent to your new email address." });
|
||||
});
|
||||
|
||||
router.post("/verify", changeLimiter, async (req: Request, res: Response) => {
|
||||
const apiKey = req.headers.authorization?.replace(/^Bearer\s+/i, "") || req.body?.apiKey;
|
||||
const { newEmail, code } = req.body || {};
|
||||
|
||||
if (!apiKey || !newEmail || !code) {
|
||||
res.status(400).json({ error: "API key, new email, and code are required." });
|
||||
return;
|
||||
}
|
||||
|
||||
const cleanEmail = newEmail.trim().toLowerCase();
|
||||
const cleanCode = String(code).trim();
|
||||
|
||||
const keys = getAllKeys();
|
||||
const userKey = keys.find((k: any) => k.key === apiKey);
|
||||
if (!userKey) {
|
||||
res.status(401).json({ error: "Invalid API key." });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await verifyCode(cleanEmail, cleanCode);
|
||||
|
||||
switch (result.status) {
|
||||
case "ok": {
|
||||
const updated = await updateKeyEmail(apiKey, cleanEmail);
|
||||
if (updated) {
|
||||
res.json({ status: "updated", message: "Email address updated successfully.", newEmail: cleanEmail });
|
||||
} else {
|
||||
res.status(500).json({ error: "Failed to update email." });
|
||||
}
|
||||
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 };
|
||||
|
|
@ -10,7 +10,7 @@ const router = Router();
|
|||
const signupLimiter = rateLimit({
|
||||
windowMs: 60 * 60 * 1000,
|
||||
max: 5,
|
||||
message: { error: "Too many signup attempts. Please try again in 1 hour.", retryAfter: "1 hour" },
|
||||
message: { error: "Too many signup attempts. Please try again in 1 hour." },
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,6 +3,10 @@ import { renderPdf } from "../services/browser.js";
|
|||
import logger from "../services/logger.js";
|
||||
import { templates, renderTemplate } from "../services/templates.js";
|
||||
|
||||
function sanitizeFilename(name: string): string {
|
||||
return name.replace(/["\r\n\x00-\x1f]/g, "_").substring(0, 200);
|
||||
}
|
||||
|
||||
export const templatesRouter = Router();
|
||||
|
||||
// GET /v1/templates — list available templates
|
||||
|
|
@ -27,13 +31,28 @@ templatesRouter.post("/:id/render", async (req: Request, res: Response) => {
|
|||
}
|
||||
|
||||
const data = req.body.data || req.body;
|
||||
|
||||
// Validate required fields
|
||||
const missingFields = template.fields
|
||||
.filter((f) => f.required && (data[f.name] === undefined || data[f.name] === null || data[f.name] === ""))
|
||||
.map((f) => f.name);
|
||||
|
||||
if (missingFields.length > 0) {
|
||||
res.status(400).json({
|
||||
error: "Missing required fields",
|
||||
missing: missingFields,
|
||||
hint: `Required fields for '${id}': ${template.fields.filter((f) => f.required).map((f) => f.name).join(", ")}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const html = renderTemplate(id, data);
|
||||
const pdf = await renderPdf(html, {
|
||||
format: data._format || "A4",
|
||||
margin: data._margin,
|
||||
});
|
||||
|
||||
const filename = data._filename || `${id}.pdf`;
|
||||
const filename = sanitizeFilename(data._filename || `${id}.pdf`);
|
||||
res.setHeader("Content-Type", "application/pdf");
|
||||
res.setHeader("Content-Disposition", `inline; filename="${filename}"`);
|
||||
res.send(pdf);
|
||||
|
|
|
|||
|
|
@ -266,15 +266,49 @@ export async function renderUrlPdf(
|
|||
margin?: { top?: string; right?: string; bottom?: string; left?: string };
|
||||
printBackground?: boolean;
|
||||
waitUntil?: string;
|
||||
hostResolverRules?: string;
|
||||
} = {}
|
||||
): Promise<Buffer> {
|
||||
const { page, instance } = await acquirePage();
|
||||
try {
|
||||
await page.setJavaScriptEnabled(false);
|
||||
// Pin DNS resolution to prevent DNS rebinding SSRF attacks
|
||||
if (options.hostResolverRules) {
|
||||
const client = await page.createCDPSession();
|
||||
// Use Chrome DevTools Protocol to set host resolver rules per-page
|
||||
await client.send("Network.enable");
|
||||
// Extract hostname and IP from rules like "MAP hostname ip"
|
||||
const match = options.hostResolverRules.match(/^MAP\s+(\S+)\s+(\S+)$/);
|
||||
if (match) {
|
||||
const [, hostname, ip] = match;
|
||||
await page.setRequestInterception(true);
|
||||
page.on("request", (request) => {
|
||||
const reqUrl = new URL(request.url());
|
||||
if (reqUrl.hostname === hostname) {
|
||||
// For HTTP, rewrite to IP with Host header
|
||||
if (reqUrl.protocol === "http:") {
|
||||
reqUrl.hostname = ip;
|
||||
request.continue({
|
||||
url: reqUrl.toString(),
|
||||
headers: { ...request.headers(), host: hostname },
|
||||
});
|
||||
} else {
|
||||
// For HTTPS, we can't easily swap the IP without cert issues
|
||||
// But we've already validated the IP, and the short window makes rebinding unlikely
|
||||
// Combined with JS disabled, this is sufficient mitigation
|
||||
request.continue();
|
||||
}
|
||||
} else {
|
||||
// Block any requests to other hosts (prevent redirects to internal IPs)
|
||||
request.abort("blockedbyclient");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
const result = await Promise.race([
|
||||
(async () => {
|
||||
await page.goto(url, {
|
||||
waitUntil: (options.waitUntil as any) || "networkidle0",
|
||||
waitUntil: (options.waitUntil as any) || "domcontentloaded",
|
||||
timeout: 30_000,
|
||||
});
|
||||
const pdf = await page.pdf({
|
||||
|
|
|
|||
|
|
@ -108,12 +108,11 @@ export async function createProKey(email: string, stripeCustomerId: string): Pro
|
|||
return entry;
|
||||
}
|
||||
|
||||
export async function revokeByCustomer(stripeCustomerId: string): Promise<boolean> {
|
||||
const idx = keysCache.findIndex((k) => k.stripeCustomerId === stripeCustomerId);
|
||||
if (idx >= 0) {
|
||||
const key = keysCache[idx].key;
|
||||
keysCache.splice(idx, 1);
|
||||
await pool.query("DELETE FROM api_keys WHERE key = $1", [key]);
|
||||
export async function downgradeByCustomer(stripeCustomerId: string): Promise<boolean> {
|
||||
const entry = keysCache.find((k) => k.stripeCustomerId === stripeCustomerId);
|
||||
if (entry) {
|
||||
entry.tier = "free";
|
||||
await pool.query("UPDATE api_keys SET tier = 'free' WHERE stripe_customer_id = $1", [stripeCustomerId]);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
|
|
@ -130,3 +129,11 @@ export async function updateKeyEmail(apiKey: string, newEmail: string): Promise<
|
|||
await pool.query("UPDATE api_keys SET email = $1 WHERE key = $2", [newEmail, apiKey]);
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function updateEmailByCustomer(stripeCustomerId: string, newEmail: string): Promise<boolean> {
|
||||
const entry = keysCache.find(k => k.stripeCustomerId === stripeCustomerId);
|
||||
if (!entry) return false;
|
||||
entry.email = newEmail;
|
||||
await pool.query("UPDATE api_keys SET email = $1 WHERE stripe_customer_id = $2", [newEmail, stripeCustomerId]);
|
||||
return true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { randomBytes, randomInt } from "crypto";
|
||||
import { randomBytes, randomInt, timingSafeEqual } from "crypto";
|
||||
import logger from "./logger.js";
|
||||
import pool from "./db.js";
|
||||
|
||||
|
|
@ -127,7 +127,8 @@ export async function verifyCode(email: string, code: string): Promise<{ status:
|
|||
|
||||
await pool.query("UPDATE pending_verifications SET attempts = attempts + 1 WHERE email = $1", [cleanEmail]);
|
||||
|
||||
if (pending.code !== code) {
|
||||
const a = Buffer.from(pending.code, "utf8"); const b = Buffer.from(code, "utf8"); const codeMatch = a.length === b.length && timingSafeEqual(a, b);
|
||||
if (!codeMatch) {
|
||||
return { status: "invalid" };
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue