/* * Book covers, end-to-end encrypted (docs/library.md). A cover the server has * (has_cover) is fetched and decrypted here; a book without one gets it from its own * file — on a device that keeps the books (the desktop and Android apps, or the * browser's cache): the EPUB's cover image or a PDF's first page, scaled to 300 px, * encrypted with the user's key and sent once. The server never sees a cover. * * Gently: one book at a time, when the page is idle, never while a book is open, and * not at all on e-ink or in diora Reader (the list there has no covers). Decrypted * covers are kept on this device (IndexedDB) and handed out as object URLs. * * DioraCovers.url(book) an object URL, or null (and it is looked for, once) * DioraCovers.onReady(fn) fn(bookId) when one becomes available * * Chrome-61-safe: no ?. / ??. */ (function () { 'use strict'; const MAX_W = 300, MAX_H = 450, QUALITY = 0.72; const html = document.documentElement; const off = () => html.classList.contains('nui-reader-app') || (document.body && document.body.classList.contains('eink')); const readerOpen = () => { const o = document.getElementById('reader-overlay'); return !!o && o.style.display !== 'none'; // open: '' (the markup starts with none) }; const localBooks = () => !!(window.dioraDesktop || window.DioraNative); // the app keeps every file const urls = new Map(); // id -> object URL const tried = new Set(); // looked for this session const queue = []; const listeners = []; let busy = false; // ---- this device's store --------------------------------------------------------------- let dbp = null; function db() { if (!dbp) { dbp = new Promise(resolve => { try { const req = indexedDB.open('diora_covers', 1); req.onupgradeneeded = () => req.result.createObjectStore('covers'); req.onsuccess = () => resolve(req.result); req.onerror = () => resolve(null); } catch (e) { resolve(null); } }); } return dbp; } async function readLocal(id) { const d = await db(); if (!d) return null; return new Promise(resolve => { try { const req = d.transaction('covers').objectStore('covers').get(String(id)); req.onsuccess = () => resolve(req.result || null); req.onerror = () => resolve(null); } catch (e) { resolve(null); } }); } async function writeLocal(id, blob) { const d = await db(); if (!d) return; try { d.transaction('covers', 'readwrite').objectStore('covers').put(blob, String(id)); } catch (e) {} } function ready(id, blob) { if (urls.has(id)) URL.revokeObjectURL(urls.get(id)); urls.set(id, URL.createObjectURL(blob)); listeners.slice().forEach(fn => { try { fn(id); } catch (e) {} }); } // ---- finding one ------------------------------------------------------------------------------ async function key() { return typeof getOrCreateEncKey === 'function' ? getOrCreateEncKey() : null; } async function fromServer(id) { const r = await fetch('/books/' + id + '/cover/', {credentials: 'same-origin'}); if (!r.ok) return null; const d = await r.json(); const plain = await decryptBytes(await key(), d.cover_iv, d.cover_ct); return new Blob([plain], {type: 'image/jpeg'}); } // The book's own file, decrypted: the browser's cache, or the app's store. async function bookBytes(id) { let rec = typeof _getCachedBook === 'function' ? await _getCachedBook(id) : null; if (!rec && localBooks()) { const r = await fetch('/books/' + id + '/data/', {credentials: 'same-origin'}); if (r.ok) rec = await r.json(); } if (!rec || !rec.data_ct) return null; return decryptBytes(await key(), rec.data_iv, rec.data_ct); } async function epubCover(bytes) { const zip = await JSZip.loadAsync(bytes); const container = zip.file('META-INF/container.xml'); if (!container) return null; const cdoc = new DOMParser().parseFromString(await container.async('text'), 'application/xml'); const root = cdoc.querySelector('rootfile'); if (!root) return null; const opfPath = root.getAttribute('full-path'); const dir = opfPath.indexOf('/') >= 0 ? opfPath.slice(0, opfPath.lastIndexOf('/') + 1) : ''; const opf = new DOMParser().parseFromString(await zip.file(opfPath).async('text'), 'application/xml'); const items = Array.prototype.slice.call(opf.getElementsByTagName('item')); const meta = Array.prototype.slice.call(opf.getElementsByTagName('meta')).filter(m => m.getAttribute('name') === 'cover')[0]; const isImage = i => /^image\//.test(i.getAttribute('media-type') || ''); let item = items.filter(i => /\bcover-image\b/.test(i.getAttribute('properties') || ''))[0] || (meta ? items.filter(i => i.getAttribute('id') === meta.getAttribute('content') && isImage(i))[0] : null) || items.filter(i => isImage(i) && /cover/i.test((i.getAttribute('id') || '') + (i.getAttribute('href') || '')))[0]; if (!item) return null; const path = decodeURIComponent(resolveEpubPath(dir, item.getAttribute('href'))); const file = zip.file(path) || zip.file(path.replace(/^\//, '')); if (!file) return null; return new Blob([await file.async('arraybuffer')], {type: item.getAttribute('media-type')}); } async function pdfCover(bytes) { if (typeof pdfjsLib === 'undefined') return null; const pdf = await (typeof _pdfLoad === 'function' ? _pdfLoad(new Uint8Array(bytes)) : pdfjsLib.getDocument({data: new Uint8Array(bytes)})).promise; const canvas = document.createElement('canvas'); try { const page = await pdf.getPage(1); const v = page.getViewport({scale: 1}); const scale = Math.min(MAX_W / v.width, MAX_H / v.height); const viewport = page.getViewport({scale}); canvas.width = Math.round(viewport.width); canvas.height = Math.round(viewport.height); await page.render({canvasContext: canvas.getContext('2d'), viewport}).promise; } finally { try { pdf.destroy(); } catch (e) {} } return new Promise(resolve => canvas.toBlob(resolve, 'image/jpeg', QUALITY)); } // Any image to a small JPEG. async function shrink(blob) { const url = URL.createObjectURL(blob); try { const img = await new Promise((resolve, reject) => { const i = new Image(); i.onload = () => resolve(i); i.onerror = reject; i.src = url; }); const scale = Math.min(1, MAX_W / img.naturalWidth, MAX_H / img.naturalHeight); const canvas = document.createElement('canvas'); canvas.width = Math.max(1, Math.round(img.naturalWidth * scale)); canvas.height = Math.max(1, Math.round(img.naturalHeight * scale)); canvas.getContext('2d').drawImage(img, 0, 0, canvas.width, canvas.height); return await new Promise(resolve => canvas.toBlob(resolve, 'image/jpeg', QUALITY)); } finally { URL.revokeObjectURL(url); } } async function extract(book) { const bytes = await bookBytes(book.id); if (!bytes) return null; let blob = book.type === 'pdf' ? await pdfCover(bytes) : await epubCover(bytes); if (!blob) return null; if (book.type !== 'pdf') blob = await shrink(blob); if (!blob) return null; const enc = await encryptBytes(await key(), new Uint8Array(await blob.arrayBuffer())); fetch('/books/' + book.id + '/cover/', { method: 'POST', credentials: 'same-origin', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({cover_ct: enc.ciphertext, cover_iv: enc.iv}), }).then(() => { book.has_cover = true; }).catch(() => {}); return blob; } async function find(book) { const local = await readLocal(book.id); if (local) return local; let blob = null; if (book.has_cover) blob = await fromServer(book.id).catch(() => null); if (!blob && !book.has_cover && book.keyOk !== false) blob = await extract(book).catch(() => null); if (blob) writeLocal(book.id, blob); return blob; } // ---- the queue: one at a time, when idle ------------------------------------------------------- const idle = fn => (window.requestIdleCallback ? requestIdleCallback(fn, {timeout: 3000}) : setTimeout(fn, 400)); function pump() { if (busy || !queue.length) return; if (readerOpen()) { setTimeout(pump, 5000); return; } busy = true; idle(async () => { const book = queue.shift(); try { const blob = await find(book); if (blob) ready(book.id, blob); } catch (e) {} busy = false; setTimeout(pump, 150); }); } function url(book) { if (!book || off()) return null; if (urls.has(book.id)) return urls.get(book.id); if (!tried.has(book.id)) { tried.add(book.id); // Stored here already: at once, without waiting in the queue. readLocal(book.id).then(blob => { if (blob) ready(book.id, blob); else { queue.push(book); pump(); } }); } return null; } // A book read just now: its file is open anyway (the reader calls this). function forget(id) { tried.delete(id); } window.DioraCovers = {url, onReady(fn) { listeners.push(fn); }, forget}; })();