rspace-online/modules/books/mod.ts

334 lines
9.2 KiB
TypeScript

/**
* 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: `
<folk-book-shelf id="shelf"></folk-book-shelf>
`,
modules: getModuleInfoList(),
theme: "light",
head: `<link rel="stylesheet" href="/modules/books/books.css">`,
scripts: `
<script type="module">
import { FolkBookShelf } from '/modules/books/folk-book-shelf.js';
const shelf = document.getElementById('shelf');
shelf.books = ${booksJSON};
shelf.spaceSlug = '${spaceSlug}';
</script>
`,
});
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: `<div style="padding:3rem;text-align:center;color:#94a3b8;"><h2>Book not found</h2><p><a href="/${spaceSlug}/books" style="color:#60a5fa;">Back to library</a></p></div>`,
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: `
<folk-book-reader
id="reader"
pdf-url="${pdfUrl}"
book-id="${book.slug}"
title="${escapeAttr(book.title)}"
author="${escapeAttr(book.author || '')}"
></folk-book-reader>
`,
modules: getModuleInfoList(),
theme: "dark",
head: `<link rel="stylesheet" href="/modules/books/books.css">`,
scripts: `
<script type="module">
import { FolkBookReader } from '/modules/books/folk-book-reader.js';
</script>
`,
});
return c.html(html);
});
// ── Initialize DB schema ──
async function initDB(): Promise<void> {
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, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
// ── 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();