bzl

self-hosted ephemeral community engine
Log | Files | Refs | README | LICENSE

server.js (39035B)


      1 const crypto = require("crypto");
      2 const fs = require("fs");
      3 const path = require("path");
      4 
      5 const MAX_PDF_BYTES = Number(process.env.LIBRARY_PDF_MAX_BYTES || 50 * 1024 * 1024); // 50MB
      6 const CHUNK_MAX_B64 = Number(process.env.LIBRARY_CHUNK_MAX_B64 || 1024 * 1024); // base64 chars
      7 const MAX_TEXT_BYTES = Number(process.env.LIBRARY_TEXT_MAX_BYTES || 512 * 1024); // 512KB
      8 
      9 function safeJsonParse(str) {
     10   try {
     11     return JSON.parse(str);
     12   } catch {
     13     return null;
     14   }
     15 }
     16 
     17 function readJsonOrNull(filePath) {
     18   try {
     19     const raw = fs.readFileSync(filePath, "utf8");
     20     return safeJsonParse(raw);
     21   } catch {
     22     return null;
     23   }
     24 }
     25 
     26 function writeFileAtomic(filePath, content) {
     27   const dir = path.dirname(filePath);
     28   fs.mkdirSync(dir, { recursive: true });
     29   const tmp = `${filePath}.tmp.${crypto.randomBytes(6).toString("hex")}`;
     30   fs.writeFileSync(tmp, content, "utf8");
     31   fs.renameSync(tmp, filePath);
     32 }
     33 
     34 function nowMs() {
     35   return Date.now();
     36 }
     37 
     38 function normalizeId(id) {
     39   return String(id || "").trim().toLowerCase();
     40 }
     41 
     42 function normalizeTitle(title) {
     43   const t = String(title || "").trim().slice(0, 120);
     44   return t || "Untitled PDF";
     45 }
     46 
     47 function normalizeTextTitle(title) {
     48   const t = String(title || "").trim().slice(0, 120);
     49   return t || "Untitled text";
     50 }
     51 
     52 function normalizeTextBody(text) {
     53   let t = typeof text === "string" ? text : "";
     54   t = t.replace(/\r\n/g, "\n");
     55   if (Buffer.byteLength(t, "utf8") > MAX_TEXT_BYTES) {
     56     // Trim to max bytes (best-effort by characters).
     57     while (t && Buffer.byteLength(t, "utf8") > MAX_TEXT_BYTES) t = t.slice(0, Math.max(0, t.length - 4096));
     58     while (t && Buffer.byteLength(t, "utf8") > MAX_TEXT_BYTES) t = t.slice(0, Math.max(0, t.length - 256));
     59     while (t && Buffer.byteLength(t, "utf8") > MAX_TEXT_BYTES) t = t.slice(0, Math.max(0, t.length - 16));
     60     while (t && Buffer.byteLength(t, "utf8") > MAX_TEXT_BYTES) t = t.slice(0, Math.max(0, t.length - 1));
     61   }
     62   return t;
     63 }
     64 
     65 function normalizeAuthor(name) {
     66   const a = String(name || "").trim().slice(0, 80);
     67   return a || "Unknown";
     68 }
     69 
     70 function normalizeTags(tags) {
     71   if (!Array.isArray(tags)) return [];
     72   const out = [];
     73   const seen = new Set();
     74   for (const raw of tags) {
     75     const t = String(raw || "")
     76       .trim()
     77       .toLowerCase()
     78       .replace(/[^a-z0-9 _-]+/g, "")
     79       .replace(/\s+/g, "-")
     80       .replace(/-+/g, "-")
     81       .replace(/^[-_]+|[-_]+$/g, "")
     82       .slice(0, 32);
     83     if (!t || seen.has(t)) continue;
     84     seen.add(t);
     85     out.push(t);
     86     if (out.length >= 16) break;
     87   }
     88   return out;
     89 }
     90 
     91 function sanitizeFilename(name) {
     92   const base = String(name || "")
     93     .trim()
     94     .toLowerCase()
     95     .replace(/\\.pdf$/i, "")
     96     .replace(/[^a-z0-9._-]+/g, "-")
     97     .replace(/-+/g, "-")
     98     .replace(/^[-.]+|[-.]+$/g, "")
     99     .slice(0, 80);
    100   return base || "pdf";
    101 }
    102 
    103 function userRole(ws) {
    104   return String(ws?.user?.role || "").trim().toLowerCase();
    105 }
    106 
    107 function username(ws) {
    108   return String(ws?.user?.username || "").trim().toLowerCase();
    109 }
    110 
    111 module.exports = function init(api) {
    112   const dataFile = path.join(__dirname, "library.json");
    113   const uploadsRoot = path.resolve(process.env.UPLOADS_DIR || path.join(process.cwd(), "data", "uploads"));
    114   const uploadsDir = path.join(uploadsRoot, "library");
    115   const tmpDir = path.join(uploadsDir, "tmp");
    116 
    117   const inFlight = new Map(); // uploadId -> { fd, tmpPath, metaPath, expected, received, createdAt, createdBy, title, lastSeenAt }
    118   const INFLIGHT_TTL_MS = Number(process.env.LIBRARY_INFLIGHT_TTL_MS || 10 * 60_000); // 10m
    119 
    120   function metaPathFor(uploadId) {
    121     return path.join(tmpDir, `${uploadId}.json`);
    122   }
    123 
    124   function readMeta(metaPath) {
    125     return readJsonOrNull(metaPath);
    126   }
    127 
    128   function writeMeta(metaPath, meta) {
    129     writeFileAtomic(metaPath, JSON.stringify(meta, null, 2) + "\n");
    130   }
    131 
    132   function tryResumeInflight(uploadId, user) {
    133     const metaPath = metaPathFor(uploadId);
    134     const meta = readMeta(metaPath);
    135     if (!meta || String(meta.uploadId || "") !== uploadId) return null;
    136     if (String(meta.createdBy || "") !== String(user || "")) return null;
    137     const tmpPath = String(meta.tmpPath || "");
    138     if (!tmpPath) return null;
    139     if (!fs.existsSync(tmpPath)) return null;
    140     const expected = Number(meta.expected || 0);
    141     if (!Number.isFinite(expected) || expected <= 0 || expected > MAX_PDF_BYTES) return null;
    142 
    143     let received = 0;
    144     try {
    145       received = Number(fs.statSync(tmpPath).size || 0);
    146     } catch {
    147       received = 0;
    148     }
    149     if (!Number.isFinite(received) || received < 0 || received > expected) return null;
    150 
    151     let fd = null;
    152     try {
    153       fd = fs.openSync(tmpPath, "r+");
    154     } catch {
    155       return null;
    156     }
    157     const t = nowMs();
    158     const rec = {
    159       fd,
    160       tmpPath,
    161       metaPath,
    162       expected,
    163       received,
    164       createdAt: Number(meta.createdAt || t),
    165       createdBy: String(meta.createdBy || ""),
    166       title: String(meta.title || ""),
    167       author: normalizeAuthor(meta.author || meta.createdBy || ""),
    168       isOriginal: meta?.isOriginal !== false,
    169       tags: normalizeTags(meta?.tags),
    170       shelfId: normalizeId(meta.shelfId || ""),
    171       lastSeenAt: t
    172     };
    173     inFlight.set(uploadId, rec);
    174     api.log("info", "library:uploadResumed", { uploadId, user: rec.createdBy, received, expected });
    175     return rec;
    176   }
    177 
    178   function normalizeShelfName(name) {
    179     const s = String(name || "").trim().slice(0, 80);
    180     return s || "Untitled shelf";
    181   }
    182 
    183   function normalizeShelfDescription(description) {
    184     return String(description || "").trim().slice(0, 240);
    185   }
    186 
    187   function createShelfId() {
    188     return `shelf-${crypto.randomBytes(8).toString("hex")}`;
    189   }
    190 
    191   function createShelfItemId() {
    192     return `si-${crypto.randomBytes(8).toString("hex")}`;
    193   }
    194 
    195   function normalizeBook(raw) {
    196     const kind = String(raw?.kind || "pdf") === "text" ? "text" : "pdf";
    197     const id = normalizeId(raw?.id);
    198     if (!id) return null;
    199     const createdAt = Number(raw?.createdAt || 0) || nowMs();
    200     const out = {
    201       id,
    202       kind,
    203       title: kind === "text" ? normalizeTextTitle(raw?.title) : normalizeTitle(raw?.title),
    204       author: normalizeAuthor(raw?.author || raw?.createdBy || ""),
    205       isOriginal: raw?.isOriginal !== false,
    206       tags: normalizeTags(raw?.tags),
    207       format: kind === "text" ? (String(raw?.format || "text").toLowerCase() === "rtf" ? "rtf" : "text") : "pdf",
    208       bytes: Number(raw?.bytes || 0),
    209       createdAt,
    210       createdBy: String(raw?.createdBy || ""),
    211       updatedAt: Number(raw?.updatedAt || createdAt) || createdAt,
    212       sourceBookId: normalizeId(raw?.sourceBookId || ""),
    213       sourceShelfId: normalizeId(raw?.sourceShelfId || ""),
    214       checkedOutBy: String(raw?.checkedOutBy || ""),
    215     };
    216     if (kind === "text") {
    217       out.text = typeof raw?.text === "string" ? normalizeTextBody(raw.text) : "";
    218       out.bytes = Number(out.bytes || Buffer.byteLength(String(out.text || ""), "utf8"));
    219       return out;
    220     }
    221     out.url = String(raw?.url || "");
    222     out.filename = String(raw?.filename || "");
    223     if (!out.url || !out.filename) return null;
    224     return out;
    225   }
    226 
    227   function normalizeShelfItem(raw) {
    228     const id = normalizeId(raw?.id);
    229     const bookId = normalizeId(raw?.bookId);
    230     if (!id || !bookId) return null;
    231     const kind = ["own", "pin", "checkout"].includes(String(raw?.kind || "")) ? String(raw.kind) : "own";
    232     return {
    233       id,
    234       bookId,
    235       kind,
    236       addedBy: String(raw?.addedBy || ""),
    237       addedAt: Number(raw?.addedAt || 0) || nowMs(),
    238       note: String(raw?.note || "").slice(0, 180),
    239       sourceBookId: normalizeId(raw?.sourceBookId || ""),
    240       sourceShelfId: normalizeId(raw?.sourceShelfId || ""),
    241     };
    242   }
    243 
    244   function normalizeShelf(raw) {
    245     const id = normalizeId(raw?.id);
    246     if (!id) return null;
    247     const createdAt = Number(raw?.createdAt || 0) || nowMs();
    248     const subscribers = Array.isArray(raw?.subscribers) ? raw.subscribers.map((x) => String(x || "").trim().toLowerCase()).filter(Boolean) : [];
    249     const uniqSubs = [...new Set(subscribers)];
    250     const items = Array.isArray(raw?.items) ? raw.items.map(normalizeShelfItem).filter(Boolean) : [];
    251     return {
    252       id,
    253       name: normalizeShelfName(raw?.name),
    254       description: normalizeShelfDescription(raw?.description),
    255       owner: String(raw?.owner || "").trim().toLowerCase(),
    256       isPublic: raw?.isPublic !== false,
    257       createdAt,
    258       updatedAt: Number(raw?.updatedAt || createdAt) || createdAt,
    259       subscribers: uniqSubs,
    260       items,
    261     };
    262   }
    263 
    264   function userPrimaryShelfId(user) {
    265     return normalizeId(`shelf-user-${String(user || "").trim().toLowerCase()}`);
    266   }
    267 
    268   function ensureUserPrimaryShelf(store, user) {
    269     const u = String(user || "").trim().toLowerCase();
    270     if (!u) return { changed: false, shelf: null };
    271     const id = userPrimaryShelfId(u);
    272     const existing = store.shelves.find((s) => s.id === id);
    273     if (existing) return { changed: false, shelf: existing };
    274     const t = nowMs();
    275     const shelf = {
    276       id,
    277       name: `${u}'s shelf`,
    278       description: "My personal shelf",
    279       owner: u,
    280       isPublic: true,
    281       createdAt: t,
    282       updatedAt: t,
    283       subscribers: [],
    284       items: [],
    285     };
    286     store.shelves.push(shelf);
    287     return { changed: true, shelf };
    288   }
    289 
    290   function migrateLegacy(parsed) {
    291     const legacyItems = Array.isArray(parsed?.items) ? parsed.items : [];
    292     const books = [];
    293     const communityItems = [];
    294     for (const it of legacyItems) {
    295       const book = normalizeBook(it);
    296       if (!book) continue;
    297       books.push(book);
    298       communityItems.push({
    299         id: createShelfItemId(),
    300         bookId: book.id,
    301         kind: "own",
    302         addedBy: String(book.createdBy || ""),
    303         addedAt: Number(book.createdAt || nowMs()),
    304         note: "",
    305         sourceBookId: "",
    306         sourceShelfId: "",
    307       });
    308     }
    309     const t = nowMs();
    310     return {
    311       version: 2,
    312       books,
    313       shelves: [
    314         {
    315           id: "shelf-community",
    316           name: "Community shelf",
    317           description: "Shared shelf migrated from the classic library.",
    318           owner: "",
    319           isPublic: true,
    320           createdAt: t,
    321           updatedAt: t,
    322           subscribers: [],
    323           items: communityItems,
    324         },
    325       ],
    326     };
    327   }
    328 
    329   function loadStore() {
    330     const parsed = readJsonOrNull(dataFile);
    331     if (!parsed || typeof parsed !== "object") {
    332       return { version: 2, books: [], shelves: [] };
    333     }
    334     if (Number(parsed.version || 0) !== 2) return migrateLegacy(parsed);
    335     const books = Array.isArray(parsed.books) ? parsed.books.map(normalizeBook).filter(Boolean) : [];
    336     const byBook = new Map(books.map((b) => [b.id, b]));
    337     const shelves = Array.isArray(parsed.shelves) ? parsed.shelves.map(normalizeShelf).filter(Boolean) : [];
    338     for (const shelf of shelves) {
    339       shelf.items = shelf.items.filter((si) => byBook.has(si.bookId));
    340     }
    341     return { version: 2, books, shelves };
    342   }
    343 
    344   function saveStore(store) {
    345     writeFileAtomic(
    346       dataFile,
    347       JSON.stringify(
    348         {
    349           version: 2,
    350           books: Array.isArray(store?.books) ? store.books : [],
    351           shelves: Array.isArray(store?.shelves) ? store.shelves : [],
    352         },
    353         null,
    354         2
    355       ) + "\n"
    356     );
    357   }
    358 
    359   function findShelf(store, shelfId) {
    360     const id = normalizeId(shelfId);
    361     if (!id) return null;
    362     return store.shelves.find((s) => s.id === id) || null;
    363   }
    364 
    365   function findBook(store, bookId) {
    366     const id = normalizeId(bookId);
    367     if (!id) return null;
    368     return store.books.find((b) => b.id === id) || null;
    369   }
    370 
    371   function removeBookIfOrphan(store, bookId) {
    372     const id = normalizeId(bookId);
    373     if (!id) return null;
    374     const stillReferenced = store.shelves.some((shelf) => shelf.items.some((si) => si.bookId === id));
    375     if (stillReferenced) return null;
    376     const idx = store.books.findIndex((b) => b.id === id);
    377     if (idx < 0) return null;
    378     const book = store.books[idx];
    379     store.books.splice(idx, 1);
    380     return book;
    381   }
    382 
    383   function isShelfOwner(user, shelf) {
    384     return Boolean(user) && String(shelf?.owner || "") === String(user || "");
    385   }
    386 
    387   function canViewShelf(user, shelf) {
    388     if (!shelf) return false;
    389     if (shelf.isPublic) return true;
    390     return isShelfOwner(user, shelf);
    391   }
    392 
    393   function canAccessBook(user, store, bookId) {
    394     const id = normalizeId(bookId);
    395     if (!id) return false;
    396     return store.shelves.some((shelf) => canViewShelf(user, shelf) && shelf.items.some((si) => si.bookId === id));
    397   }
    398 
    399   function popularityByBook(store) {
    400     const byId = new Map();
    401     const add = (id, field) => {
    402       if (!id) return;
    403       if (!byId.has(id)) byId.set(id, { pins: 0, checkouts: 0 });
    404       byId.get(id)[field] += 1;
    405     };
    406     for (const shelf of store.shelves) {
    407       for (const si of shelf.items) {
    408         if (si.kind === "pin") add(si.bookId, "pins");
    409       }
    410     }
    411     for (const book of store.books) {
    412       if (book.sourceBookId) add(book.sourceBookId, "checkouts");
    413     }
    414     return byId;
    415   }
    416 
    417   function makeSnapshot(ws) {
    418     const u = username(ws);
    419     const role = userRole(ws);
    420     const isOwner = role === "owner";
    421     const store = loadStore();
    422     const ensured = ensureUserPrimaryShelf(store, u);
    423     if (ensured.changed) saveStore(store);
    424 
    425     const pop = popularityByBook(store);
    426     const booksById = new Map(store.books.map((b) => [b.id, b]));
    427     const shelves = store.shelves
    428       .filter((shelf) => canViewShelf(u, shelf))
    429       .sort((a, b) => Number(b.updatedAt || 0) - Number(a.updatedAt || 0))
    430       .map((shelf) => {
    431         const shelfOwner = isShelfOwner(u, shelf);
    432         const items = shelf.items
    433           .slice()
    434           .sort((a, b) => Number(b.addedAt || 0) - Number(a.addedAt || 0))
    435           .map((si) => {
    436             const book = booksById.get(si.bookId);
    437             if (!book) return null;
    438             const score = pop.get(book.id) || { pins: 0, checkouts: 0 };
    439             return {
    440               id: si.id,
    441               kind: si.kind,
    442               addedAt: si.addedAt,
    443               addedBy: si.addedBy,
    444               note: si.note || "",
    445               sourceBookId: si.sourceBookId || "",
    446               sourceShelfId: si.sourceShelfId || "",
    447               canRemoveItem: shelfOwner,
    448               canReturn: shelfOwner && si.kind === "checkout",
    449               book: {
    450                 id: book.id,
    451                 kind: book.kind,
    452                 title: book.title,
    453                 author: book.author || normalizeAuthor(book.createdBy || ""),
    454                 isOriginal: book.isOriginal !== false,
    455                 tags: Array.isArray(book.tags) ? book.tags : [],
    456                 format: String(book.format || (book.kind === "text" ? "text" : "pdf")),
    457                 url: book.kind === "pdf" ? book.url : "",
    458                 filename: book.kind === "pdf" ? book.filename : "",
    459                 bytes: book.bytes,
    460                 createdAt: book.createdAt,
    461                 createdBy: book.createdBy,
    462                 updatedAt: book.updatedAt || book.createdAt,
    463                 sourceBookId: book.sourceBookId || "",
    464                 sourceShelfId: book.sourceShelfId || "",
    465                 checkedOutBy: book.checkedOutBy || "",
    466                 popularity: { pins: Number(score.pins || 0), checkouts: Number(score.checkouts || 0) },
    467                 canDeleteBook: isOwner || String(book.createdBy || "") === u,
    468               },
    469             };
    470           })
    471           .filter(Boolean);
    472         return {
    473           id: shelf.id,
    474           name: shelf.name,
    475           description: shelf.description || "",
    476           owner: shelf.owner || "",
    477           isPublic: shelf.isPublic !== false,
    478           createdAt: shelf.createdAt,
    479           updatedAt: shelf.updatedAt,
    480           isOwner: shelfOwner,
    481           isSubscribed: Boolean(u && shelf.subscribers.includes(u)),
    482           subscriberCount: Number((shelf.subscribers || []).length || 0),
    483           items,
    484         };
    485       });
    486 
    487     return {
    488       me: u,
    489       myShelfId: ensured.shelf?.id || userPrimaryShelfId(u),
    490       shelves,
    491     };
    492   }
    493 
    494   function broadcastChanged() {
    495     api.broadcast({ type: "plugin:library:changed" });
    496   }
    497 
    498   function send(ws, msg) {
    499     try {
    500       ws.send(JSON.stringify(msg));
    501       return true;
    502     } catch {
    503       return false;
    504     }
    505   }
    506 
    507   function sendError(ws, message, data) {
    508     send(ws, { type: "plugin:library:error", message: String(message || "Error."), data: data || null });
    509   }
    510 
    511   // Important: do not delete inflight uploads on WS close. Reconnects are common, and
    512   // the client may continue the upload on a new socket. Instead, time out abandoned uploads.
    513   setInterval(() => {
    514     const t = nowMs();
    515     for (const [uploadId, rec] of inFlight.entries()) {
    516       const last = Number(rec?.lastSeenAt || rec?.createdAt || 0);
    517       if (!last || t - last <= INFLIGHT_TTL_MS) continue;
    518       try {
    519         if (rec.fd) fs.closeSync(rec.fd);
    520       } catch {
    521         // ignore
    522       }
    523       try {
    524         if (rec.tmpPath) fs.unlinkSync(rec.tmpPath);
    525       } catch {
    526         // ignore
    527       }
    528       try {
    529         if (rec.metaPath && fs.existsSync(rec.metaPath)) fs.unlinkSync(rec.metaPath);
    530       } catch {
    531         // ignore
    532       }
    533       inFlight.delete(uploadId);
    534       api.log("info", "library:uploadTimeout", { uploadId, user: rec?.createdBy || "", received: rec?.received || 0, expected: rec?.expected || 0 });
    535     }
    536   }, 60_000).unref?.();
    537 
    538   api.registerWs("list", (ws) => {
    539     send(ws, { type: "plugin:library:list", ...makeSnapshot(ws) });
    540   });
    541 
    542   api.registerWs("shelfCreate", (ws, msg) => {
    543     const u = username(ws);
    544     if (!u) return sendError(ws, "Sign in required.");
    545     const name = normalizeShelfName(msg?.name);
    546     const description = normalizeShelfDescription(msg?.description);
    547     const isPublic = msg?.isPublic !== false;
    548     const t = nowMs();
    549     const store = loadStore();
    550     const shelf = {
    551       id: createShelfId(),
    552       name,
    553       description,
    554       owner: u,
    555       isPublic,
    556       createdAt: t,
    557       updatedAt: t,
    558       subscribers: [],
    559       items: [],
    560     };
    561     store.shelves.push(shelf);
    562     saveStore(store);
    563     send(ws, { type: "plugin:library:shelfCreated", ok: true, shelfId: shelf.id });
    564     broadcastChanged();
    565   });
    566 
    567   api.registerWs("shelfUpdate", (ws, msg) => {
    568     const u = username(ws);
    569     if (!u) return sendError(ws, "Sign in required.");
    570     const shelfId = normalizeId(msg?.shelfId);
    571     const store = loadStore();
    572     const shelf = findShelf(store, shelfId);
    573     if (!shelf) return sendError(ws, "Shelf not found.");
    574     if (!isShelfOwner(u, shelf)) return sendError(ws, "Only the shelf owner can edit it.");
    575     shelf.name = normalizeShelfName(msg?.name ?? shelf.name);
    576     shelf.description = normalizeShelfDescription(msg?.description ?? shelf.description);
    577     if (typeof msg?.isPublic === "boolean") shelf.isPublic = msg.isPublic;
    578     shelf.updatedAt = nowMs();
    579     saveStore(store);
    580     send(ws, { type: "plugin:library:shelfUpdated", ok: true, shelfId: shelf.id });
    581     broadcastChanged();
    582   });
    583 
    584   api.registerWs("shelfSubscribe", (ws, msg) => {
    585     const u = username(ws);
    586     if (!u) return sendError(ws, "Sign in required.");
    587     const shelfId = normalizeId(msg?.shelfId);
    588     const subscribe = msg?.subscribe !== false;
    589     const store = loadStore();
    590     const shelf = findShelf(store, shelfId);
    591     if (!shelf || !canViewShelf(u, shelf)) return sendError(ws, "Shelf not found.");
    592     const set = new Set(Array.isArray(shelf.subscribers) ? shelf.subscribers : []);
    593     if (subscribe) set.add(u);
    594     else set.delete(u);
    595     shelf.subscribers = [...set];
    596     shelf.updatedAt = nowMs();
    597     saveStore(store);
    598     send(ws, { type: "plugin:library:shelfSubscribed", ok: true, shelfId: shelf.id, subscribed: subscribe });
    599     broadcastChanged();
    600   });
    601 
    602   api.registerWs("pinBook", (ws, msg) => {
    603     const u = username(ws);
    604     if (!u) return sendError(ws, "Sign in required.");
    605     const shelfId = normalizeId(msg?.shelfId);
    606     const bookId = normalizeId(msg?.bookId);
    607     if (!shelfId || !bookId) return sendError(ws, "Missing shelf/book id.");
    608     const store = loadStore();
    609     const shelf = findShelf(store, shelfId);
    610     if (!shelf) return sendError(ws, "Shelf not found.");
    611     if (!isShelfOwner(u, shelf)) return sendError(ws, "You can only pin into your own shelves.");
    612     const book = findBook(store, bookId);
    613     if (!book) return sendError(ws, "Book not found.");
    614     if (!canAccessBook(u, store, book.id)) return sendError(ws, "Book not found.");
    615     if (shelf.items.some((si) => si.bookId === book.id && si.kind === "pin")) {
    616       return sendError(ws, "Already pinned on this shelf.");
    617     }
    618     shelf.items.push({
    619       id: createShelfItemId(),
    620       bookId: book.id,
    621       kind: "pin",
    622       addedBy: u,
    623       addedAt: nowMs(),
    624       note: "",
    625       sourceBookId: book.id,
    626       sourceShelfId: normalizeId(msg?.sourceShelfId || ""),
    627     });
    628     shelf.updatedAt = nowMs();
    629     saveStore(store);
    630     send(ws, { type: "plugin:library:pinned", ok: true, shelfId: shelf.id, bookId: book.id });
    631     broadcastChanged();
    632   });
    633 
    634   api.registerWs("checkoutBook", (ws, msg) => {
    635     const u = username(ws);
    636     if (!u) return sendError(ws, "Sign in required.");
    637     const targetShelfId = normalizeId(msg?.targetShelfId);
    638     const sourceBookId = normalizeId(msg?.sourceBookId);
    639     const sourceShelfId = normalizeId(msg?.sourceShelfId);
    640     if (!targetShelfId || !sourceBookId) return sendError(ws, "Missing ids.");
    641     const store = loadStore();
    642     const shelf = findShelf(store, targetShelfId);
    643     if (!shelf) return sendError(ws, "Target shelf not found.");
    644     if (!isShelfOwner(u, shelf)) return sendError(ws, "You can only check out to your own shelf.");
    645     const source = findBook(store, sourceBookId);
    646     if (!source) return sendError(ws, "Source book not found.");
    647     if (!canAccessBook(u, store, source.id)) return sendError(ws, "Source book not found.");
    648     const t = nowMs();
    649     const copyId = crypto.randomBytes(10).toString("hex");
    650     const cloned = source.kind === "text"
    651       ? {
    652           id: copyId,
    653           kind: "text",
    654           title: source.title,
    655           author: source.author || source.createdBy || u,
    656           isOriginal: false,
    657           tags: normalizeTags(source.tags),
    658           format: String(source.format || "text").toLowerCase() === "rtf" ? "rtf" : "text",
    659           text: String(source.text || ""),
    660           bytes: Number(source.bytes || Buffer.byteLength(String(source.text || ""), "utf8")),
    661           createdAt: t,
    662           updatedAt: t,
    663           createdBy: u,
    664           sourceBookId: source.id,
    665           sourceShelfId,
    666           checkedOutBy: u,
    667         }
    668       : {
    669           id: copyId,
    670           kind: "pdf",
    671           title: source.title,
    672           author: source.author || source.createdBy || u,
    673           isOriginal: false,
    674           tags: normalizeTags(source.tags),
    675           format: "pdf",
    676           filename: source.filename,
    677           url: source.url,
    678           bytes: source.bytes,
    679           createdAt: t,
    680           updatedAt: t,
    681           createdBy: u,
    682           sourceBookId: source.id,
    683           sourceShelfId,
    684           checkedOutBy: u,
    685         };
    686     store.books.push(cloned);
    687     shelf.items.push({
    688       id: createShelfItemId(),
    689       bookId: cloned.id,
    690       kind: "checkout",
    691       addedBy: u,
    692       addedAt: t,
    693       note: "",
    694       sourceBookId: source.id,
    695       sourceShelfId,
    696     });
    697     shelf.updatedAt = t;
    698     saveStore(store);
    699     send(ws, { type: "plugin:library:checkedOut", ok: true, targetShelfId: shelf.id, newBookId: cloned.id, sourceBookId: source.id });
    700     broadcastChanged();
    701   });
    702 
    703   api.registerWs("shelfItemRemove", (ws, msg) => {
    704     const u = username(ws);
    705     if (!u) return sendError(ws, "Sign in required.");
    706     const shelfId = normalizeId(msg?.shelfId);
    707     const shelfItemId = normalizeId(msg?.shelfItemId);
    708     if (!shelfId || !shelfItemId) return sendError(ws, "Missing ids.");
    709     const store = loadStore();
    710     const shelf = findShelf(store, shelfId);
    711     if (!shelf) return sendError(ws, "Shelf not found.");
    712     if (!isShelfOwner(u, shelf)) return sendError(ws, "Only the shelf owner can remove items.");
    713     const row = shelf.items.find((si) => si.id === shelfItemId);
    714     if (!row) return sendError(ws, "Item not found.");
    715     shelf.items = shelf.items.filter((si) => si.id !== shelfItemId);
    716     const maybeRemovedBook = removeBookIfOrphan(store, row.bookId);
    717     if (maybeRemovedBook?.kind === "pdf" && maybeRemovedBook.filename) {
    718       const filePath = path.join(uploadsDir, maybeRemovedBook.filename);
    719       try {
    720         if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
    721       } catch {
    722         // ignore
    723       }
    724     }
    725     shelf.updatedAt = nowMs();
    726     saveStore(store);
    727     send(ws, { type: "plugin:library:shelfItemRemoved", ok: true, shelfId, shelfItemId });
    728     broadcastChanged();
    729   });
    730 
    731   api.registerWs("returnBook", (ws, msg) => {
    732     const u = username(ws);
    733     if (!u) return sendError(ws, "Sign in required.");
    734     const shelfId = normalizeId(msg?.shelfId);
    735     const shelfItemId = normalizeId(msg?.shelfItemId);
    736     if (!shelfId || !shelfItemId) return sendError(ws, "Missing ids.");
    737     const store = loadStore();
    738     const shelf = findShelf(store, shelfId);
    739     if (!shelf) return sendError(ws, "Shelf not found.");
    740     if (!isShelfOwner(u, shelf)) return sendError(ws, "Only the shelf owner can return books.");
    741     const row = shelf.items.find((si) => si.id === shelfItemId);
    742     if (!row) return sendError(ws, "Item not found.");
    743     if (row.kind !== "checkout") return sendError(ws, "Only checked-out books can be returned.");
    744 
    745     shelf.items = shelf.items.filter((si) => si.id !== shelfItemId);
    746     const removed = removeBookIfOrphan(store, row.bookId);
    747     if (removed?.kind === "pdf" && removed.filename) {
    748       const filePath = path.join(uploadsDir, removed.filename);
    749       try {
    750         if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
    751       } catch {
    752         // ignore
    753       }
    754     }
    755     shelf.updatedAt = nowMs();
    756     saveStore(store);
    757     send(ws, { type: "plugin:library:returned", ok: true, shelfId, shelfItemId, bookId: row.bookId });
    758     broadcastChanged();
    759   });
    760 
    761   api.registerWs("textGet", (ws, msg) => {
    762     const u = username(ws);
    763     if (!u) return sendError(ws, "Sign in required.");
    764     const id = normalizeId(msg?.id);
    765     if (!id) return sendError(ws, "Missing id.");
    766     const store = loadStore();
    767     if (!canAccessBook(u, store, id)) return sendError(ws, "Not found.");
    768     const it = findBook(store, id);
    769     if (!it || it.kind !== "text") return sendError(ws, "Not found.");
    770     send(ws, {
    771       type: "plugin:library:text",
    772       item: {
    773         id: it.id,
    774         kind: "text",
    775         title: it.title,
    776         author: it.author || normalizeAuthor(it.createdBy || ""),
    777         isOriginal: it.isOriginal !== false,
    778         tags: Array.isArray(it.tags) ? it.tags : [],
    779         format: String(it.format || "text"),
    780         text: String(it.text || ""),
    781         bytes: Number(it.bytes || Buffer.byteLength(String(it.text || ""), "utf8")),
    782         createdAt: it.createdAt,
    783         createdBy: it.createdBy,
    784         updatedAt: it.updatedAt || it.createdAt,
    785       },
    786     });
    787   });
    788 
    789   api.registerWs("textCreate", (ws, msg) => {
    790     const u = username(ws);
    791     if (!u) return sendError(ws, "Sign in required.");
    792     const store = loadStore();
    793     const ensured = ensureUserPrimaryShelf(store, u);
    794     const shelfId = normalizeId(msg?.shelfId || ensured.shelf?.id);
    795     const shelf = findShelf(store, shelfId);
    796     if (!shelf) return sendError(ws, "Shelf not found.");
    797     if (!isShelfOwner(u, shelf)) return sendError(ws, "You can only add books to your own shelf.");
    798     const title = normalizeTextTitle(msg?.title);
    799     const author = normalizeAuthor(msg?.author || u);
    800     const isOriginal = msg?.isOriginal !== false;
    801     const tags = normalizeTags(msg?.tags);
    802     const format = String(msg?.format || "text").toLowerCase() === "rtf" ? "rtf" : "text";
    803     const text = normalizeTextBody(msg?.text);
    804     const bytes = Buffer.byteLength(text, "utf8");
    805     if (bytes > MAX_TEXT_BYTES) return sendError(ws, `Text too large. Max is ${MAX_TEXT_BYTES} bytes.`);
    806 
    807     const id = crypto.randomBytes(10).toString("hex");
    808     const t = nowMs();
    809     store.books.push({
    810       id,
    811       kind: "text",
    812       title,
    813       author,
    814       isOriginal,
    815       tags,
    816       format,
    817       text,
    818       bytes,
    819       createdAt: t,
    820       updatedAt: t,
    821       createdBy: u,
    822     });
    823     shelf.items.push({
    824       id: createShelfItemId(),
    825       bookId: id,
    826       kind: "own",
    827       addedBy: u,
    828       addedAt: t,
    829       note: "",
    830       sourceBookId: "",
    831       sourceShelfId: "",
    832     });
    833     shelf.updatedAt = t;
    834     saveStore(store);
    835     api.log("info", "library:textCreate", { id, bytes, user: u, shelfId: shelf.id });
    836     send(ws, { type: "plugin:library:textCreated", ok: true, id });
    837     broadcastChanged();
    838   });
    839 
    840   api.registerWs("textUpdate", (ws, msg) => {
    841     const u = username(ws);
    842     if (!u) return sendError(ws, "Sign in required.");
    843     const id = normalizeId(msg?.id);
    844     if (!id) return sendError(ws, "Missing id.");
    845 
    846     const store = loadStore();
    847     const idx = store.books.findIndex((x) => x.id === id);
    848     if (idx < 0) return sendError(ws, "Not found.");
    849     const it = store.books[idx];
    850     if (it.kind !== "text") return sendError(ws, "Not found.");
    851     if (String(it.createdBy || "") !== u) return sendError(ws, "Only the author can edit this text.");
    852 
    853     const title = normalizeTextTitle(msg?.title ?? it.title);
    854     const author = normalizeAuthor(msg?.author ?? it.author ?? u);
    855     const isOriginal = typeof msg?.isOriginal === "boolean" ? msg.isOriginal : it.isOriginal !== false;
    856     const tags = Array.isArray(msg?.tags) ? normalizeTags(msg.tags) : Array.isArray(it.tags) ? normalizeTags(it.tags) : [];
    857     const format = String(msg?.format || it.format || "text").toLowerCase() === "rtf" ? "rtf" : "text";
    858     const text = normalizeTextBody(msg?.text);
    859     const bytes = Buffer.byteLength(text, "utf8");
    860     if (bytes > MAX_TEXT_BYTES) return sendError(ws, `Text too large. Max is ${MAX_TEXT_BYTES} bytes.`);
    861 
    862     const t = nowMs();
    863     store.books[idx] = { ...it, title, author, isOriginal, tags, format, text, bytes, updatedAt: t };
    864     saveStore(store);
    865     api.log("info", "library:textUpdate", { id, bytes, user: u });
    866     send(ws, { type: "plugin:library:textUpdated", ok: true, id, updatedAt: t });
    867     broadcastChanged();
    868   });
    869 
    870   api.registerWs("uploadStart", (ws, msg) => {
    871     const u = username(ws);
    872     if (!u) return sendError(ws, "Sign in required.");
    873     const store = loadStore();
    874     const ensured = ensureUserPrimaryShelf(store, u);
    875     const shelfId = normalizeId(msg?.shelfId || ensured.shelf?.id);
    876     const shelf = findShelf(store, shelfId);
    877     if (!shelf) return sendError(ws, "Shelf not found.");
    878     if (!isShelfOwner(u, shelf)) return sendError(ws, "You can only upload to your own shelf.");
    879     if (ensured.changed) saveStore(store);
    880 
    881     const size = Number(msg?.size || 0);
    882     if (!Number.isFinite(size) || size <= 0) return sendError(ws, "Invalid file size.");
    883     if (size > MAX_PDF_BYTES) return sendError(ws, `PDF too large. Max is ${MAX_PDF_BYTES} bytes.`);
    884 
    885     const original = String(msg?.filename || "").trim();
    886     const mime = String(msg?.mime || "").trim().toLowerCase();
    887     const isPdf = /\\.pdf$/i.test(original) || mime === "application/pdf";
    888     if (!isPdf) return sendError(ws, "Only PDF files are supported.");
    889 
    890     const title = normalizeTitle(msg?.title || original.replace(/\\.pdf$/i, ""));
    891     const author = normalizeAuthor(msg?.author || u);
    892     const isOriginal = msg?.isOriginal !== false;
    893     const tags = normalizeTags(msg?.tags);
    894     const uploadId = crypto.randomBytes(12).toString("hex");
    895 
    896     fs.mkdirSync(tmpDir, { recursive: true });
    897     const tmpPath = path.join(tmpDir, `${uploadId}.part`);
    898     const metaPath = metaPathFor(uploadId);
    899     let fd = null;
    900     try {
    901       fd = fs.openSync(tmpPath, "w");
    902     } catch (e) {
    903       return sendError(ws, "Failed to start upload.", { error: e?.message || String(e) });
    904     }
    905 
    906     const t = nowMs();
    907     try {
    908       writeMeta(metaPath, {
    909         version: 1,
    910         uploadId,
    911         tmpPath,
    912         expected: size,
    913         received: 0,
    914         createdAt: t,
    915         createdBy: u,
    916         title,
    917         author,
    918         isOriginal,
    919         tags,
    920         shelfId: shelf.id,
    921       });
    922     } catch (e) {
    923       try {
    924         fs.closeSync(fd);
    925       } catch {
    926         // ignore
    927       }
    928       try {
    929         fs.unlinkSync(tmpPath);
    930       } catch {
    931         // ignore
    932       }
    933       return sendError(ws, "Failed to start upload.", { error: e?.message || String(e) });
    934     }
    935 
    936     inFlight.set(uploadId, {
    937       fd,
    938       tmpPath,
    939       metaPath,
    940       expected: size,
    941       received: 0,
    942       createdAt: t,
    943       createdBy: u,
    944       title,
    945       author,
    946       isOriginal,
    947       tags,
    948       shelfId: shelf.id,
    949       lastSeenAt: t,
    950     });
    951     api.log("info", "library:uploadStart", { uploadId, size, user: u, shelfId: shelf.id });
    952     send(ws, { type: "plugin:library:uploadStarted", uploadId, maxBytes: MAX_PDF_BYTES });
    953   });
    954 
    955   api.registerWs("uploadChunk", (ws, msg) => {
    956     const u = username(ws);
    957     if (!u) return sendError(ws, "Sign in required.");
    958     const uploadId = normalizeId(msg?.uploadId);
    959     let rec = inFlight.get(uploadId);
    960     if (!rec) rec = tryResumeInflight(uploadId, u);
    961     if (!rec || rec.createdBy !== u) return sendError(ws, "Upload not found.");
    962 
    963     const b64 = typeof msg?.data === "string" ? msg.data : "";
    964     if (!b64) return sendError(ws, "Missing chunk data.");
    965     if (b64.length > CHUNK_MAX_B64) return sendError(ws, "Chunk too large.");
    966 
    967     let buf = null;
    968     try {
    969       buf = Buffer.from(b64, "base64");
    970     } catch (e) {
    971       return sendError(ws, "Invalid base64 chunk.", { error: e?.message || String(e) });
    972     }
    973     if (!buf.length) return sendError(ws, "Empty chunk.");
    974 
    975     const next = rec.received + buf.length;
    976     if (next > rec.expected) return sendError(ws, "Upload exceeds expected size.");
    977 
    978     try {
    979       fs.writeSync(rec.fd, buf, 0, buf.length, rec.received);
    980     } catch (e) {
    981       return sendError(ws, "Failed writing chunk.", { error: e?.message || String(e) });
    982     }
    983     rec.received = next;
    984     rec.lastSeenAt = nowMs();
    985     inFlight.set(uploadId, rec);
    986     try {
    987       writeMeta(rec.metaPath, {
    988         version: 1,
    989         uploadId,
    990         tmpPath: rec.tmpPath,
    991         expected: rec.expected,
    992         received: rec.received,
    993         createdAt: rec.createdAt,
    994         createdBy: rec.createdBy,
    995         title: rec.title,
    996         author: rec.author || rec.createdBy || "",
    997         isOriginal: rec.isOriginal !== false,
    998         tags: normalizeTags(rec.tags),
    999         shelfId: rec.shelfId || "",
   1000       });
   1001     } catch {
   1002       // ignore
   1003     }
   1004 
   1005     if (rec.received % (1024 * 1024) < buf.length) {
   1006       send(ws, { type: "plugin:library:uploadProgress", uploadId, received: rec.received, expected: rec.expected });
   1007     }
   1008   });
   1009 
   1010   api.registerWs("uploadFinish", (ws, msg) => {
   1011     const u = username(ws);
   1012     if (!u) return sendError(ws, "Sign in required.");
   1013     const uploadId = normalizeId(msg?.uploadId);
   1014     let rec = inFlight.get(uploadId);
   1015     if (!rec) rec = tryResumeInflight(uploadId, u);
   1016     if (!rec || rec.createdBy !== u) return sendError(ws, "Upload not found.");
   1017 
   1018     if (rec.received !== rec.expected) {
   1019       return sendError(ws, "Upload incomplete.", { received: rec.received, expected: rec.expected });
   1020     }
   1021 
   1022     try {
   1023       fs.closeSync(rec.fd);
   1024     } catch {
   1025       // ignore
   1026     }
   1027 
   1028     fs.mkdirSync(uploadsDir, { recursive: true });
   1029     const stamp = new Date(rec.createdAt).toISOString().replace(/[:.]/g, "-");
   1030     const safe = sanitizeFilename(rec.title);
   1031     const finalName = `${safe}-${stamp}-${crypto.randomBytes(4).toString("hex")}.pdf`;
   1032     const finalPath = path.join(uploadsDir, finalName);
   1033     try {
   1034       fs.renameSync(rec.tmpPath, finalPath);
   1035     } catch (e) {
   1036       try {
   1037         fs.unlinkSync(rec.tmpPath);
   1038       } catch {
   1039         // ignore
   1040       }
   1041       inFlight.delete(uploadId);
   1042       return sendError(ws, "Failed to finalize upload.", { error: e?.message || String(e) });
   1043     }
   1044 
   1045     const store = loadStore();
   1046     const shelf = findShelf(store, rec.shelfId || userPrimaryShelfId(u));
   1047     if (!shelf || !isShelfOwner(u, shelf)) {
   1048       try {
   1049         if (fs.existsSync(finalPath)) fs.unlinkSync(finalPath);
   1050       } catch {
   1051         // ignore
   1052       }
   1053       inFlight.delete(uploadId);
   1054       return sendError(ws, "Target shelf not found.");
   1055     }
   1056     const itemId = crypto.randomBytes(10).toString("hex");
   1057     const item = {
   1058       id: itemId,
   1059       kind: "pdf",
   1060       title: rec.title,
   1061       author: normalizeAuthor(rec.author || u),
   1062       isOriginal: rec.isOriginal !== false,
   1063       tags: normalizeTags(rec.tags),
   1064       format: "pdf",
   1065       filename: finalName,
   1066       url: `/uploads/library/${finalName}`,
   1067       bytes: rec.expected,
   1068       createdAt: rec.createdAt,
   1069       createdBy: u,
   1070     };
   1071     store.books.push(item);
   1072     shelf.items.push({
   1073       id: createShelfItemId(),
   1074       bookId: itemId,
   1075       kind: "own",
   1076       addedBy: u,
   1077       addedAt: rec.createdAt,
   1078       note: "",
   1079       sourceBookId: "",
   1080       sourceShelfId: "",
   1081     });
   1082     shelf.updatedAt = nowMs();
   1083     saveStore(store);
   1084     inFlight.delete(uploadId);
   1085     try {
   1086       if (rec.metaPath && fs.existsSync(rec.metaPath)) fs.unlinkSync(rec.metaPath);
   1087     } catch {
   1088       // ignore
   1089     }
   1090 
   1091     api.log("info", "library:uploadFinish", { id: itemId, bytes: rec.expected, user: u });
   1092     send(ws, { type: "plugin:library:uploadFinished", ok: true, item });
   1093     broadcastChanged();
   1094   });
   1095 
   1096   api.registerWs("delete", (ws, msg) => {
   1097     const u = username(ws);
   1098     if (!u) return sendError(ws, "Sign in required.");
   1099     const id = normalizeId(msg?.id);
   1100     if (!id) return sendError(ws, "Missing id.");
   1101 
   1102     const role = userRole(ws);
   1103     const isOwner = role === "owner";
   1104 
   1105     const store = loadStore();
   1106     const idx = store.books.findIndex((it) => it.id === id);
   1107     if (idx < 0) return sendError(ws, "Not found.");
   1108     const item = store.books[idx];
   1109     if (!isOwner && item.createdBy !== u) return sendError(ws, "Not allowed.");
   1110 
   1111     store.books.splice(idx, 1);
   1112     for (const shelf of store.shelves) {
   1113       const before = shelf.items.length;
   1114       shelf.items = shelf.items.filter((si) => si.bookId !== id);
   1115       if (shelf.items.length !== before) shelf.updatedAt = nowMs();
   1116     }
   1117     saveStore(store);
   1118 
   1119     if (item.kind === "pdf" && item.filename) {
   1120       const stillUsed = store.books.some((b) => b.kind === "pdf" && String(b.filename || "") === String(item.filename || ""));
   1121       if (!stillUsed) {
   1122         const filePath = path.join(uploadsDir, item.filename);
   1123         try {
   1124           if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
   1125         } catch {
   1126           // ignore
   1127         }
   1128       }
   1129     }
   1130 
   1131     api.log("info", "library:delete", { id, user: u });
   1132     send(ws, { type: "plugin:library:deleted", ok: true, id });
   1133     broadcastChanged();
   1134   });
   1135 };