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 };