rspace-online/modules/rpubs/mod.ts

711 lines
23 KiB
TypeScript

/**
* 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<string, number[]>();
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, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
// ── 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: [
`<div style="font-family: system-ui, sans-serif; max-width: 480px; margin: 0 auto; padding: 24px;">`,
`<h2 style="margin: 0 0 8px; font-size: 18px;">Your publication is ready</h2>`,
`<p style="color: #64748b; margin: 0 0 16px; font-size: 14px;">`,
`<strong>${title || document.title || "Untitled"}</strong>`,
author ? ` by ${author}` : "",
`</p>`,
`<table style="font-size: 13px; color: #475569; margin-bottom: 16px;">`,
`<tr><td style="padding: 2px 12px 2px 0; color: #94a3b8;">Format</td><td>${format.name}</td></tr>`,
`<tr><td style="padding: 2px 12px 2px 0; color: #94a3b8;">Pages</td><td>${result.pageCount}</td></tr>`,
`</table>`,
`<p style="font-size: 13px; color: #64748b;">The PDF is attached below.</p>`,
`<hr style="border: none; border-top: 1px solid #e2e8f0; margin: 20px 0;" />`,
`<p style="font-size: 11px; color: #94a3b8;">rPubs &middot; Community pocket press &middot; <a href="https://rpubs.online" style="color: #5a9a7a;">rpubs.online</a></p>`,
`</div>`,
].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: `<folk-pubs-editor space="${spaceSlug}"></folk-pubs-editor>`,
scripts: `<script type="module" src="/modules/rpubs/folk-pubs-editor.js"></script>
<script type="module" src="/modules/rpubs/folk-pubs-flipbook.js"></script>
<script type="module" src="/modules/rpubs/folk-pubs-publish-panel.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/rpubs/pubs.css">`,
}));
});
// ── 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" },
],
};