Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UaC9HmtHMdRP8dAbN4wAKj
151 lines
11 KiB
JavaScript
151 lines
11 KiB
JavaScript
// E-book library: EPUB, PDF and DOCX files kept per person under /data/users/<id>/books/. The server stores the files, covers, where each person got to in each
|
|
// book, and their bookmarks, notes and highlights; the browser does the reading (epub.js, pdf.js and mammoth, in public/vendor/reader).
|
|
const fs = require('fs'), path = require('path'), crypto = require('crypto');
|
|
const users = require('./users');
|
|
|
|
const MAX_BOOK = 300 * 1024 * 1024, MAX_COVER = 3 * 1024 * 1024, MAX_JSON = 2e6;
|
|
const TYPES = { epub: 'application/epub+zip', pdf: 'application/pdf', mobi: 'application/x-mobipocket-ebook', azw: 'application/vnd.amazon.ebook', azw3: 'application/vnd.amazon.mobi8-ebook', docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' };
|
|
const COVERS = { 'image/jpeg': 'jpg', 'image/png': 'png', 'image/gif': 'gif', 'image/webp': 'webp' };
|
|
|
|
const dir = () => { const d = path.join(users.dir(), 'books'); fs.mkdirSync(d, { recursive: true, mode: 0o700 }); return d; };
|
|
const json = (res, code, o) => { res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(o)); };
|
|
const readJson = (f, dflt) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return dflt; } };
|
|
const writeJson = (f, o) => { const tmp = f + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(o), { mode: 0o600 }); fs.renameSync(tmp, f); };
|
|
const index = () => { const l = readJson(path.join(dir(), 'index.json'), []); return Array.isArray(l) ? l : []; };
|
|
const saveIndex = l => writeJson(path.join(dir(), 'index.json'), l);
|
|
const state = () => readJson(path.join(dir(), 'state.json'), {});
|
|
const saveState = s => writeJson(path.join(dir(), 'state.json'), s);
|
|
const clip = (s, n) => String(s == null ? '' : s).replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, '').trim().slice(0, n);
|
|
const coverFile = id => fs.readdirSync(dir()).find(f => f.startsWith(id + '.cover.'));
|
|
|
|
async function body(req, max) {
|
|
const chunks = []; let n = 0;
|
|
for await (const c of req) { n += c.length; if (n > max) return null; chunks.push(c); }
|
|
return Buffer.concat(chunks);
|
|
}
|
|
const MOBI = new Set(['mobi', 'azw', 'azw3']);
|
|
/** Kindle files with DRM have a non-zero encryption type in their first record (the PalmDOC header, 12 bytes in). Those cannot be read and are refused. */
|
|
function mobiLocked(buf) { try { const rec0 = buf.readUInt32BE(78); return buf.readUInt16BE(rec0 + 12) !== 0; } catch { return true; } }
|
|
/** A file must look like what its name says: PDFs start with %PDF, EPUB and DOCX are zip archives (and an EPUB says so in its first entry). */
|
|
function looksLike(ext, buf) {
|
|
if (ext === 'pdf') return buf.subarray(0, 1024).includes('%PDF-');
|
|
if (MOBI.has(ext)) return buf.length > 100 && buf.toString('latin1', 60, 68) === 'BOOKMOBI';
|
|
if (buf.length < 4 || buf[0] !== 0x50 || buf[1] !== 0x4b) return false;
|
|
return ext === 'epub' ? buf.subarray(0, 200).includes('mimetypeapplication/epub+zip') || buf.includes('META-INF/container.xml') : buf.includes('word/document.xml');
|
|
}
|
|
const withState = (b, st) => ({ ...b, loc: (st[b.id] || {}).loc || null, pct: (st[b.id] || {}).pct || 0, read: (st[b.id] || {}).t || 0 });
|
|
|
|
/** Merge notes by id; for each id the newest edit (or deletion) wins, so two devices can add notes without losing each other's. */
|
|
function mergeNotes(have, sent) {
|
|
const by = new Map((have || []).map(n => [n.id, n]));
|
|
for (const n of Array.isArray(sent) ? sent.slice(0, 2000) : []) {
|
|
if (!n || !/^[\w-]{1,40}$/.test(n.id || '')) continue;
|
|
const clean = { id: n.id, t: +n.t || 0, type: ['mark', 'hl', 'note'].includes(n.type) ? n.type : 'mark', loc: clip(n.loc, 3000), pct: Math.min(1, Math.max(0, +n.pct || 0)),
|
|
text: clip(n.text, 2000), note: clip(n.note, 5000), color: /^[a-z]{3,12}$/.test(n.color || '') ? n.color : 'yellow', page: Math.max(0, Math.round(+n.page || 0)), del: !!n.del };
|
|
const old = by.get(n.id);
|
|
if (!old || clean.t >= old.t) by.set(n.id, clean);
|
|
}
|
|
return [...by.values()].filter(n => !n.del || Date.now() - n.t < 30 * 864e5);
|
|
}
|
|
|
|
/** Put a book on the signed-in person's shelf (the same checks for an upload and for a download from the store). Returns the book; `duplicate` is set when it was already there. */
|
|
function addBook(buf, ext, meta = {}, cover) {
|
|
if (!TYPES[ext]) throw new Error('Only EPUB, PDF, DOCX, MOBI, AZW and AZW3 books can be added');
|
|
if (buf.length > MAX_BOOK) throw new Error('That file is larger than 300 MB');
|
|
if (!looksLike(ext, buf)) throw new Error('That does not look like a real ' + ext.toUpperCase() + ' file');
|
|
if (MOBI.has(ext) && mobiLocked(buf)) throw new Error('This book has DRM (copy protection), which NE Hub cannot open. Only DRM-free books work.');
|
|
const hash = crypto.createHash('sha1').update(buf).digest('hex'), list = index(), dup = list.find(b => b.hash === hash);
|
|
if (dup) return { ...dup, duplicate: true };
|
|
const id = crypto.randomBytes(8).toString('hex');
|
|
fs.writeFileSync(path.join(dir(), id + '.' + ext), buf, { mode: 0o600 });
|
|
const book = { id, ext, hash, size: buf.length, added: Date.now(), title: clip(meta.title, 200) || 'Untitled', author: clip(meta.author, 200), cover: false };
|
|
if (cover && COVERS[cover.type] && cover.data.length <= MAX_COVER) { fs.writeFileSync(path.join(dir(), `${id}.cover.${COVERS[cover.type]}`), cover.data, { mode: 0o600 }); book.cover = true; }
|
|
list.push(book); saveIndex(list); return book;
|
|
}
|
|
|
|
async function handle(req, res) {
|
|
const u = new URL(req.url, 'http://x'), p = u.pathname.slice('/api/read'.length) || '/';
|
|
let m;
|
|
if (p.startsWith('/store/')) return require('./store').handle(req, res, p.slice('/store'.length));
|
|
if (p === '/list' && req.method === 'GET') { const st = state(); return json(res, 200, index().map(b => withState(b, st))); }
|
|
|
|
if (p === '/upload' && req.method === 'POST') {
|
|
const name = clip(u.searchParams.get('name'), 200), ext = path.extname(name).slice(1).toLowerCase();
|
|
if (!TYPES[ext]) return json(res, 400, { error: 'Only EPUB, PDF, DOCX, MOBI, AZW and AZW3 books can be added' });
|
|
const buf = await body(req, MAX_BOOK); if (!buf) return json(res, 413, { error: 'That file is larger than 300 MB' });
|
|
try { return json(res, 200, addBook(buf, ext, { title: path.basename(name, '.' + ext).replace(/[_]+/g, ' ') })); } catch (e) { return json(res, 400, { error: e.message }); }
|
|
}
|
|
|
|
if (p === '/state' && req.method === 'GET') return json(res, 200, state());
|
|
|
|
if ((m = /^\/book\/(\w{16})(?:\/(file|cover|pos|notes))?$/.exec(p))) {
|
|
const id = m[1], part = m[2], list = index(), book = list.find(b => b.id === id);
|
|
if (!book) return json(res, 404, { error: 'Book not found' });
|
|
|
|
if (part === 'file' && req.method === 'GET') { // the book itself, with range support so PDFs can load page by page
|
|
const f = path.join(dir(), id + '.' + book.ext), size = fs.statSync(f).size, range = /^bytes=(\d*)-(\d*)$/.exec(req.headers.range || '');
|
|
const h = { 'content-type': TYPES[book.ext], 'accept-ranges': 'bytes', 'cache-control': 'private, max-age=3600', 'content-disposition': 'attachment; filename*=UTF-8\'\'' + encodeURIComponent(book.title + '.' + book.ext) };
|
|
if (u.searchParams.get('inline') === '1') h['content-disposition'] = 'inline';
|
|
let a = 0, b = size - 1, code = 200;
|
|
if (range && (range[1] || range[2])) {
|
|
a = range[1] ? +range[1] : Math.max(0, size - +range[2]); b = range[1] && range[2] ? Math.min(+range[2], size - 1) : size - 1;
|
|
if (a > b || a >= size) { res.writeHead(416, { 'content-range': 'bytes */' + size }); return res.end(); }
|
|
code = 206; h['content-range'] = `bytes ${a}-${b}/${size}`;
|
|
}
|
|
h['content-length'] = b - a + 1; res.writeHead(code, h);
|
|
return fs.createReadStream(f, { start: a, end: b }).pipe(res);
|
|
}
|
|
|
|
if (part === 'cover') {
|
|
if (req.method === 'GET') {
|
|
const c = coverFile(id); if (!c) return json(res, 404, { error: 'No cover' });
|
|
res.writeHead(200, { 'content-type': Object.entries(COVERS).find(([, e]) => c.endsWith('.' + e))[0], 'cache-control': 'private, max-age=86400' });
|
|
return fs.createReadStream(path.join(dir(), c)).pipe(res);
|
|
}
|
|
if (req.method === 'PUT') {
|
|
const type = String(req.headers['content-type'] || '').split(';')[0].trim();
|
|
if (!COVERS[type]) return json(res, 400, { error: 'Cover must be a JPEG, PNG, GIF or WebP image' });
|
|
const buf = await body(req, MAX_COVER); if (!buf) return json(res, 413, { error: 'Cover too large' });
|
|
const old = coverFile(id); if (old) fs.unlinkSync(path.join(dir(), old));
|
|
fs.writeFileSync(path.join(dir(), `${id}.cover.${COVERS[type]}`), buf, { mode: 0o600 });
|
|
book.cover = true; saveIndex(list); return json(res, 200, book);
|
|
}
|
|
}
|
|
|
|
if (part === 'pos' && req.method === 'POST') { // where you got to; the newest report wins so an old tab cannot undo a newer position
|
|
const b = await body(req, 1e4); let o; try { o = JSON.parse((b || '').toString('utf8') || '{}'); } catch { return json(res, 400, { error: 'Bad request' }); }
|
|
const st = state(), cur = st[id] || {};
|
|
if ((+o.t || 0) >= (cur.t || 0)) st[id] = { ...cur, loc: clip(o.loc, 600), pct: Math.min(1, Math.max(0, +o.pct || 0)), t: +o.t || Date.now() };
|
|
saveState(st); return json(res, 200, st[id]);
|
|
}
|
|
|
|
if (part === 'notes') {
|
|
const st = state(), cur = st[id] || {};
|
|
if (req.method === 'GET') return json(res, 200, (cur.notes || []).filter(n => !n.del));
|
|
if (req.method === 'POST') {
|
|
const b = await body(req, MAX_JSON); let o; try { o = JSON.parse((b || '').toString('utf8') || '[]'); } catch { return json(res, 400, { error: 'Bad request' }); }
|
|
cur.notes = mergeNotes(cur.notes, o); st[id] = cur; saveState(st); return json(res, 200, cur.notes.filter(n => !n.del));
|
|
}
|
|
}
|
|
|
|
if (!part && req.method === 'PATCH') { // title and author (the browser reads them out of the book after it is added)
|
|
const b = await body(req, 1e4); let o; try { o = JSON.parse((b || '').toString('utf8') || '{}'); } catch { return json(res, 400, { error: 'Bad request' }); }
|
|
if (o.title !== undefined) book.title = clip(o.title, 200) || book.title;
|
|
if (o.author !== undefined) book.author = clip(o.author, 200);
|
|
saveIndex(list); return json(res, 200, book);
|
|
}
|
|
|
|
if (!part && req.method === 'DELETE') {
|
|
for (const f of fs.readdirSync(dir())) if (f.startsWith(id + '.')) fs.unlinkSync(path.join(dir(), f));
|
|
saveIndex(list.filter(b => b.id !== id)); const st = state(); delete st[id]; saveState(st);
|
|
return json(res, 200, { removed: true });
|
|
}
|
|
}
|
|
return json(res, 404, { error: 'Not found' });
|
|
}
|
|
|
|
/** Where a book's file is on disk (for the signed-in person). */
|
|
const fileOf = id => { const b = index().find(x => x.id === id); return b ? { path: path.join(dir(), id + '.' + b.ext), ext: b.ext, title: b.title, author: b.author } : null; };
|
|
|
|
module.exports = { handle, mergeNotes, looksLike, addBook, index, TYPES, fileOf };
|