/** * Books module — community PDF library with flipbook reader. * * Ported from rbooks-online (Next.js) to Hono routes. * Routes are relative to mount point (/:space/books in unified, / in standalone). */ import { Hono } from "hono"; import { resolve } from "node:path"; import { mkdir, readFile } from "node:fs/promises"; import { randomUUID } from "node:crypto"; import { sql } from "../../shared/db/pool"; import { renderShell } from "../../server/shell"; import { getModuleInfoList } from "../../shared/module"; import type { RSpaceModule } from "../../shared/module"; import { verifyEncryptIDToken, extractToken, } from "@encryptid/sdk/server"; const BOOKS_DIR = process.env.BOOKS_DIR || "/data/books"; // ── Types ── export interface BookRow { id: string; slug: string; title: string; author: string | null; description: string | null; pdf_path: string; pdf_size_bytes: number; page_count: number; tags: string[]; license: string; cover_color: string; contributor_id: string | null; contributor_name: string | null; status: string; featured: boolean; view_count: number; download_count: number; created_at: string; updated_at: string; } // ── Helpers ── function slugify(text: string): string { return text .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-|-$/g, "") .slice(0, 80); } // ── Routes ── const routes = new Hono(); // ── API: List books ── routes.get("/api/books", async (c) => { const search = c.req.query("search"); const tag = c.req.query("tag"); const limit = Math.min(parseInt(c.req.query("limit") || "50"), 100); const offset = parseInt(c.req.query("offset") || "0"); let query = `SELECT id, slug, title, author, description, pdf_size_bytes, page_count, tags, cover_color, contributor_name, featured, view_count, created_at FROM rbooks.books WHERE status = 'published'`; const params: (string | number)[] = []; if (search) { params.push(`%${search}%`); query += ` AND (title ILIKE $${params.length} OR author ILIKE $${params.length} OR description ILIKE $${params.length})`; } if (tag) { params.push(tag); query += ` AND $${params.length} = ANY(tags)`; } query += ` ORDER BY featured DESC, created_at DESC`; params.push(limit); query += ` LIMIT $${params.length}`; params.push(offset); query += ` OFFSET $${params.length}`; const rows = await sql.unsafe(query, params); return c.json({ books: rows }); }); // ── API: Upload book ── routes.post("/api/books", async (c) => { const token = extractToken(c.req.raw.headers); if (!token) return c.json({ error: "Authentication required" }, 401); let claims; try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } const formData = await c.req.formData(); const file = formData.get("pdf") as File | null; const title = (formData.get("title") as string || "").trim(); const author = (formData.get("author") as string || "").trim() || null; const description = (formData.get("description") as string || "").trim() || null; const tagsRaw = (formData.get("tags") as string || "").trim(); const license = (formData.get("license") as string || "CC BY-SA 4.0").trim(); if (!file || file.type !== "application/pdf") { return c.json({ error: "PDF file required" }, 400); } if (!title) { return c.json({ error: "Title required" }, 400); } const tags = tagsRaw ? tagsRaw.split(",").map((t) => t.trim()).filter(Boolean) : []; const shortId = randomUUID().slice(0, 8); let slug = slugify(title); // Check slug collision const existing = await sql.unsafe( `SELECT 1 FROM rbooks.books WHERE slug = $1`, [slug] ); if (existing.length > 0) { slug = `${slug}-${shortId}`; } // Save PDF to disk await mkdir(BOOKS_DIR, { recursive: true }); const filename = `${slug}.pdf`; const filepath = resolve(BOOKS_DIR, filename); const buffer = Buffer.from(await file.arrayBuffer()); await Bun.write(filepath, buffer); // Insert into DB const rows = await sql.unsafe( `INSERT INTO rbooks.books (slug, title, author, description, pdf_path, pdf_size_bytes, tags, license, contributor_id, contributor_name) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id, slug, title, author, description, tags, created_at`, [slug, title, author, description, filename, buffer.length, tags, license, claims.sub, claims.username || null] ); return c.json(rows[0], 201); }); // ── API: Get book details ── routes.get("/api/books/:id", async (c) => { const id = c.req.param("id"); const rows = await sql.unsafe( `SELECT * FROM rbooks.books WHERE (slug = $1 OR id::text = $1) AND status = 'published'`, [id] ); if (rows.length === 0) return c.json({ error: "Book not found" }, 404); // Increment view count await sql.unsafe( `UPDATE rbooks.books SET view_count = view_count + 1 WHERE id = $1`, [rows[0].id] ); return c.json(rows[0]); }); // ── API: Serve PDF ── routes.get("/api/books/:id/pdf", async (c) => { const id = c.req.param("id"); const rows = await sql.unsafe( `SELECT id, slug, title, pdf_path FROM rbooks.books WHERE (slug = $1 OR id::text = $1) AND status = 'published'`, [id] ); if (rows.length === 0) return c.json({ error: "Book not found" }, 404); const book = rows[0]; const filepath = resolve(BOOKS_DIR, book.pdf_path); const file = Bun.file(filepath); if (!(await file.exists())) { return c.json({ error: "PDF file not found" }, 404); } // Increment download count await sql.unsafe( `UPDATE rbooks.books SET download_count = download_count + 1 WHERE id = $1`, [book.id] ); return new Response(file, { headers: { "Content-Type": "application/pdf", "Content-Disposition": `inline; filename="${book.slug}.pdf"`, "Content-Length": String(file.size), }, }); }); // ── Page: Library (book grid) ── routes.get("/", async (c) => { const spaceSlug = c.req.param("space") || "personal"; // Fetch books for the library page const rows = await sql.unsafe( `SELECT id, slug, title, author, description, pdf_size_bytes, page_count, tags, cover_color, contributor_name, featured, view_count, created_at FROM rbooks.books WHERE status = 'published' ORDER BY featured DESC, created_at DESC LIMIT 50` ); const booksJSON = JSON.stringify(rows); const html = renderShell({ title: `${spaceSlug} — Library | rSpace`, moduleId: "books", spaceSlug, body: ` `, modules: getModuleInfoList(), theme: "light", head: ``, scripts: ` `, }); return c.html(html); }); // ── Page: Book reader ── routes.get("/read/:id", async (c) => { const spaceSlug = c.req.param("space") || "personal"; const id = c.req.param("id"); const rows = await sql.unsafe( `SELECT * FROM rbooks.books WHERE (slug = $1 OR id::text = $1) AND status = 'published'`, [id] ); if (rows.length === 0) { const html = renderShell({ title: "Book not found | rSpace", moduleId: "books", spaceSlug, body: `

Book not found

Back to library

`, modules: getModuleInfoList(), }); return c.html(html, 404); } const book = rows[0]; // Increment view count await sql.unsafe( `UPDATE rbooks.books SET view_count = view_count + 1 WHERE id = $1`, [book.id] ); // Build the PDF URL relative to this module's mount point const pdfUrl = `/${spaceSlug}/books/api/books/${book.slug}/pdf`; const html = renderShell({ title: `${book.title} | rSpace`, moduleId: "books", spaceSlug, body: ` `, modules: getModuleInfoList(), theme: "dark", head: ``, scripts: ` `, }); return c.html(html); }); // ── Initialize DB schema ── async function initDB(): Promise { try { const schemaPath = resolve(import.meta.dir, "db/schema.sql"); const schemaSql = await readFile(schemaPath, "utf-8"); await sql.unsafe(`SET search_path TO rbooks, public`); await sql.unsafe(schemaSql); await sql.unsafe(`SET search_path TO public`); console.log("[Books] Database schema initialized"); } catch (e) { console.error("[Books] Schema init failed:", e); } } function escapeAttr(s: string): string { return s.replace(/&/g, "&").replace(/"/g, """).replace(//g, ">"); } // ── Module export ── export const booksModule: RSpaceModule = { id: "books", name: "rBooks", icon: "📚", description: "Community PDF library with flipbook reader", routes, standaloneDomain: "rbooks.online", async onSpaceCreate(spaceSlug: string) { // Books are global, not space-scoped (for now). No-op. }, }; // Run schema init on import initDB();