From a1c86b0ebc3d61359ea68f8dec453a5668861737 Mon Sep 17 00:00:00 2001 From: Hoid Date: Sat, 14 Feb 2026 13:46:50 +0000 Subject: [PATCH] business: DocFast deployed on Hetzner CAX11 (167.235.156.214) --- projects/business/memory/financials.json | 14 ++++- projects/business/memory/sessions.md | 15 +++++ projects/business/memory/state.json | 18 +++--- projects/business/src/pdf-api/Dockerfile | 14 ++--- projects/business/src/pdf-api/dist/index.js | 60 ++++++++++--------- .../src/pdf-api/dist/middleware/auth.js | 5 +- .../src/pdf-api/dist/routes/convert.js | 60 +++++++++++++++---- .../src/pdf-api/dist/routes/health.js | 9 +-- .../src/pdf-api/dist/routes/templates.js | 23 ++++--- .../src/pdf-api/dist/services/browser.js | 48 ++++++++++----- .../src/pdf-api/dist/services/markdown.js | 12 ++-- .../src/pdf-api/dist/services/templates.js | 10 +--- .../src/pdf-api/src/services/browser.ts | 4 +- 13 files changed, 181 insertions(+), 111 deletions(-) diff --git a/projects/business/memory/financials.json b/projects/business/memory/financials.json index 5aaa677..45e074f 100644 --- a/projects/business/memory/financials.json +++ b/projects/business/memory/financials.json @@ -1,9 +1,17 @@ { "budget": 200.00, "currency": "EUR", - "spent": 0.00, + "spent": 3.29, "revenue": 0.00, - "balance": 200.00, - "expenses": [], + "balance": 196.71, + "expenses": [ + { + "date": "2026-02-14", + "item": "Hetzner CAX11 server (docfast-1, nbg1)", + "amount": 3.29, + "recurring": "monthly", + "notes": "First month prorated" + } + ], "income": [] } diff --git a/projects/business/memory/sessions.md b/projects/business/memory/sessions.md index 144120f..636cbbd 100644 --- a/projects/business/memory/sessions.md +++ b/projects/business/memory/sessions.md @@ -58,6 +58,21 @@ - **Next:** Need human for: domain purchase, server deployment, Stripe setup. - **Blockers:** Domain, Stripe, deployment access — all require human action. +## Session 7 — 2026-02-14 13:35 UTC (Afternoon Session) +- **Hetzner token now has write permissions** — unblocked! +- Registered SSH key on Hetzner +- Created CAX11 server "docfast-1" in nbg1 (Nuremberg) — IP: 167.235.156.214, €3.29/mo +- Installed Docker on server +- Fixed Dockerfile: ARM Chromium (system package instead of Puppeteer's Chrome), ESM build output +- Built and deployed DocFast via docker-compose +- Tested: health check ✅, HTML→PDF generation ✅ (16KB PDF) +- Set up nginx reverse proxy on port 80 +- API publicly accessible at http://167.235.156.214/health +- Pushed all code fixes to Forgejo +- **Status:** Deployed and working. Needs DNS + SSL. +- **Next:** Human needs to point docfast.dev → 167.235.156.214 at INWX. Then certbot for SSL. Then Stripe. +- **Expenses:** ~€3.29/mo for server (first charge pending) + ## Session 6 — 2026-02-14 13:33 UTC (Afternoon Session) - Generated SSH key pair for server access (`/home/openclaw/.ssh/docfast`) - Tested Hetzner API token — **read-only permissions**. Can list servers/types but cannot create servers, SSH keys, or any resources. diff --git a/projects/business/memory/state.json b/projects/business/memory/state.json index 4614b6e..b903aef 100644 --- a/projects/business/memory/state.json +++ b/projects/business/memory/state.json @@ -1,22 +1,24 @@ { "phase": 1, - "phaseLabel": "Build MVP — Infrastructure Setup", - "status": "blocked-hetzner-permissions", + "phaseLabel": "Build MVP — Deployed, needs DNS + SSL", + "status": "deployed-needs-dns", "product": "DocFast — HTML/Markdown to PDF API", - "currentPriority": "Hetzner API token needs read+write permissions. Current token is read-only (can list but not create resources). Once fixed: spin up CAX11, deploy DocFast, set up HTTPS.", + "currentPriority": "DNS: Point docfast.dev A record to 167.235.156.214. Then SSL via certbot. Then Stripe integration for billing.", "infrastructure": { "domain": "docfast.dev", "registrar": "INWX", - "hosting": "Hetzner Cloud (API access needed with write permissions)", - "preferredVM": "CAX11 (ARM, 2 vCPU, 4GB, ~€3.29/mo)", - "sshKey": "/home/openclaw/.ssh/docfast" + "hosting": "Hetzner Cloud", + "server": "docfast-1 (CAX11, nbg1)", + "serverIP": "167.235.156.214", + "sshKey": "/home/openclaw/.ssh/docfast", + "apiKey": "df_live_9760e44a3e732be0f8628a44e0cdbc040107499f6e8f457a" }, "credentials": { "file": "/home/openclaw/.openclaw/workspace/.credentials/docfast.env", "keys": ["HETZNER_API_TOKEN", "STRIPE_PUBLISHABLE_KEY", "STRIPE_SECRET_KEY"], "NEVER_READ_DIRECTLY": true }, - "blockers": ["Hetzner API token is read-only — needs write permissions to create servers/SSH keys"], + "blockers": ["DNS: Need docfast.dev A record pointed to 167.235.156.214 (human action at INWX)"], "startDate": "2026-02-14", - "sessionCount": 6 + "sessionCount": 7 } diff --git a/projects/business/src/pdf-api/Dockerfile b/projects/business/src/pdf-api/Dockerfile index b75fc11..bdc953a 100644 --- a/projects/business/src/pdf-api/Dockerfile +++ b/projects/business/src/pdf-api/Dockerfile @@ -1,19 +1,19 @@ -FROM node:22-slim +FROM node:22-bookworm-slim -# Chrome deps +# Install Chromium (works on ARM and x86) RUN apt-get update && apt-get install -y --no-install-recommends \ - libatk1.0-0t64 libatk-bridge2.0-0t64 libcups2t64 libdrm2 \ - libxkbcommon0 libxcomposite1 libxdamage1 libxfixes3 libxrandr2 \ - libgbm1 libpango-1.0-0 libpangocairo-1.0-0 libcairo2 \ - libasound2t64 libnspr4 libnss3 fonts-liberation \ + chromium fonts-liberation \ && rm -rf /var/lib/apt/lists/* +ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true +ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium + WORKDIR /app COPY package*.json ./ RUN npm ci --omit=dev COPY dist/ dist/ +COPY public/ public/ ENV PORT=3100 EXPOSE 3100 -USER node CMD ["node", "dist/index.js"] diff --git a/projects/business/src/pdf-api/dist/index.js b/projects/business/src/pdf-api/dist/index.js index d146d88..b76c407 100644 --- a/projects/business/src/pdf-api/dist/index.js +++ b/projects/business/src/pdf-api/dist/index.js @@ -1,25 +1,22 @@ -"use strict"; -var __importDefault = (this && this.__importDefault) || function (mod) { - return (mod && mod.__esModule) ? mod : { "default": mod }; -}; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.app = void 0; -const express_1 = __importDefault(require("express")); -const helmet_1 = __importDefault(require("helmet")); -const express_rate_limit_1 = __importDefault(require("express-rate-limit")); -const convert_js_1 = require("./routes/convert.js"); -const templates_js_1 = require("./routes/templates.js"); -const health_js_1 = require("./routes/health.js"); -const auth_js_1 = require("./middleware/auth.js"); -const browser_js_1 = require("./services/browser.js"); -const app = (0, express_1.default)(); -exports.app = app; +import express from "express"; +import helmet from "helmet"; +import path from "path"; +import { fileURLToPath } from "url"; +import rateLimit from "express-rate-limit"; +import { convertRouter } from "./routes/convert.js"; +import { templatesRouter } from "./routes/templates.js"; +import { healthRouter } from "./routes/health.js"; +import { 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(); const PORT = parseInt(process.env.PORT || "3100", 10); -app.use((0, helmet_1.default)()); -app.use(express_1.default.json({ limit: "2mb" })); -app.use(express_1.default.text({ limit: "2mb", type: "text/*" })); +app.use(helmet()); +app.use(express.json({ limit: "2mb" })); +app.use(express.text({ limit: "2mb", type: "text/*" })); // Rate limiting: 100 req/min for free tier -const limiter = (0, express_rate_limit_1.default)({ +const limiter = rateLimit({ windowMs: 60_000, max: 100, standardHeaders: true, @@ -27,12 +24,19 @@ const limiter = (0, express_rate_limit_1.default)({ }); app.use(limiter); // Public -app.use("/health", health_js_1.healthRouter); +app.use("/health", healthRouter); // Authenticated -app.use("/v1/convert", auth_js_1.authMiddleware, convert_js_1.convertRouter); -app.use("/v1/templates", auth_js_1.authMiddleware, templates_js_1.templatesRouter); -// Root -app.get("/", (_req, res) => { +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)); +app.use(express.static(path.join(__dirname, "../public"))); +// API root (for programmatic discovery) +app.get("/api", (_req, res) => { res.json({ name: "DocFast API", version: "0.1.0", @@ -40,17 +44,18 @@ app.get("/", (_req, res) => { endpoints: [ "POST /v1/convert/html", "POST /v1/convert/markdown", + "POST /v1/convert/url", "POST /v1/templates/:id/render", "GET /v1/templates", ], }); }); async function start() { - await (0, browser_js_1.initBrowser)(); + await initBrowser(); app.listen(PORT, () => console.log(`DocFast API running on :${PORT}`)); const shutdown = async () => { console.log("Shutting down..."); - await (0, browser_js_1.closeBrowser)(); + await closeBrowser(); process.exit(0); }; process.on("SIGTERM", shutdown); @@ -60,3 +65,4 @@ start().catch((err) => { console.error("Failed to start:", err); process.exit(1); }); +export { app }; diff --git a/projects/business/src/pdf-api/dist/middleware/auth.js b/projects/business/src/pdf-api/dist/middleware/auth.js index 4e43ccf..b82e91d 100644 --- a/projects/business/src/pdf-api/dist/middleware/auth.js +++ b/projects/business/src/pdf-api/dist/middleware/auth.js @@ -1,8 +1,5 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.authMiddleware = authMiddleware; const API_KEYS = new Set((process.env.API_KEYS || "test-key-123").split(",").map((k) => k.trim())); -function authMiddleware(req, res, next) { +export function authMiddleware(req, res, next) { const header = req.headers.authorization; if (!header?.startsWith("Bearer ")) { res.status(401).json({ error: "Missing API key. Use: Authorization: Bearer " }); diff --git a/projects/business/src/pdf-api/dist/routes/convert.js b/projects/business/src/pdf-api/dist/routes/convert.js index 2f0de30..a59b1e6 100644 --- a/projects/business/src/pdf-api/dist/routes/convert.js +++ b/projects/business/src/pdf-api/dist/routes/convert.js @@ -1,12 +1,9 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.convertRouter = void 0; -const express_1 = require("express"); -const browser_js_1 = require("../services/browser.js"); -const markdown_js_1 = require("../services/markdown.js"); -exports.convertRouter = (0, express_1.Router)(); +import { Router } from "express"; +import { renderPdf, renderUrlPdf } from "../services/browser.js"; +import { markdownToHtml, wrapHtml } from "../services/markdown.js"; +export const convertRouter = Router(); // POST /v1/convert/html -exports.convertRouter.post("/html", async (req, res) => { +convertRouter.post("/html", async (req, res) => { try { const body = typeof req.body === "string" ? { html: req.body } : req.body; if (!body.html) { @@ -16,8 +13,8 @@ exports.convertRouter.post("/html", async (req, res) => { // Wrap bare HTML fragments const fullHtml = body.html.includes(" { } }); // POST /v1/convert/markdown -exports.convertRouter.post("/markdown", async (req, res) => { +convertRouter.post("/markdown", async (req, res) => { try { const body = typeof req.body === "string" ? { markdown: req.body } : req.body; if (!body.markdown) { res.status(400).json({ error: "Missing 'markdown' field" }); return; } - const html = (0, markdown_js_1.markdownToHtml)(body.markdown, body.css); - const pdf = await (0, browser_js_1.renderPdf)(html, { + const html = markdownToHtml(body.markdown, body.css); + const pdf = await renderPdf(html, { format: body.format, landscape: body.landscape, margin: body.margin, @@ -58,3 +55,40 @@ exports.convertRouter.post("/markdown", async (req, res) => { res.status(500).json({ error: "PDF generation failed", detail: err.message }); } }); +// POST /v1/convert/url +convertRouter.post("/url", async (req, res) => { + try { + const body = req.body; + 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) { + 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/dist/routes/health.js b/projects/business/src/pdf-api/dist/routes/health.js index 9b70c8f..62c6344 100644 --- a/projects/business/src/pdf-api/dist/routes/health.js +++ b/projects/business/src/pdf-api/dist/routes/health.js @@ -1,8 +1,5 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.healthRouter = void 0; -const express_1 = require("express"); -exports.healthRouter = (0, express_1.Router)(); -exports.healthRouter.get("/", (_req, res) => { +import { Router } from "express"; +export const healthRouter = Router(); +healthRouter.get("/", (_req, res) => { res.json({ status: "ok", version: "0.1.0" }); }); diff --git a/projects/business/src/pdf-api/dist/routes/templates.js b/projects/business/src/pdf-api/dist/routes/templates.js index a879d52..21eebc3 100644 --- a/projects/business/src/pdf-api/dist/routes/templates.js +++ b/projects/business/src/pdf-api/dist/routes/templates.js @@ -1,13 +1,10 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.templatesRouter = void 0; -const express_1 = require("express"); -const browser_js_1 = require("../services/browser.js"); -const templates_js_1 = require("../services/templates.js"); -exports.templatesRouter = (0, express_1.Router)(); +import { Router } from "express"; +import { renderPdf } from "../services/browser.js"; +import { templates, renderTemplate } from "../services/templates.js"; +export const templatesRouter = Router(); // GET /v1/templates — list available templates -exports.templatesRouter.get("/", (_req, res) => { - const list = Object.entries(templates_js_1.templates).map(([id, t]) => ({ +templatesRouter.get("/", (_req, res) => { + const list = Object.entries(templates).map(([id, t]) => ({ id, name: t.name, description: t.description, @@ -16,17 +13,17 @@ exports.templatesRouter.get("/", (_req, res) => { res.json({ templates: list }); }); // POST /v1/templates/:id/render — render template to PDF -exports.templatesRouter.post("/:id/render", async (req, res) => { +templatesRouter.post("/:id/render", async (req, res) => { try { const id = req.params.id; - const template = templates_js_1.templates[id]; + const template = templates[id]; if (!template) { res.status(404).json({ error: `Template '${id}' not found` }); return; } const data = req.body; - const html = (0, templates_js_1.renderTemplate)(id, data); - const pdf = await (0, browser_js_1.renderPdf)(html, { + const html = renderTemplate(id, data); + const pdf = await renderPdf(html, { format: data._format || "A4", margin: data._margin, }); diff --git a/projects/business/src/pdf-api/dist/services/browser.js b/projects/business/src/pdf-api/dist/services/browser.js index cdb81fc..e450d96 100644 --- a/projects/business/src/pdf-api/dist/services/browser.js +++ b/projects/business/src/pdf-api/dist/services/browser.js @@ -1,25 +1,19 @@ -"use strict"; -var __importDefault = (this && this.__importDefault) || function (mod) { - return (mod && mod.__esModule) ? mod : { "default": mod }; -}; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.initBrowser = initBrowser; -exports.closeBrowser = closeBrowser; -exports.renderPdf = renderPdf; -const puppeteer_1 = __importDefault(require("puppeteer")); +import puppeteer from "puppeteer"; let browser = null; -async function initBrowser() { - browser = await puppeteer_1.default.launch({ +export async function initBrowser() { + const execPath = process.env.PUPPETEER_EXECUTABLE_PATH || undefined; + browser = await puppeteer.launch({ headless: true, - args: ["--no-sandbox", "--disable-setuid-sandbox", "--disable-gpu"], + executablePath: execPath, + args: ["--no-sandbox", "--disable-setuid-sandbox", "--disable-gpu", "--disable-dev-shm-usage"], }); console.log("Browser pool ready"); } -async function closeBrowser() { +export async function closeBrowser() { if (browser) await browser.close(); } -async function renderPdf(html, options = {}) { +export async function renderPdf(html, options = {}) { if (!browser) throw new Error("Browser not initialized"); const page = await browser.newPage(); @@ -45,3 +39,29 @@ async function renderPdf(html, options = {}) { await page.close(); } } +export async function renderUrlPdf(url, options = {}) { + if (!browser) + throw new Error("Browser not initialized"); + const page = await browser.newPage(); + try { + await page.goto(url, { + waitUntil: options.waitUntil || "networkidle0", + timeout: 30_000, + }); + const pdf = await page.pdf({ + format: options.format || "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(); + } +} diff --git a/projects/business/src/pdf-api/dist/services/markdown.js b/projects/business/src/pdf-api/dist/services/markdown.js index de89696..55d2b0a 100644 --- a/projects/business/src/pdf-api/dist/services/markdown.js +++ b/projects/business/src/pdf-api/dist/services/markdown.js @@ -1,8 +1,4 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.markdownToHtml = markdownToHtml; -exports.wrapHtml = wrapHtml; -const marked_1 = require("marked"); +import { marked } from "marked"; const defaultCss = ` body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; @@ -23,11 +19,11 @@ th { background: #f8f8f8; font-weight: 600; } blockquote { border-left: 4px solid #ddd; margin: 1em 0; padding: 0.5em 1em; color: #666; } img { max-width: 100%; } `; -function markdownToHtml(md, css) { - const html = marked_1.marked.parse(md, { async: false }); +export function markdownToHtml(md, css) { + const html = marked.parse(md, { async: false }); return wrapHtml(html, css || defaultCss); } -function wrapHtml(body, css) { +export function wrapHtml(body, css) { return ` ${body}`; diff --git a/projects/business/src/pdf-api/dist/services/templates.js b/projects/business/src/pdf-api/dist/services/templates.js index d30e822..151c4f4 100644 --- a/projects/business/src/pdf-api/dist/services/templates.js +++ b/projects/business/src/pdf-api/dist/services/templates.js @@ -1,8 +1,4 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.templates = void 0; -exports.renderTemplate = renderTemplate; -exports.templates = { +export const templates = { invoice: { name: "Invoice", description: "Professional invoice with line items, taxes, and payment details", @@ -159,8 +155,8 @@ function renderReceipt(d) {
Thank you!
`; } -function renderTemplate(id, data) { - const template = exports.templates[id]; +export function renderTemplate(id, data) { + const template = templates[id]; if (!template) throw new Error(`Template '${id}' not found`); return template.render(data); diff --git a/projects/business/src/pdf-api/src/services/browser.ts b/projects/business/src/pdf-api/src/services/browser.ts index 448de6b..acb831a 100644 --- a/projects/business/src/pdf-api/src/services/browser.ts +++ b/projects/business/src/pdf-api/src/services/browser.ts @@ -3,9 +3,11 @@ import puppeteer, { Browser, Page } from "puppeteer"; let browser: Browser | null = null; export async function initBrowser(): Promise { + const execPath = process.env.PUPPETEER_EXECUTABLE_PATH || undefined; browser = await puppeteer.launch({ headless: true, - args: ["--no-sandbox", "--disable-setuid-sandbox", "--disable-gpu"], + executablePath: execPath, + args: ["--no-sandbox", "--disable-setuid-sandbox", "--disable-gpu", "--disable-dev-shm-usage"], }); console.log("Browser pool ready"); }