/** * Pubs module — markdown → print-ready pocket books via Typst. * * Ported from pocket-press (Next.js) to Hono routes. * Stateless — no database needed. Artifacts stored in /tmp. */ import { Hono } from "hono"; import { resolve, join } from "node:path"; import { mkdir, writeFile, readFile, readdir, stat } from "node:fs/promises"; import { randomUUID } from "node:crypto"; import { createTransport, type Transporter } from "nodemailer"; import { parseMarkdown } from "./parse-document"; import { compileDocument } from "./typst-compile"; import { getFormat, FORMATS, listFormats } from "./formats"; import type { BookFormat } from "./formats"; import { generateImposition } from "./imposition"; import { discoverPrinters } from "./printer-discovery"; import { renderShell } from "../../server/shell"; import { getModuleInfoList } from "../../shared/module"; import type { RSpaceModule } from "../../shared/module"; import { renderLanding } from "./landing"; const ARTIFACTS_DIR = process.env.ARTIFACTS_DIR || "/tmp/rpubs-artifacts"; // ── SMTP ── let _smtpTransport: Transporter | null = null; function getSmtpTransport(): Transporter | null { if (_smtpTransport) return _smtpTransport; if (!process.env.SMTP_PASS) return null; _smtpTransport = createTransport({ host: process.env.SMTP_HOST || "mail.rmail.online", port: Number(process.env.SMTP_PORT) || 587, secure: Number(process.env.SMTP_PORT) === 465, auth: { user: process.env.SMTP_USER || "noreply@rmail.online", pass: process.env.SMTP_PASS, }, tls: { rejectUnauthorized: false }, }); return _smtpTransport; } // ── Email rate limiter (5/hour per IP) ── const emailRateMap = new Map(); function checkEmailRate(ip: string): boolean { const now = Date.now(); const hour = 60 * 60 * 1000; const attempts = (emailRateMap.get(ip) || []).filter((t) => now - t < hour); if (attempts.length >= 5) return false; attempts.push(now); emailRateMap.set(ip, attempts); return true; } // rCart internal URL const RCART_URL = process.env.RCART_URL || "http://localhost:3000"; // ── Types ── interface ArtifactRequest { content: string; title?: string; author?: string; format: string; creator_id?: string; creator_name?: string; creator_wallet?: string; source_space?: string; license?: string; tags?: string[]; description?: string; pricing?: { creator_share_pct?: number; community_share_pct?: number; creator_min?: { amount: number; currency?: string }; suggested_retail?: { amount: number; currency?: string }; }; } // ── Helpers ── function buildArtifactEnvelope(opts: { id: string; format: BookFormat; pageCount: number; pdfSizeBytes: number; parsedTitle: string; req: ArtifactRequest; baseUrl: string; }) { const { id, format, pageCount, pdfSizeBytes, parsedTitle, req, baseUrl } = opts; const isBook = pageCount > 48; const productType = isBook ? "book" : "zine"; const binding = isBook ? "perfect-bind" : "saddle-stitch"; const substrates = isBook ? ["paper-100gsm", "paper-100gsm-recycled", "cover-250gsm"] : ["paper-100gsm-recycled", "paper-100gsm", "paper-80gsm"]; const requiredCapabilities = isBook ? ["laser-print", "perfect-bind"] : ["laser-print", "saddle-stitch"]; const pdfUrl = `${baseUrl}/api/artifact/${id}/pdf`; return { id, schema_version: "1.0" as const, type: "print-ready" as const, origin: "rpubs.online", source_space: req.source_space || null, creator: { id: req.creator_id || "anonymous", ...(req.creator_name ? { name: req.creator_name } : {}), ...(req.creator_wallet ? { wallet: req.creator_wallet } : {}), }, created_at: new Date().toISOString(), ...(req.license ? { license: req.license } : {}), payload: { title: parsedTitle, ...(req.description ? { description: req.description } : { description: `A ${pageCount}-page ${productType} in ${format.name} format, generated by rPubs.` }), ...(req.tags && req.tags.length > 0 ? { tags: req.tags } : {}), }, spec: { product_type: productType, dimensions: { width_mm: format.widthMm, height_mm: format.heightMm, bleed_mm: 3, }, pages: pageCount, color_space: "grayscale", dpi: 300, binding, finish: "uncoated", substrates, required_capabilities: requiredCapabilities, }, render_targets: { [format.id]: { url: pdfUrl, format: "pdf", dpi: 300, file_size_bytes: pdfSizeBytes, notes: `${format.name} format, grayscale, ${binding}. ${pageCount} pages.`, }, }, pricing: { creator_share_pct: req.pricing?.creator_share_pct ?? 30, community_share_pct: req.pricing?.community_share_pct ?? 10, ...(req.pricing?.creator_min ? { creator_min: { amount: req.pricing.creator_min.amount, currency: req.pricing.creator_min.currency || "USD" } } : {}), ...(req.pricing?.suggested_retail ? { suggested_retail: { amount: req.pricing.suggested_retail.amount, currency: req.pricing.suggested_retail.currency || "USD" } } : {}), }, next_actions: [ { tool: "rcart.online", action: "list-for-sale", label: "Sell in community shop", endpoint: "/api/catalog/ingest", method: "POST" }, { tool: "rpubs.online", action: "reformat", label: "Generate another format", endpoint: "/api/reformat", method: "POST" }, { tool: "rfiles.online", action: "archive", label: "Save to files", endpoint: "/api/v1/files/import", method: "POST" }, ], }; } function escapeAttr(s: string): string { return s.replace(/&/g, "&").replace(/"/g, """).replace(//g, ">"); } // ── Routes ── const routes = new Hono(); // ── API: List available formats ── routes.get("/api/formats", (c) => { return c.json({ formats: listFormats() }); }); // ── API: Generate PDF (direct download) ── routes.post("/api/generate", async (c) => { try { const body = await c.req.json(); const { content, title, author, format: formatId } = body; if (!content || typeof content !== "string" || content.trim().length === 0) { return c.json({ error: "Content is required" }, 400); } if (!formatId || !getFormat(formatId)) { return c.json({ error: `Invalid format. Available: ${Object.keys(FORMATS).join(", ")}` }, 400); } const document = parseMarkdown(content, title, author); const result = await compileDocument({ document, formatId }); const filename = `${document.title.toLowerCase().replace(/[^a-z0-9]+/g, "-")}-${formatId}.pdf`; return new Response(new Uint8Array(result.pdf), { status: 200, headers: { "Content-Type": "application/pdf", "Content-Disposition": `attachment; filename="${filename}"`, "X-Page-Count": String(result.pageCount), }, }); } catch (error) { console.error("[Pubs] Generation error:", error); return c.json({ error: error instanceof Error ? error.message : "Generation failed" }, 500); } }); // ── API: Create persistent artifact ── routes.post("/api/artifact", async (c) => { try { const body: ArtifactRequest = await c.req.json(); const { content, title, author, format: formatId } = body; if (!content || typeof content !== "string" || content.trim().length === 0) { return c.json({ error: "Content is required" }, 400); } const format = getFormat(formatId); if (!formatId || !format) { return c.json({ error: `Invalid format. Available: ${Object.keys(FORMATS).join(", ")}` }, 400); } if (body.tags && !Array.isArray(body.tags)) { return c.json({ error: "tags must be an array of strings" }, 400); } const document = parseMarkdown(content, title, author); const result = await compileDocument({ document, formatId }); // Store artifact const artifactId = randomUUID(); const artifactDir = join(ARTIFACTS_DIR, artifactId); await mkdir(artifactDir, { recursive: true }); await writeFile(join(artifactDir, `${formatId}.pdf`), result.pdf); await writeFile(join(artifactDir, "source.md"), content); // Build envelope const proto = c.req.header("x-forwarded-proto") || "https"; const host = c.req.header("host") || "rpubs.online"; const baseUrl = `${proto}://${host}`; const artifact = buildArtifactEnvelope({ id: artifactId, format, pageCount: result.pageCount, pdfSizeBytes: result.pdf.length, parsedTitle: document.title, req: body, baseUrl, }); await writeFile(join(artifactDir, "artifact.json"), JSON.stringify(artifact, null, 2)); return c.json(artifact, 201); } catch (error) { console.error("[Pubs] Artifact error:", error); return c.json({ error: error instanceof Error ? error.message : "Artifact generation failed" }, 500); } }); // ── API: Get artifact (metadata or files) ── routes.get("/api/artifact/:id", async (c) => { const id = c.req.param("id"); if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id)) { return c.json({ error: "Invalid artifact ID" }, 400); } const artifactDir = join(ARTIFACTS_DIR, id); try { await stat(artifactDir); } catch { return c.json({ error: "Artifact not found" }, 404); } const fileType = c.req.query("file"); if (fileType === "source") { try { const source = await readFile(join(artifactDir, "source.md")); return new Response(source, { headers: { "Content-Type": "text/markdown; charset=utf-8", "Content-Disposition": `inline; filename="source.md"`, }, }); } catch { return c.json({ error: "Source file not found" }, 404); } } if (fileType === "pdf") { const files = await readdir(artifactDir); const pdfFile = files.find((f) => f.endsWith(".pdf")); if (!pdfFile) return c.json({ error: "PDF not found" }, 404); const pdf = await readFile(join(artifactDir, pdfFile)); return new Response(new Uint8Array(pdf), { headers: { "Content-Type": "application/pdf", "Content-Disposition": `inline; filename="${pdfFile}"`, "Content-Length": String(pdf.length), }, }); } // Default: return artifact JSON try { const artifactJson = await readFile(join(artifactDir, "artifact.json"), "utf-8"); return new Response(artifactJson, { headers: { "Content-Type": "application/json" }, }); } catch { return c.json({ error: "Artifact metadata not found" }, 404); } }); // ── API: Direct PDF access ── routes.get("/api/artifact/:id/pdf", async (c) => { const id = c.req.param("id"); if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id)) { return c.json({ error: "Invalid artifact ID" }, 400); } const artifactDir = join(ARTIFACTS_DIR, id); try { await stat(artifactDir); } catch { return c.json({ error: "Artifact not found" }, 404); } const files = await readdir(artifactDir); const pdfFile = files.find((f) => f.endsWith(".pdf")); if (!pdfFile) return c.json({ error: "PDF not found" }, 404); const pdf = await readFile(join(artifactDir, pdfFile)); return new Response(new Uint8Array(pdf), { headers: { "Content-Type": "application/pdf", "Content-Disposition": `inline; filename="${pdfFile}"`, "Content-Length": String(pdf.length), }, }); }); // ── API: Generate imposition PDF ── routes.post("/api/imposition", async (c) => { try { const body = await c.req.json(); const { content, title, author, format: formatId } = body; if (!content || typeof content !== "string" || content.trim().length === 0) { return c.json({ error: "Content is required" }, 400); } if (!formatId || !getFormat(formatId)) { return c.json({ error: `Invalid format. Available: ${Object.keys(FORMATS).join(", ")}` }, 400); } const document = parseMarkdown(content, title, author); const result = await compileDocument({ document, formatId }); const imposition = await generateImposition(result.pdf, formatId); const filename = `${document.title.toLowerCase().replace(/[^a-z0-9]+/g, "-")}-${formatId}-imposition.pdf`; return new Response(new Uint8Array(imposition.pdf), { status: 200, headers: { "Content-Type": "application/pdf", "Content-Disposition": `attachment; filename="${filename}"`, "X-Sheet-Count": String(imposition.sheetCount), "X-Page-Count": String(imposition.pageCount), }, }); } catch (error) { console.error("[Pubs] Imposition error:", error); return c.json({ error: error instanceof Error ? error.message : "Imposition generation failed" }, 500); } }); // ── API: Email PDF ── routes.post("/api/email-pdf", async (c) => { try { const body = await c.req.json(); const { content, title, author, format: formatId, email } = body; if (!content || typeof content !== "string" || content.trim().length === 0) { return c.json({ error: "Content is required" }, 400); } if (!email || typeof email !== "string" || !email.includes("@")) { return c.json({ error: "Valid email is required" }, 400); } const format = getFormat(formatId); if (!formatId || !format) { return c.json({ error: `Invalid format. Available: ${Object.keys(FORMATS).join(", ")}` }, 400); } const ip = c.req.header("x-forwarded-for") || c.req.header("x-real-ip") || "unknown"; if (!checkEmailRate(ip)) { return c.json({ error: "Rate limit exceeded (5 emails/hour). Try again later." }, 429); } const transport = getSmtpTransport(); if (!transport) { return c.json({ error: "Email service not configured" }, 503); } const document = parseMarkdown(content, title, author); const result = await compileDocument({ document, formatId }); const slug = (title || document.title || "document") .toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""); const fromAddr = process.env.SMTP_FROM || process.env.SMTP_USER || "noreply@rmail.online"; await transport.sendMail({ from: `"rPubs Press" <${fromAddr}>`, to: email, subject: `Your publication: ${title || document.title || "Untitled"}`, text: [ `Here's your publication from rPubs Pocket Press.`, ``, `Title: ${title || document.title || "Untitled"}`, author ? `Author: ${author}` : null, `Format: ${format.name} (${format.widthMm}\u00D7${format.heightMm}mm)`, `Pages: ${result.pageCount}`, ``, `---`, `rPubs \u00B7 Community pocket press`, `https://rpubs.online`, ].filter(Boolean).join("\n"), html: [ `
`, `

Your publication is ready

`, `

`, `${title || document.title || "Untitled"}`, author ? ` by ${author}` : "", `

`, ``, ``, ``, `
Format${format.name}
Pages${result.pageCount}
`, `

The PDF is attached below.

`, `
`, `

rPubs · Community pocket press · rpubs.online

`, `
`, ].join("\n"), attachments: [{ filename: `${slug}-${formatId}.pdf`, content: Buffer.from(result.pdf), contentType: "application/pdf", }], }); return c.json({ ok: true, message: `PDF sent to ${email}` }); } catch (error) { console.error("[Pubs] Email error:", error); return c.json({ error: error instanceof Error ? error.message : "Failed to send email" }, 500); } }); // ── API: Discover printers ── routes.get("/api/printers", async (c) => { try { const lat = parseFloat(c.req.query("lat") || ""); const lng = parseFloat(c.req.query("lng") || ""); if (isNaN(lat) || isNaN(lng)) { return c.json({ error: "lat and lng are required" }, 400); } const radiusKm = parseFloat(c.req.query("radius") || "100"); const formatId = c.req.query("format") || undefined; const providers = await discoverPrinters({ lat, lng, radiusKm, formatId }); return c.json({ providers }); } catch (error) { console.error("[Pubs] Printer discovery error:", error); return c.json({ error: error instanceof Error ? error.message : "Discovery failed" }, 500); } }); // ── API: Place order (forward to rCart) ── routes.post("/api/order", async (c) => { try { const body = await c.req.json(); const { provider_id, total_price } = body; if (!provider_id || total_price === undefined) { return c.json({ error: "provider_id and total_price are required" }, 400); } // Generate artifact first if content is provided let artifactId = body.artifact_id; if (!artifactId && body.content) { const document = parseMarkdown(body.content, body.title, body.author); const formatId = body.format || "digest"; const result = await compileDocument({ document, formatId }); artifactId = randomUUID(); const artifactDir = join(ARTIFACTS_DIR, artifactId); await mkdir(artifactDir, { recursive: true }); await writeFile(join(artifactDir, `${formatId}.pdf`), result.pdf); await writeFile(join(artifactDir, "source.md"), body.content); } const orderRes = await fetch(`${RCART_URL}/api/orders`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ catalog_entry_id: body.catalog_entry_id, artifact_id: artifactId, provider_id, provider_name: body.provider_name, provider_distance_km: body.provider_distance_km, quantity: body.quantity || 1, production_cost: body.production_cost, creator_payout: body.creator_payout, community_payout: body.community_payout, total_price, currency: body.currency || "USD", payment_method: "manual", buyer_contact: body.buyer_contact, buyer_location: body.buyer_location, }), }); if (!orderRes.ok) { const err = await orderRes.json().catch(() => ({})); console.error("[Pubs] rCart order failed:", err); return c.json({ error: "Failed to create order" }, 502 as any); } const order = await orderRes.json(); return c.json(order, 201); } catch (error) { console.error("[Pubs] Order error:", error); return c.json({ error: error instanceof Error ? error.message : "Order creation failed" }, 500); } }); // ── API: Batch / group buy ── routes.post("/api/batch", async (c) => { try { const body = await c.req.json(); const { artifact_id, catalog_entry_id, provider_id, provider_name, buyer_contact, buyer_location, quantity = 1 } = body; if (!artifact_id && !catalog_entry_id) { return c.json({ error: "artifact_id or catalog_entry_id required" }, 400); } if (!provider_id) { return c.json({ error: "provider_id required" }, 400); } // Check for existing open batch const searchParams = new URLSearchParams({ artifact_id: artifact_id || catalog_entry_id, status: "open", ...(provider_id && { provider_id }), }); const existingRes = await fetch(`${RCART_URL}/api/batches?${searchParams}`); const existingData = await existingRes.json(); const openBatches = (existingData.batches || []).filter( (b: { provider_id: string }) => b.provider_id === provider_id ); if (openBatches.length > 0) { const batch = openBatches[0]; const joinRes = await fetch(`${RCART_URL}/api/batches/${batch.id}/join`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ buyer_contact, buyer_location, quantity }), }); if (!joinRes.ok) { const err = await joinRes.json().catch(() => ({})); return c.json({ error: (err as any).error || "Failed to join batch" }, 502 as any); } const result = await joinRes.json(); return c.json({ action: "joined", ...result }); } // Create new batch const createRes = await fetch(`${RCART_URL}/api/batches`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ catalog_entry_id, artifact_id, provider_id, provider_name }), }); if (!createRes.ok) { const err = await createRes.json().catch(() => ({})); return c.json({ error: (err as any).error || "Failed to create batch" }, 502 as any); } const batch = await createRes.json(); const joinRes = await fetch(`${RCART_URL}/api/batches/${batch.id}/join`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ buyer_contact, buyer_location, quantity }), }); if (!joinRes.ok) { return c.json({ action: "created", batch, member: null }, 201); } const joinResult = await joinRes.json(); return c.json({ action: "created", ...joinResult }, 201); } catch (error) { console.error("[Pubs] Batch error:", error); return c.json({ error: error instanceof Error ? error.message : "Batch operation failed" }, 500); } }); routes.get("/api/batch", async (c) => { const artifactId = c.req.query("artifact_id"); const providerId = c.req.query("provider_id"); if (!artifactId) { return c.json({ error: "artifact_id required" }, 400); } const params = new URLSearchParams({ artifact_id: artifactId, status: "open" }); if (providerId) params.set("provider_id", providerId); try { const res = await fetch(`${RCART_URL}/api/batches?${params}`); if (!res.ok) return c.json({ batches: [] }); const data = await res.json(); return c.json(data); } catch { return c.json({ batches: [] }); } }); // ── Page: Zine Generator (redirect to canvas with auto-spawn) ── routes.get("/zine", (c) => { const spaceSlug = c.req.param("space") || "personal"; return c.redirect(`/${spaceSlug}?tool=folk-zine-gen`); }); // ── Page: Editor ── routes.get("/", (c) => { const spaceSlug = c.req.param("space") || "personal"; const dataSpace = c.get("effectiveSpace") || spaceSlug; return c.html(renderShell({ title: `${spaceSlug} — rPubs Editor | rSpace`, moduleId: "rpubs", spaceSlug, modules: getModuleInfoList(), theme: "dark", body: ``, scripts: ` `, styles: ``, })); }); // ── Module export ── export const pubsModule: RSpaceModule = { id: "rpubs", name: "rPubs", icon: "📖", description: "Drop in a document, get a pocket book", scoping: { defaultScope: 'global', userConfigurable: true }, routes, publicWrite: true, standaloneDomain: "rpubs.online", landingPage: renderLanding, feeds: [ { id: "publications", name: "Publications", kind: "data", description: "Print-ready artifacts generated from markdown documents", filterable: true, }, { id: "citations", name: "Citations", kind: "data", description: "Citation and reference data extracted from documents", }, ], acceptsFeeds: ["data"], outputPaths: [ { path: "publications", name: "Publications", icon: "📖", description: "Published pocket books and zines" }, { path: "drafts", name: "Drafts", icon: "📝", description: "Work-in-progress documents" }, ], };