Business agent: deployment details — NixOS/Podman/ARM64, domain docfast.dev
This commit is contained in:
parent
930a2e03cc
commit
d8738db8ce
6 changed files with 174 additions and 17 deletions
|
|
@ -1,18 +1,21 @@
|
||||||
{
|
{
|
||||||
"phase": 1,
|
"phase": 1,
|
||||||
"phaseLabel": "Build MVP",
|
"phaseLabel": "Build MVP — Deployment",
|
||||||
"status": "deployment-ready",
|
"status": "ready-to-deploy",
|
||||||
"product": "DocFast — HTML/Markdown/URL to PDF API",
|
"product": "DocFast — HTML/Markdown to PDF API",
|
||||||
"currentPriority": "Deploy to server. Buy domain. Set up Stripe for payments.",
|
"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.",
|
||||||
"humanFeedback": "User created the Forgejo repo. Will host on existing server.",
|
"infrastructure": {
|
||||||
"blockers": [
|
"domain": "docfast.dev",
|
||||||
"Need domain (docfast.dev or similar)",
|
"registrar": "INWX",
|
||||||
"Need Stripe account for payments",
|
"server": "NixOS ARM64",
|
||||||
"Need deployment instructions (which server, docker access?)"
|
"containerRuntime": "podman",
|
||||||
],
|
"arch": "arm64"
|
||||||
"resolved": [
|
},
|
||||||
"Forgejo push — fixed by using ssh://forgejo@ URL"
|
"credentials": {
|
||||||
],
|
"stripeKeys": "/home/openclaw/.openclaw/workspace/.credentials/docfast.env",
|
||||||
|
"NEVER_READ_DIRECTLY": true
|
||||||
|
},
|
||||||
|
"blockers": [],
|
||||||
"startDate": "2026-02-14",
|
"startDate": "2026-02-14",
|
||||||
"sessionCount": 5
|
"sessionCount": 5
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,5 +23,6 @@
|
||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
"vitest": "^3.0.0"
|
"vitest": "^3.0.0"
|
||||||
}
|
},
|
||||||
|
"type": "module"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@ import { convertRouter } from "./routes/convert.js";
|
||||||
import { templatesRouter } from "./routes/templates.js";
|
import { templatesRouter } from "./routes/templates.js";
|
||||||
import { healthRouter } from "./routes/health.js";
|
import { healthRouter } from "./routes/health.js";
|
||||||
import { authMiddleware } from "./middleware/auth.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";
|
import { initBrowser, closeBrowser } from "./services/browser.js";
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
@ -29,8 +31,13 @@ app.use(limiter);
|
||||||
app.use("/health", healthRouter);
|
app.use("/health", healthRouter);
|
||||||
|
|
||||||
// Authenticated
|
// Authenticated
|
||||||
app.use("/v1/convert", authMiddleware, convertRouter);
|
app.use("/v1/convert", authMiddleware, usageMiddleware, convertRouter);
|
||||||
app.use("/v1/templates", authMiddleware, templatesRouter);
|
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
|
// Landing page
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
@ -45,6 +52,7 @@ app.get("/api", (_req, res) => {
|
||||||
endpoints: [
|
endpoints: [
|
||||||
"POST /v1/convert/html",
|
"POST /v1/convert/html",
|
||||||
"POST /v1/convert/markdown",
|
"POST /v1/convert/markdown",
|
||||||
|
"POST /v1/convert/url",
|
||||||
"POST /v1/templates/:id/render",
|
"POST /v1/templates/:id/render",
|
||||||
"GET /v1/templates",
|
"GET /v1/templates",
|
||||||
],
|
],
|
||||||
|
|
|
||||||
68
projects/business/src/pdf-api/src/middleware/usage.ts
Normal file
68
projects/business/src/pdf-api/src/middleware/usage.ts
Normal file
|
|
@ -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<string, UsageRecord>();
|
||||||
|
|
||||||
|
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<string, { count: number; month: string }> {
|
||||||
|
const stats: Record<string, { count: number; month: string }> = {};
|
||||||
|
for (const [key, record] of usage) {
|
||||||
|
const masked = key.slice(0, 8) + "...";
|
||||||
|
stats[masked] = { count: record.count, month: record.monthKey };
|
||||||
|
}
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { Router, Request, Response } from "express";
|
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";
|
import { markdownToHtml, wrapHtml } from "../services/markdown.js";
|
||||||
|
|
||||||
export const convertRouter = Router();
|
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 });
|
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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -52,3 +52,40 @@ export async function renderPdf(
|
||||||
await page.close();
|
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<Buffer> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue