rspace-online/modules/rpubs/mod.ts

1101 lines
40 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, compileDocumentToPages } from "./typst-compile";
import { buildReflowableEpub, buildFixedLayoutEpub } from "./epub-gen";
import { getFormat, FORMATS, listFormats } from "./formats";
import {
listPublications,
getPublication,
getPublicationFile,
savePublication,
slugify as slugifyPub,
} from "./publications-store";
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";
import * as Automerge from "@automerge/automerge";
import { verifyToken, extractToken } from "../../server/auth";
import type { SyncServer } from '../../server/local-first/sync-server';
import { pubsDraftSchema, pubsDocId } from './schemas';
import type { PubsDoc } from './schemas';
const ARTIFACTS_DIR = process.env.ARTIFACTS_DIR || "/tmp/rpubs-artifacts";
// ── SMTP ──
let _smtpTransport: Transporter | null = null;
function getSmtpTransport(): Transporter | null {
if (_smtpTransport) return _smtpTransport;
const host = process.env.SMTP_HOST || "mail.rmail.online";
const isInternal = host.includes('mailcow') || host.includes('postfix');
if (!process.env.SMTP_PASS && !isInternal) return null;
_smtpTransport = createTransport({
host,
port: isInternal ? 25 : (Number(process.env.SMTP_PORT) || 587),
secure: !isInternal && Number(process.env.SMTP_PORT) === 465,
...(isInternal ? {} : {
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;");
}
function formatBytes(n: number): string {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KB`;
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
}
function renderReaderPage(opts: { record: import("./publications-store").PublicationRecord; spaceSlug: string }): string {
const { record, spaceSlug } = opts;
const base = `/${escapeAttr(spaceSlug)}/rpubs/publications/${escapeAttr(record.slug)}`;
const pdfUrl = `${base}/pdf`;
const epubUrl = `${base}/epub`;
const epubFixedUrl = record.fixedEpubBytes ? `${base}/epub-fixed` : null;
return `
<style>
.reader-wrap { max-width: 980px; margin: 0 auto; padding: 28px 20px 80px; color: var(--rs-text, #e5e7eb); }
.reader-hero { margin-bottom: 20px; }
.reader-title { font-size: 1.6rem; font-weight: 700; margin: 0 0 4px; }
.reader-author { color: var(--rs-text-muted, #94a3b8); font-size: 0.95rem; }
.reader-meta { color: var(--rs-text-muted, #94a3b8); font-size: 0.82rem; margin-top: 6px; }
.reader-description { margin: 12px 0 0; line-height: 1.6; color: var(--rs-text, #cbd5e1); }
.reader-flipbook { margin: 20px 0 28px; min-height: 420px; }
.reader-downloads { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px; }
.dl-card { display: flex; flex-direction: column; gap: 4px; padding: 14px; background: var(--rs-surface, #1e293b); border: 1px solid var(--rs-border, #334155); border-radius: 8px; text-decoration: none; color: inherit; transition: all 0.15s; }
.dl-card:hover { border-color: var(--rs-accent, #14b8a6); transform: translateY(-1px); }
.dl-head { display: flex; justify-content: space-between; align-items: baseline; }
.dl-title { font-weight: 600; font-size: 0.98rem; }
.dl-size { color: var(--rs-text-muted, #94a3b8); font-size: 0.78rem; }
.dl-sub { color: var(--rs-text-muted, #94a3b8); font-size: 0.82rem; }
.reader-share { margin-top: 20px; display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.reader-share input { flex: 1; min-width: 200px; padding: 8px 10px; background: var(--rs-surface-alt, #0f172a); color: inherit; border: 1px solid var(--rs-border, #334155); border-radius: 6px; font-family: inherit; font-size: 0.85rem; }
.reader-share button { padding: 8px 14px; background: var(--rs-accent, #14b8a6); color: #fff; border: none; border-radius: 6px; cursor: pointer; font-weight: 600; font-size: 0.85rem; }
.reader-share button:hover { background: var(--rs-accent-hover, #0d9488); }
</style>
<div class="reader-wrap">
<header class="reader-hero">
<h1 class="reader-title">${escapeAttr(record.title)}</h1>
${record.author ? `<div class="reader-author">by ${escapeAttr(record.author)}</div>` : ""}
<div class="reader-meta">${escapeAttr(record.formatName)} · ${record.pageCount} pages · ${new Date(record.createdAt).toLocaleDateString()}</div>
${record.description ? `<p class="reader-description">${escapeAttr(record.description)}</p>` : ""}
</header>
<div class="reader-flipbook">
<folk-pubs-flipbook pdf-url="${pdfUrl}"></folk-pubs-flipbook>
</div>
<div class="reader-downloads">
<a class="dl-card" href="${pdfUrl}" download>
<div class="dl-head"><span class="dl-title">Download PDF</span><span class="dl-size">${formatBytes(record.pdfBytes)}</span></div>
<span class="dl-sub">Print-ready, preserves design</span>
</a>
<a class="dl-card" href="${epubUrl}" download>
<div class="dl-head"><span class="dl-title">EPUB · reflowable</span><span class="dl-size">${formatBytes(record.reflowableEpubBytes)}</span></div>
<span class="dl-sub">Resizable text, works on any e-reader</span>
</a>
${epubFixedUrl ? `
<a class="dl-card" href="${epubFixedUrl}" download>
<div class="dl-head"><span class="dl-title">EPUB · fixed layout</span><span class="dl-size">${record.fixedEpubBytes ? formatBytes(record.fixedEpubBytes) : ""}</span></div>
<span class="dl-sub">Preserves page design, for Kindle/iPad</span>
</a>` : ""}
</div>
<div class="reader-share">
<input type="text" id="share-url" readonly value="" />
<button onclick="(() => { const el = document.getElementById('share-url'); el.select(); navigator.clipboard.writeText(el.value); const btn = event.target; const orig = btn.textContent; btn.textContent = 'Copied!'; setTimeout(() => btn.textContent = orig, 1500); })()">Copy Link</button>
</div>
<script>document.getElementById('share-url').value = window.location.href;</script>
</div>`;
}
// ── Routes ──
let _syncServer: SyncServer | null = null;
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 EPUB (reflowable or fixed-layout) ──
routes.post("/api/generate-epub", async (c) => {
try {
const body = await c.req.json();
const { content, title, author, format: formatId, mode = "reflowable", description } = body;
if (!content || typeof content !== "string" || content.trim().length === 0) {
return c.json({ error: "Content is required" }, 400);
}
const epubMode = mode === "fixed" ? "fixed" : "reflowable";
// Format is only required for fixed-layout (used by Typst).
if (epubMode === "fixed" && (!formatId || !getFormat(formatId))) {
return c.json({ error: `Invalid format. Available: ${Object.keys(FORMATS).join(", ")}` }, 400);
}
const document = parseMarkdown(content, title, author);
const slug = (document.title || "document").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "document";
let epubBuf: Buffer;
let filename: string;
let pageCount = 0;
if (epubMode === "fixed") {
const { pages } = await compileDocumentToPages({ document, formatId, ppi: 144 });
pageCount = pages.length;
epubBuf = await buildFixedLayoutEpub({ document, pages, formatId, description });
filename = `${slug}-${formatId}-fixed.epub`;
} else {
epubBuf = await buildReflowableEpub({ document, description });
filename = `${slug}.epub`;
}
return new Response(new Uint8Array(epubBuf), {
status: 200,
headers: {
"Content-Type": "application/epub+zip",
"Content-Disposition": `attachment; filename="${filename}"`,
"Content-Length": String(epubBuf.length),
"X-Epub-Mode": epubMode,
...(pageCount ? { "X-Page-Count": String(pageCount) } : {}),
},
});
} catch (error) {
console.error("[Pubs] EPUB error:", error);
return c.json({ error: error instanceof Error ? error.message : "EPUB generation failed" }, 500);
}
});
// ── API: Publish to a space (persistent, publicly addressable) ──
routes.post("/api/publish", async (c) => {
try {
const body = await c.req.json();
const {
content,
title,
author,
format: formatId,
description,
include_fixed_epub = false,
} = 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);
}
const space = c.req.param("space") || c.get("effectiveSpace") || "personal";
const document = parseMarkdown(content, title, author);
// Compile PDF (always) + reflowable EPUB (always).
const pdfResult = await compileDocument({ document, formatId });
const reflowable = await buildReflowableEpub({ document, description });
// Fixed EPUB only if requested — it's the costly path.
let fixedEpub: Buffer | undefined;
if (include_fixed_epub) {
const { pages } = await compileDocumentToPages({ document, formatId, ppi: 144 });
fixedEpub = await buildFixedLayoutEpub({ document, pages, formatId, description });
}
const record = await savePublication({
space,
title: document.title,
author: document.author || author || "Unknown",
description,
formatId,
formatName: format.name,
pageCount: pdfResult.pageCount,
sourceMarkdown: content,
pdf: pdfResult.pdf,
reflowableEpub: reflowable,
fixedEpub,
});
const proto = c.req.header("x-forwarded-proto") || "https";
const host = c.req.header("host") || "rspace.online";
const baseUrl = `${proto}://${host}`;
// The reader lives under the module mount (`/:space/rpubs/publications/:slug`)
// or, on the standalone rpubs.online domain, at `/publications/:slug`.
const isStandalone = host.includes("rpubs.online");
const hostedPath = isStandalone
? `/publications/${record.slug}`
: `/${record.space}/rpubs/publications/${record.slug}`;
return c.json({
...record,
hosted_url: `${baseUrl}${hostedPath}`,
hosted_path: hostedPath,
pdf_url: `${baseUrl}${hostedPath}/pdf`,
epub_url: `${baseUrl}${hostedPath}/epub`,
epub_fixed_url: fixedEpub ? `${baseUrl}${hostedPath}/epub-fixed` : null,
}, 201);
} catch (error) {
console.error("[Pubs] Publish error:", error);
return c.json({ error: error instanceof Error ? error.message : "Publish failed" }, 500);
}
});
// ── API: List publications in a space ──
routes.get("/api/publications", async (c) => {
const space = c.req.param("space") || c.get("effectiveSpace") || "personal";
const records = await listPublications(space);
return c.json({ publications: records });
});
// ── API: Get publication metadata ──
routes.get("/api/publications/:slug", async (c) => {
const space = c.req.param("space") || c.get("effectiveSpace") || "personal";
const slug = c.req.param("slug");
const record = await getPublication(space, slug);
if (!record) return c.json({ error: "Publication not found" }, 404);
return c.json(record);
});
// ── Public: Reader page ──
routes.get("/publications/:slug", async (c) => {
const spaceSlug = c.req.param("space") || "personal";
const dataSpace = c.get("effectiveSpace") || spaceSlug;
const slug = c.req.param("slug");
const record = await getPublication(dataSpace, slug);
if (!record) {
return c.html(`<!doctype html><meta charset="utf-8"><title>Not found</title><body style="font:14px system-ui;padding:40px;color:#ddd;background:#111"><h1>Publication not found</h1><p>No publication exists at this URL.</p><p><a style="color:#14b8a6" href="/${escapeAttr(spaceSlug)}/rpubs">Back to rPubs</a></p></body>`, 404);
}
return c.html(renderShell({
title: `${record.title}${spaceSlug} | rSpace`,
moduleId: "rpubs",
spaceSlug,
modules: getModuleInfoList(),
theme: "dark",
body: renderReaderPage({ record, spaceSlug }),
scripts: `<script type="module" src="/modules/rpubs/folk-pubs-flipbook.js?v=3"></script>`,
styles: `<link rel="stylesheet" href="/modules/rpubs/pubs.css">`,
}));
});
// ── Public: Download PDF ──
routes.get("/publications/:slug/pdf", async (c) => {
const space = c.req.param("space") || c.get("effectiveSpace") || "personal";
const slug = c.req.param("slug");
const buf = await getPublicationFile(space, slug, "pdf");
if (!buf) return c.json({ error: "PDF not found" }, 404);
return new Response(new Uint8Array(buf), {
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `inline; filename="${slugifyPub(slug)}.pdf"`,
"Content-Length": String(buf.length),
},
});
});
// ── Public: Download EPUB (reflowable default) ──
routes.get("/publications/:slug/epub", async (c) => {
const space = c.req.param("space") || c.get("effectiveSpace") || "personal";
const slug = c.req.param("slug");
const buf = await getPublicationFile(space, slug, "reflowable.epub");
if (!buf) return c.json({ error: "EPUB not found" }, 404);
return new Response(new Uint8Array(buf), {
headers: {
"Content-Type": "application/epub+zip",
"Content-Disposition": `attachment; filename="${slugifyPub(slug)}.epub"`,
"Content-Length": String(buf.length),
},
});
});
// ── Public: Download fixed-layout EPUB (if available) ──
routes.get("/publications/:slug/epub-fixed", async (c) => {
const space = c.req.param("space") || c.get("effectiveSpace") || "personal";
const slug = c.req.param("slug");
const buf = await getPublicationFile(space, slug, "fixed.epub");
if (!buf) return c.json({ error: "Fixed-layout EPUB not available for this publication" }, 404);
return new Response(new Uint8Array(buf), {
headers: {
"Content-Type": "application/epub+zip",
"Content-Disposition": `attachment; filename="${slugifyPub(slug)}-fixed.epub"`,
"Content-Length": String(buf.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: [] });
}
});
// ── CRUD: Drafts (Automerge) ──
routes.get("/api/drafts", (c) => {
if (!_syncServer) return c.json({ drafts: [] });
const space = c.req.param("space") || "demo";
const prefix = `${space}:pubs:drafts:`;
const drafts: any[] = [];
for (const docId of _syncServer.listDocs()) {
if (!docId.startsWith(prefix)) continue;
const doc = _syncServer.getDoc<PubsDoc>(docId);
if (!doc?.draft) continue;
drafts.push({ id: doc.draft.id, title: doc.draft.title, author: doc.draft.author, format: doc.draft.format, createdAt: doc.draft.createdAt, updatedAt: doc.draft.updatedAt });
}
return c.json({ drafts: drafts.sort((a, b) => b.updatedAt - a.updatedAt) });
});
routes.post("/api/drafts", async (c) => {
const authToken = extractToken(c.req.raw.headers);
if (!authToken) return c.json({ error: "Authentication required" }, 401);
try { await verifyToken(authToken); } catch { return c.json({ error: "Invalid token" }, 401); }
if (!_syncServer) return c.json({ error: "Not initialized" }, 503);
const space = c.req.param("space") || "demo";
const { title = "Untitled", author = "", format = "digest", content = "" } = await c.req.json();
const id = crypto.randomUUID();
const docId = pubsDocId(space, id);
const now = Date.now();
const doc = Automerge.change(Automerge.init<PubsDoc>(), 'create draft', (d) => {
const init = pubsDraftSchema.init();
Object.assign(d, init);
d.meta.spaceSlug = space;
d.draft.id = id;
d.draft.title = title;
d.draft.author = author;
d.draft.format = format;
d.draft.createdAt = now;
d.draft.updatedAt = now;
d.content = content;
});
_syncServer.setDoc(docId, doc);
return c.json({ id, title, author, format }, 201);
});
routes.delete("/api/drafts/:id", async (c) => {
const authToken = extractToken(c.req.raw.headers);
if (!authToken) return c.json({ error: "Authentication required" }, 401);
try { await verifyToken(authToken); } catch { return c.json({ error: "Invalid token" }, 401); }
if (!_syncServer) return c.json({ error: "Not initialized" }, 503);
const space = c.req.param("space") || "demo";
const id = c.req.param("id");
const docId = pubsDocId(space, id);
const doc = _syncServer.getDoc<PubsDoc>(docId);
if (!doc) return c.json({ error: "Not found" }, 404);
_syncServer.changeDoc<PubsDoc>(docId, `delete draft ${id}`, (d) => {
d.draft.title = '[deleted]';
d.content = '';
});
return c.json({ ok: true });
});
// ── 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 (also served at /press) ──
routes.get("/press", (c) => {
const spaceSlug = c.req.param("space") || "personal";
const dataSpace = c.get("effectiveSpace") || spaceSlug;
return c.html(renderShell({
title: `${spaceSlug} — rPubs Press | 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?v=3"></script>
<script type="module" src="/modules/rpubs/folk-pubs-flipbook.js?v=3"></script>
<script type="module" src="/modules/rpubs/folk-pubs-publish-panel.js?v=3"></script>`,
styles: `<link rel="stylesheet" href="/modules/rpubs/pubs.css">`,
}));
});
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?v=3"></script>
<script type="module" src="/modules/rpubs/folk-pubs-flipbook.js?v=3"></script>
<script type="module" src="/modules/rpubs/folk-pubs-publish-panel.js?v=3"></script>`,
styles: `<link rel="stylesheet" href="/modules/rpubs/pubs.css">`,
}));
});
export function getRecentPublicationsForMI(space: string, limit = 5): { id: string; title: string; author: string; format: string; updatedAt: number }[] {
if (!_syncServer) return [];
const prefix = `${space}:pubs:drafts:`;
const items: { id: string; title: string; author: string; format: string; updatedAt: number }[] = [];
for (const docId of _syncServer.listDocs()) {
if (!docId.startsWith(prefix)) continue;
const doc = _syncServer.getDoc<PubsDoc>(docId);
if (!doc?.draft || doc.draft.title === '[deleted]') continue;
items.push({ id: doc.draft.id, title: doc.draft.title, author: doc.draft.author, format: doc.draft.format, updatedAt: doc.draft.updatedAt });
}
return items.sort((a, b) => b.updatedAt - a.updatedAt).slice(0, limit);
}
// ── 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,
docSchemas: [{ pattern: '{space}:pubs:drafts:{draftId}', description: 'One doc per publication draft', init: pubsDraftSchema.init }],
async onInit(ctx) { _syncServer = ctx.syncServer; },
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" },
],
};