Books: read DRM-free MOBI, AZW and AZW3; refuse Kindle files with DRM

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaC9HmtHMdRP8dAbN4wAKj
This commit is contained in:
Claude committed 2026-10-24 02:54:34 +00:00
1 parent 07c16ddc25
commit 2e99893c8b
7 files changed
+138 -21

No files matched your search

+7 -2
View File
@@ -4,7 +4,7 @@ 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', docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' };
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; };
@@ -23,9 +23,13 @@ async function body(req, max) {
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');
}
@@ -51,9 +55,10 @@ async function handle(req, res) {
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 and DOCX books can be added' });
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');