// E-book library: EPUB, PDF and DOCX files kept per person under /data/users//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); } async function handle(req, res) { const u = new URL(req.url, 'http://x'), p = u.pathname.slice('/api/read'.length) || '/'; let m; 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' }); if (!looksLike(ext, buf)) return json(res, 400, { error: 'That does not look like a real ' + ext.toUpperCase() + ' file' }); if (MOBI.has(ext) && mobiLocked(buf)) return json(res, 400, { 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 json(res, 200, { ...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(path.basename(name, '.' + ext).replace(/[_]+/g, ' '), 200) || 'Untitled', author: '', cover: false }; list.push(book); saveIndex(list); return json(res, 200, book); } 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' }); } module.exports = { handle, mergeNotes, looksLike };