memory: 2026-02-17 daily log

This commit is contained in:
Hoid 2026-02-17 21:49:00 +00:00
parent 6e5ca5dd0c
commit 3e37a420f6
26 changed files with 579 additions and 295 deletions

0
.env Normal file
View file

View 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
View 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%)

View file

@ -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": [],

View file

@ -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
}
}

View file

@ -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"
}
]
}

View file

@ -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"
}
]
}

View file

@ -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 }}

View file

@ -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();

View file

@ -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) {

View file

@ -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);

View file

@ -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({

View file

@ -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

View 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);

View 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!"

View file

@ -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

View file

@ -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();

View file

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

View file

@ -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:

View file

@ -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();

View file

@ -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 };

View file

@ -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,
});

View file

@ -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);

View file

@ -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({

View file

@ -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;
}

View file

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