224 lines
8.9 KiB
JavaScript
224 lines
8.9 KiB
JavaScript
|
|
/*
|
||
|
|
* 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 pdfjsLib.getDocument({data: new Uint8Array(bytes)}).promise;
|
||
|
|
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});
|
||
|
|
const canvas = document.createElement('canvas');
|
||
|
|
canvas.width = Math.round(viewport.width);
|
||
|
|
canvas.height = Math.round(viewport.height);
|
||
|
|
await page.render({canvasContext: canvas.getContext('2d'), viewport}).promise;
|
||
|
|
pdf.destroy();
|
||
|
|
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};
|
||
|
|
})();
|