From d8738db8ce7871b052f545705d7b462fb00ca6df Mon Sep 17 00:00:00 2001 From: Hoid Date: Sat, 14 Feb 2026 13:16:28 +0000 Subject: [PATCH] =?UTF-8?q?Business=20agent:=20deployment=20details=20?= =?UTF-8?q?=E2=80=94=20NixOS/Podman/ARM64,=20domain=20docfast.dev?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- projects/business/memory/state.json | 29 ++++---- projects/business/src/pdf-api/package.json | 3 +- projects/business/src/pdf-api/src/index.ts | 12 +++- .../src/pdf-api/src/middleware/usage.ts | 68 +++++++++++++++++++ .../src/pdf-api/src/routes/convert.ts | 42 +++++++++++- .../src/pdf-api/src/services/browser.ts | 37 ++++++++++ 6 files changed, 174 insertions(+), 17 deletions(-) create mode 100644 projects/business/src/pdf-api/src/middleware/usage.ts diff --git a/projects/business/memory/state.json b/projects/business/memory/state.json index 0397a9f..0325893 100644 --- a/projects/business/memory/state.json +++ b/projects/business/memory/state.json @@ -1,18 +1,21 @@ { "phase": 1, - "phaseLabel": "Build MVP", - "status": "deployment-ready", - "product": "DocFast — HTML/Markdown/URL to PDF API", - "currentPriority": "Deploy to server. Buy domain. Set up Stripe for payments.", - "humanFeedback": "User created the Forgejo repo. Will host on existing server.", - "blockers": [ - "Need domain (docfast.dev or similar)", - "Need Stripe account for payments", - "Need deployment instructions (which server, docker access?)" - ], - "resolved": [ - "Forgejo push — fixed by using ssh://forgejo@ URL" - ], + "phaseLabel": "Build MVP — Deployment", + "status": "ready-to-deploy", + "product": "DocFast — HTML/Markdown to PDF API", + "currentPriority": "Prepare deployment for NixOS + Podman on ARM64. Domain docfast.dev is bought (INWX). Stripe keys will be in /home/openclaw/.openclaw/workspace/.credentials/docfast.env (NEVER read this file — source at runtime only). Update Dockerfile for ARM64 compatibility. Create podman-compose or deployment script. Soft-launch free tier first, add Stripe billing once keys are filled in.", + "infrastructure": { + "domain": "docfast.dev", + "registrar": "INWX", + "server": "NixOS ARM64", + "containerRuntime": "podman", + "arch": "arm64" + }, + "credentials": { + "stripeKeys": "/home/openclaw/.openclaw/workspace/.credentials/docfast.env", + "NEVER_READ_DIRECTLY": true + }, + "blockers": [], "startDate": "2026-02-14", "sessionCount": 5 } diff --git a/projects/business/src/pdf-api/package.json b/projects/business/src/pdf-api/package.json index 8c27dd3..96edde8 100644 --- a/projects/business/src/pdf-api/package.json +++ b/projects/business/src/pdf-api/package.json @@ -23,5 +23,6 @@ "@types/express": "^5.0.0", "@types/node": "^22.0.0", "vitest": "^3.0.0" - } + }, + "type": "module" } diff --git a/projects/business/src/pdf-api/src/index.ts b/projects/business/src/pdf-api/src/index.ts index a144da3..98ea04c 100644 --- a/projects/business/src/pdf-api/src/index.ts +++ b/projects/business/src/pdf-api/src/index.ts @@ -7,6 +7,8 @@ import { convertRouter } from "./routes/convert.js"; import { templatesRouter } from "./routes/templates.js"; import { healthRouter } from "./routes/health.js"; import { authMiddleware } from "./middleware/auth.js"; +import { usageMiddleware } from "./middleware/usage.js"; +import { getUsageStats } from "./middleware/usage.js"; import { initBrowser, closeBrowser } from "./services/browser.js"; const app = express(); @@ -29,8 +31,13 @@ app.use(limiter); app.use("/health", healthRouter); // Authenticated -app.use("/v1/convert", authMiddleware, convertRouter); -app.use("/v1/templates", authMiddleware, templatesRouter); +app.use("/v1/convert", authMiddleware, usageMiddleware, convertRouter); +app.use("/v1/templates", authMiddleware, usageMiddleware, templatesRouter); + +// Admin: usage stats (protected by auth) +app.get("/v1/usage", authMiddleware, (_req, res) => { + res.json(getUsageStats()); +}); // Landing page const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -45,6 +52,7 @@ app.get("/api", (_req, res) => { endpoints: [ "POST /v1/convert/html", "POST /v1/convert/markdown", + "POST /v1/convert/url", "POST /v1/templates/:id/render", "GET /v1/templates", ], diff --git a/projects/business/src/pdf-api/src/middleware/usage.ts b/projects/business/src/pdf-api/src/middleware/usage.ts new file mode 100644 index 0000000..576c88c --- /dev/null +++ b/projects/business/src/pdf-api/src/middleware/usage.ts @@ -0,0 +1,68 @@ +import { Request, Response, NextFunction } from "express"; + +interface UsageRecord { + count: number; + monthKey: string; +} + +// In-memory usage tracking (replace with Redis/DB for production) +const usage = new Map(); + +const FREE_TIER_LIMIT = 50; // 50 PDFs/month for free tier +const PRO_KEYS = new Set( + (process.env.PRO_KEYS || "").split(",").map((k) => k.trim()).filter(Boolean) +); + +function getMonthKey(): string { + const d = new Date(); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`; +} + +export function usageMiddleware( + req: Request, + res: Response, + next: NextFunction +): void { + const key = req.headers.authorization?.slice(7) || "unknown"; + const monthKey = getMonthKey(); + + // Pro keys have no limit + if (PRO_KEYS.has(key)) { + trackUsage(key, monthKey); + next(); + return; + } + + // Free tier limit check + 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 unlimited conversions: https://docfast.dev/pricing", + }); + return; + } + + trackUsage(key, monthKey); + next(); +} + +function trackUsage(key: string, monthKey: string): void { + const record = usage.get(key); + if (!record || record.monthKey !== monthKey) { + usage.set(key, { count: 1, monthKey }); + } else { + record.count++; + } +} + +export function getUsageStats(): Record { + const stats: Record = {}; + for (const [key, record] of usage) { + const masked = key.slice(0, 8) + "..."; + stats[masked] = { count: record.count, month: record.monthKey }; + } + return stats; +} diff --git a/projects/business/src/pdf-api/src/routes/convert.ts b/projects/business/src/pdf-api/src/routes/convert.ts index fac1000..d546a3a 100644 --- a/projects/business/src/pdf-api/src/routes/convert.ts +++ b/projects/business/src/pdf-api/src/routes/convert.ts @@ -1,5 +1,5 @@ import { Router, Request, Response } from "express"; -import { renderPdf } from "../services/browser.js"; +import { renderPdf, renderUrlPdf } from "../services/browser.js"; import { markdownToHtml, wrapHtml } from "../services/markdown.js"; export const convertRouter = Router(); @@ -76,3 +76,43 @@ convertRouter.post("/markdown", async (req: Request, res: Response) => { res.status(500).json({ error: "PDF generation failed", detail: err.message }); } }); + +// POST /v1/convert/url +convertRouter.post("/url", async (req: Request, res: Response) => { + try { + const body = req.body as { url?: string; format?: string; landscape?: boolean; margin?: any; printBackground?: boolean; waitUntil?: string; filename?: string }; + + if (!body.url) { + res.status(400).json({ error: "Missing 'url' field" }); + return; + } + + // Basic URL validation + try { + const parsed = new URL(body.url); + if (!["http:", "https:"].includes(parsed.protocol)) { + res.status(400).json({ error: "Only http/https URLs are supported" }); + return; + } + } catch { + res.status(400).json({ error: "Invalid URL" }); + return; + } + + const pdf = await renderUrlPdf(body.url, { + format: body.format, + landscape: body.landscape, + margin: body.margin, + printBackground: body.printBackground, + waitUntil: body.waitUntil, + }); + + const filename = body.filename || "page.pdf"; + res.setHeader("Content-Type", "application/pdf"); + res.setHeader("Content-Disposition", `inline; filename="${filename}"`); + res.send(pdf); + } catch (err: any) { + console.error("Convert URL error:", err); + res.status(500).json({ error: "PDF generation failed", detail: err.message }); + } +}); diff --git a/projects/business/src/pdf-api/src/services/browser.ts b/projects/business/src/pdf-api/src/services/browser.ts index 6d9d5d4..448de6b 100644 --- a/projects/business/src/pdf-api/src/services/browser.ts +++ b/projects/business/src/pdf-api/src/services/browser.ts @@ -52,3 +52,40 @@ export async function renderPdf( await page.close(); } } + +export async function renderUrlPdf( + url: string, + options: { + format?: string; + landscape?: boolean; + margin?: { top?: string; right?: string; bottom?: string; left?: string }; + printBackground?: boolean; + waitUntil?: string; + } = {} +): Promise { + if (!browser) throw new Error("Browser not initialized"); + + const page: Page = await browser.newPage(); + try { + await page.goto(url, { + waitUntil: (options.waitUntil as any) || "networkidle0", + timeout: 30_000, + }); + + const pdf = await page.pdf({ + format: (options.format as any) || "A4", + landscape: options.landscape || false, + printBackground: options.printBackground !== false, + margin: options.margin || { + top: "20mm", + right: "15mm", + bottom: "20mm", + left: "15mm", + }, + }); + + return Buffer.from(pdf); + } finally { + await page.close(); + } +}