From bb1f3ff8e44098bfa9b046138ecf1d04a4cb4d99 Mon Sep 17 00:00:00 2001 From: Nino Date: Mon, 2 Nov 2026 22:00:44 -0600 Subject: [PATCH] =?UTF-8?q?Libros:=20Wikisource,=20EPUB=20propios=20y=20le?= =?UTF-8?q?ctura=20sin=20conexi=C3=B3n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wikisource en español como segunda fuente, con el EPUB que genera ws-export y portadas generadas con el título. Cada usuario puede subir sus EPUB, visibles solo para él en cualquier ruta. Los archivos manipulados (ZIP bombs, cabeceras falsas) se rechazan. Un libro se puede guardar entero en el dispositivo para leerlo sin conexión. Co-Authored-By: Claude Opus 5.5 --- README.md | 14 ++++ app/books.js | 194 +++++++++++++++++++++++++++++++++++++++++----- app/cbz.js | 2 + app/public/app.js | 76 +++++++++++++++--- app/public/sw.js | 2 +- app/server.js | 24 ++++-- 6 files changed, 274 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index 041e917..f0d6bbf 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,20 @@ Lector web de manga autoalojado, con las fuentes de favoritos y lo más leído. - Las imágenes pasan por el servidor (`/api/img`), solo con URLs firmadas por él (clave en `data/secret`). +## Libros de texto + +Además del manga, lee libros en EPUB con el mismo lector (hoja que gira, sombra, hendidura, sonido): + +- **Project Gutenberg**: dominio público, con búsqueda en su catálogo OPDS. +- **Wikisource en español**: novelas, con el EPUB que genera ws-export. +- **Mis libros**: los EPUB que sube cada usuario (hasta 100 MB), visibles solo para quien los sube. + +El servidor convierte el EPUB en una lista cerrada de bloques (`app/books.js`), sin pasar HTML del libro al +navegador, y guarda el resultado en `data/books/`. El navegador maqueta el libro entero como texto continuo según su +pantalla y dibuja cada página en un canvas. La letra, los márgenes y el papel se eligen en el lector, por dispositivo. +Un libro se puede guardar entero para leerlo sin conexión. En Ajustes → «Qué mostrar», cada usuario elige ver manga, +libros o ambos. + ## Uso ```bash diff --git a/app/books.js b/app/books.js index 4b0edee..b5176db 100644 --- a/app/books.js +++ b/app/books.js @@ -2,23 +2,32 @@ // HTML del libro, y es él quien maqueta los bloques en páginas según su pantalla (ver maquetado en app.js). // bloque: { k: 'p' | 'q' | 'h' | 'hr' | 'img', l?: nivel de título, n?: imagen, c?: centrado, v?: verso, // r?: [[texto, estilo]] } estilo: 1 cursiva, 2 negrita (sumables) -// Fuente en línea: Project Gutenberg, con su propio catálogo OPDS. IDs: libro "gb~", capítulo "gb~~". -// Título, autores e idioma salen del propio EPUB, así que abrir un libro solo necesita el archivo. +// Título, autores e idioma salen del propio EPUB, así que abrir un libro solo necesita el archivo. Orígenes: +// - Project Gutenberg, con su catálogo OPDS. IDs: libro "gb~", capítulo "gb~~". +// - Wikisource en español, con el EPUB que genera ws-export. IDs: "ws~", "ws~~". +// - Los EPUB que sube cada usuario, solo visibles para él. IDs: "ub~<32 hex>", "ub~<32 hex>~". // En /books/ se guardan el EPUB (de ahí salen las imágenes) y el libro ya convertido. import fs from 'node:fs' import zlib from 'node:zlib' +import crypto from 'node:crypto' import path from 'node:path' import { readZipIndex, readCbzEntry, imageType } from './cbz.js' export const isBookId = id => /^(gb|ws|ub)~/.test(id || '') const GUTENBERG = 'https://www.gutenberg.org' +const WS_API = 'https://es.wikisource.org/w/api.php' +const WS_EXPORT = 'https://ws-export.wmcloud.org/' +const WS_MIN_WORDS = 2000 // sin subpáginas, lo que no llega a esto es un poema o un texto suelto, no un libro const OPDS_PAGE = 25 +const GB_NAME = 'Project Gutenberg' +const WS_NAME = 'Wikisource' // El buscador de Gutenberg filtra por el nombre del idioma, no por el código. const LANG_NAMES = { es: 'spanish', en: 'english', fr: 'french', pt: 'portuguese', it: 'italian', de: 'german', ru: 'russian', ja: 'japanese', zh: 'chinese', ko: 'korean', ca: 'catalan', nl: 'dutch', pl: 'polish' } -const PARSER_VERSION = 2 +const PARSER_VERSION = 3 const MAX_EPUB_BYTES = 100 * 1024 * 1024 const MAX_UNZIPPED_ENTRY = 64 * 1024 * 1024 // un XHTML o una imagen -const MAX_UNZIPPED_TOTAL = 512 * 1024 * 1024 // todo el libro: lo que pase de aquí es una ZIP bomb +const MAX_UNZIPPED_TOTAL = 256 * 1024 * 1024 // todo lo que se lee del libro: lo que pase de aquí es una ZIP bomb +const MAX_UPLOADS_PER_USER = 500 const BOOK_IMAGE = /\.(jpe?g|png|gif|webp)$/i const ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', mdash: '—', ndash: '–', hellip: '…', laquo: '«', raquo: '»', ldquo: '“', rdquo: '”', lsquo: '‘', rsquo: '’', iexcl: '¡', iquest: '¿', aacute: 'á', eacute: 'é', iacute: 'í', oacute: 'ó', uacute: 'ú', ntilde: 'ñ', uuml: 'ü', Aacute: 'Á', Eacute: 'É', Iacute: 'Í', Oacute: 'Ó', Uacute: 'Ú', Ntilde: 'Ñ', Uuml: 'Ü', agrave: 'à', egrave: 'è', igrave: 'ì', ograve: 'ò', ugrave: 'ù', ccedil: 'ç', shy: '', ensp: ' ', emsp: ' ', thinsp: ' ', zwnj: '', zwj: '', copy: '©', deg: '°', ordm: 'º', ordf: 'ª', sect: '§', para: '¶', middot: '·', dagger: '†', Dagger: '‡', bull: '•', times: '×' } @@ -42,7 +51,7 @@ function resolveHref(base, href) { const SKIP = new Set(['head', 'script', 'style', 'template', 'noscript', 'iframe', 'object', 'embed', 'audio', 'video', 'math', 'select', 'form', 'nav']) const BLOCK = new Set(['p', 'div', 'section', 'article', 'aside', 'header', 'footer', 'figure', 'figcaption', 'main', 'body', 'ul', 'ol', 'li', 'dl', 'dt', 'dd', 'table', 'thead', 'tbody', 'tfoot', 'tr', 'caption', 'pre', 'address', 'center', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote']) const VOID = new Set(['br', 'hr', 'img', 'image', 'meta', 'link', 'input', 'col', 'area', 'base', 'wbr']) -const TOKEN = /||<[?!][^>]*>|<(\/?)([a-zA-Z][a-zA-Z0-9:_-]*)((?:[^>"']|"[^"]*"|'[^']*')*)>/g +const TOKEN = /||<[?!][^>]*>|<(\/?)([a-zA-Z][a-zA-Z0-9:_-]*)((?:[^<>"']|"[^"]*"|'[^']*')*)>/g // onAnchor(id) se llama con cada id del documento; devuelve true si ahí empieza un capítulo. function xhtmlToBlocks(xml, file, imageOf, onBlock, onAnchor) { @@ -216,11 +225,15 @@ export function parseEpub(file) { const index = readZipIndex(file) const entries = new Map(index.map(e => [e.name, e])) if (index.reduce((n, e) => n + e.unzipped, 0) > MAX_UNZIPPED_TOTAL) throw new Error('EPUB demasiado grande al descomprimir') + let unzipped = 0 const readBuf = name => { const e = entries.get(name) if (!e) throw new Error(`Falta ${name} en el EPUB`) if (e.unzipped > MAX_UNZIPPED_ENTRY) throw new Error('Archivo demasiado grande dentro del EPUB') - return readCbzEntry(file, e, MAX_UNZIPPED_ENTRY) + const buf = readCbzEntry(file, e, MAX_UNZIPPED_ENTRY) + unzipped += buf.length // lo real: la cabecera del ZIP puede mentir + if (unzipped > MAX_UNZIPPED_TOTAL) throw new Error('EPUB demasiado grande al descomprimir') + return buf } const read = name => readBuf(name).toString('utf8').replace(/^/, '') @@ -285,6 +298,10 @@ export function parseEpub(file) { if (carry) merged.push(carry) const out = merged if (!out.length) throw new Error('EPUB sin texto') + // Portada: EPUB 3 la marca en el manifiesto y EPUB 2 con un , que a veces no apunta a una imagen. + const coverId = /]*\bname\s*=\s*["']cover["'])[^>]*\bcontent\s*=\s*["']([^"']+)["']/i.exec(opf)?.[1] + const coverItem = [...manifest.values()].find(it => /\bcover-image\b/.test(it.props)) || manifest.get(coverId) + const cover = coverItem ? imageOf(coverItem.path) : null out.forEach((c, i) => { if (!c.title) c.title = `Sección ${i + 1}` }) // El navegador maqueta el libro entero sin cargar las imágenes: necesita saber su tamaño. for (const c of out) { @@ -298,35 +315,65 @@ export function parseEpub(file) { version: PARSER_VERSION, title: dc('title'), authors: [...opf.matchAll(/]*>([\s\S]*?)<\/dc:creator>/gi)].map(m => clean(m[1])).filter(Boolean), language: dc('language').slice(0, 2).toLowerCase(), description: dc('description'), year: year > 0 && year < 2100 ? year : null, - images, chapters: out, + images, cover, chapters: out, } } // ---------- Fuente y caché ---------- -export function setupBooks({ DATA_DIR, cached, HttpError, coverUrl, UA, net, safeFetch }) { +// Portada generada para lo que no trae una: el título sobre un color que sale del propio título. +function titleCover(title, author) { + const esc = t => String(t).replace(/[<>&"']/g, c => ({ '<': '<', '>': '>', '&': '&', '"': '"', "'": ''' }[c])) + const wrap = (t, width, max) => { + const lines = [] + let cur = '' + for (const w of t.split(/\s+/)) { + if (cur && cur.length + w.length + 1 > width) { lines.push(cur); cur = w } else cur = cur ? `${cur} ${w}` : w + } + if (cur) lines.push(cur) + if (lines.length > max) { lines.length = max; lines[max - 1] += '…' } + return lines.map(l => l.slice(0, width + 6)) + } + const hue = [...String(title)].reduce((n, c) => (n * 31 + c.codePointAt(0)) % 360, 7) + const lines = wrap(title, 15, 6) + const by = author ? wrap(author, 22, 2) : [] + const text = (t, y, size, weight, fill) => `${esc(t)}` + return `${lines.map((l, i) => text(l, 150 + i * 48, 38, 'bold', '#f6f1e6')).join('')}${by.map((l, i) => text(l, 500 + i * 30, 23, 'normal', `hsl(${hue} 45% 80%)`)).join('')}` +} + +export function setupBooks({ db, DATA_DIR, cached, HttpError, coverUrl, UA, net, safeFetch }) { const DIR = path.join(DATA_DIR, 'books') fs.mkdirSync(DIR, { recursive: true }) - const epubFile = id => path.join(DIR, `${id.replace('~', '-')}.epub`) - const jsonFile = id => path.join(DIR, `${id.replace('~', '-')}.json`) + db.exec(`CREATE TABLE IF NOT EXISTS user_books ( + id TEXT PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + title TEXT NOT NULL, + authors TEXT NOT NULL, + created_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_user_books_user ON user_books(user_id, created_at);`) + // Un título de Wikisource puede ser largo para nombre de archivo. + const fileKey = id => (id.startsWith('ws~') ? `ws-${crypto.createHash('sha256').update(id).digest('hex').slice(0, 32)}` : id.replace('~', '-')) + const epubFile = id => path.join(DIR, `${fileKey(id)}.epub`) + const jsonFile = id => path.join(DIR, `${fileKey(id)}.json`) // Hasta 3 intentos: un fallo de red o un 5xx de Gutenberg suele durar poco. El motivo real queda en el log. - async function gutenberg(url, timeout) { + async function fetchSource(url, timeout, name) { for (let i = 1; ; i++) { net.guard() let why try { const r = await safeFetch(url, { headers: { 'User-Agent': UA }, signal: AbortSignal.timeout(timeout) }) if (r.ok) return r - if (r.status === 404) throw new HttpError(404, 'Ese libro no está en Project Gutenberg') - if (r.status < 500 && r.status !== 429) throw new HttpError(502, `Project Gutenberg respondió ${r.status}`) + if (r.status === 404) throw new HttpError(404, `Ese libro no está en ${name}`) + if (r.status < 500 && r.status !== 429) throw new HttpError(502, `${name} respondió ${r.status}`) why = `HTTP ${r.status}` } catch (e) { if (e instanceof HttpError) throw e why = e.cause?.code || e.cause?.message || e.message net.failed() } - console.warn(`Gutenberg ${url}: intento ${i}: ${why}`) - if (i >= 3) throw new HttpError(502, 'Project Gutenberg no responde ahora mismo. Prueba en un rato.') + console.warn(`${name} ${url}: intento ${i}: ${why}`) + if (i >= 3) throw new HttpError(502, `${name} no responde ahora mismo. Prueba en un rato.`) await new Promise(r => setTimeout(r, 800 * i)) } } @@ -353,7 +400,93 @@ export function setupBooks({ DATA_DIR, cached, HttpError, coverUrl, UA, net, saf const qs = new URLSearchParams({ query: [text, name && `l.${name}`].filter(Boolean).join(' '), sort_order: mode === 'latest' && !text ? 'release_date' : 'downloads' }) const start = (Math.max(1, page | 0) - 1) * OPDS_PAGE + 1 if (start > 1) qs.set('start_index', String(start)) - return cached(`gb:${qs}`, 30 * 60e3, async () => parseFeed(await (await gutenberg(`${GUTENBERG}/ebooks/search.opds/?${qs}`, 15e3)).text())) + return cached(`gb:${qs}`, 30 * 60e3, async () => parseFeed(await (await fetchSource(`${GUTENBERG}/ebooks/search.opds/?${qs}`, 15e3, GB_NAME)).text())) + } + + // ---------- Wikisource ---------- + const wsId = title => `ws~${Buffer.from(title).toString('base64url')}` + const wsTitle = id => Buffer.from(id.slice(3), 'base64url').toString('utf8') + const wsCard = root => { + const id = wsId(root) + // "Realidad (Pérez Galdós)": la obra y, entre paréntesis, de quién es o de qué año. + const m = /^(.+?)\s*\(([^()]+)\)$/.exec(root) + return { id, source: 'ws', title: m ? m[1] : root, cover: `/api/book/${id}/cover`, sub: m ? m[2] : undefined } + } + const wsQuery = async params => { + const qs = new URLSearchParams({ action: 'query', format: 'json', ...params }) + return (await fetchSource(`${WS_API}?${qs}`, 20e3, WS_NAME)).json() + } + async function wsList(q, mode) { + const text = q.trim() + if (text) { + const d = await wsQuery({ list: 'search', srsearch: text, srnamespace: '0', srlimit: '200', srprop: 'wordcount' }) + // Una novela es una página índice corta con muchas subpáginas ("Obra/12"): cuenta la obra, una vez. + const works = new Map() + for (const r of d.query?.search || []) { + const root = r.title.split('/')[0] + const w = works.get(root) || { root, chapters: 0, words: 0 } + if (r.title.includes('/')) w.chapters++ + else w.words = r.wordcount + works.set(root, w) + } + return [...works.values()].filter(w => w.chapters || w.words >= WS_MIN_WORDS).map(w => w.root) + } + const d = await wsQuery({ list: 'categorymembers', cmtitle: 'Categoría:Novelas', cmnamespace: '0', cmlimit: '500', ...(mode === 'latest' ? { cmsort: 'timestamp', cmdir: 'desc' } : {}) }) + return (d.query?.categorymembers || []).map(m => m.title) + } + async function wsSearch(q, mode, page) { + const list = await cached(`ws:${mode === 'latest' ? 'latest' : 'popular'}:${q.trim().toLowerCase()}`, 30 * 60e3, () => wsList(q, mode)) + const start = (Math.max(1, page | 0) - 1) * OPDS_PAGE + return list.filter(t => Buffer.byteLength(t) <= 280).slice(start, start + OPDS_PAGE).map(wsCard) + } + + // ---------- Libros subidos ---------- + const owns = (userId, id) => !!db.prepare('SELECT 1 FROM user_books WHERE id = ? AND user_id = ?').get(id, userId) + const ubCard = r => ({ id: r.id, source: 'ub', title: r.title, cover: `/api/book/${r.id}/cover`, sub: JSON.parse(r.authors).join(', ') || undefined }) + function ubList(userId, q) { + const fold = t => String(t).toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '') + const words = fold(q).split(/\s+/).filter(Boolean) + return db.prepare('SELECT * FROM user_books WHERE user_id = ? ORDER BY created_at DESC').all(userId) + .filter(r => words.every(w => fold(`${r.title} ${r.authors}`).includes(w))).map(ubCard) + } + async function upload(req, user) { + if (db.prepare('SELECT COUNT(*) n FROM user_books WHERE user_id = ?').get(user.id).n >= MAX_UPLOADS_PER_USER) throw new HttpError(400, `Se admiten hasta ${MAX_UPLOADS_PER_USER} libros por usuario`) + let size = 0 + const chunks = [] + for await (const c of req) { + size += c.length + if (size > MAX_EPUB_BYTES) throw new HttpError(413, 'El libro es demasiado grande (máximo 100 MB)') + chunks.push(c) + } + if (!size) throw new HttpError(400, 'No se recibió ningún archivo') + const data = Buffer.concat(chunks) + // El mismo archivo subido dos veces por la misma persona es el mismo libro. + const id = `ub~${crypto.createHash('sha256').update(`${user.id}:`).update(data).digest('hex').slice(0, 32)}` + const tmp = path.join(DIR, `up-${crypto.randomBytes(8).toString('hex')}.part`) + fs.writeFileSync(tmp, data) + let book + try { book = parseEpub(tmp) } catch (e) { + fs.rmSync(tmp, { force: true }) + throw new HttpError(400, `No se pudo leer ese archivo como EPUB: ${String(e.message).slice(0, 120)}`) + } + fs.renameSync(tmp, epubFile(id)) + fs.writeFileSync(jsonFile(id), JSON.stringify(book)) + remember(id, book) + db.prepare(`INSERT INTO user_books (id, user_id, title, authors, created_at) VALUES (?, ?, ?, ?, ?) + ON CONFLICT (id) DO UPDATE SET created_at = excluded.created_at`) + .run(id, user.id, (book.title || 'Sin título').slice(0, 300), JSON.stringify(book.authors.map(authorName).slice(0, 10)), Date.now()) + return ubCard(db.prepare('SELECT * FROM user_books WHERE id = ?').get(id)) + } + function removeUpload(id, userId) { + db.prepare('DELETE FROM user_books WHERE id = ? AND user_id = ?').run(id, userId) + db.prepare('DELETE FROM progress WHERE user_id = ? AND manga_id = ?').run(userId, id) + db.prepare('DELETE FROM chapter_reads WHERE user_id = ? AND manga_id = ?').run(userId, id) + db.prepare('DELETE FROM manga_prefs WHERE user_id = ? AND manga_id = ?').run(userId, id) + db.prepare('DELETE FROM list_items WHERE manga_id = ? AND list_id IN (SELECT id FROM lists WHERE user_id = ?)').run(id, userId) + memory.delete(id) + packed.delete(id) + fs.rmSync(epubFile(id), { force: true }) + fs.rmSync(jsonFile(id), { force: true }) } // Una sola descarga y conversión a la vez por libro. @@ -366,7 +499,10 @@ export function setupBooks({ DATA_DIR, cached, HttpError, coverUrl, UA, net, saf return book } async function downloadEpub(id) { - const r = await gutenberg(`${GUTENBERG}/ebooks/${id.slice(3)}.epub3.images`, 120e3) + if (id.startsWith('ub~')) throw new HttpError(404, 'Ese libro ya no está') + const r = id.startsWith('ws~') + ? await fetchSource(`${WS_EXPORT}?${new URLSearchParams({ lang: 'es', page: wsTitle(id), format: 'epub-3' })}`, 120e3, WS_NAME) + : await fetchSource(`${GUTENBERG}/ebooks/${id.slice(3)}.epub3.images`, 120e3, GB_NAME) if (Number(r.headers.get('content-length')) > MAX_EPUB_BYTES) throw new HttpError(502, 'El libro es demasiado grande') const buf = Buffer.from(await r.arrayBuffer()) if (buf.length > MAX_EPUB_BYTES) throw new HttpError(502, 'El libro es demasiado grande') @@ -401,16 +537,17 @@ export function setupBooks({ DATA_DIR, cached, HttpError, coverUrl, UA, net, saf async function manga(id) { const book = await loadBook(id) + const source = id.slice(0, 2) return { manga: { - id, source: 'gb', type: 'Libro', - title: book.title || `Libro ${id.slice(3)}`, + id, source, type: 'Libro', + title: book.title || (source === 'ws' ? wsCard(wsTitle(id)).title : `Libro ${id.slice(3)}`), description: book.description || '', status: null, year: null, // la fecha del EPUB es la de la edición digital, no la de la obra tags: [], authors: book.authors.map(authorName), languages: book.language ? [book.language] : [], - cover: coverOf(id.slice(3), id), + cover: source === 'gb' ? coverOf(id.slice(3), id) : `/api/book/${id}/cover`, }, chapters: book.chapters.map((c, i) => ({ id: `${id}~${i}`, num: String(i + 1), volume: null, title: c.title, pages: null, group: '', publishAt: null })), } @@ -447,5 +584,20 @@ export function setupBooks({ DATA_DIR, cached, HttpError, coverUrl, UA, net, saf res.writeHead(200, { 'Content-Type': imageType(name), 'Cache-Control': 'private, max-age=604800', 'X-Content-Type-Options': 'nosniff' }) res.end(data) } - return { search, manga, chapter, sendText, sendImage } + async function sendCover(res, id) { + if (id.startsWith('ub~')) { + const book = await loadBook(id) + if (book.cover != null) return sendImage(res, id, book.cover) + const r = db.prepare('SELECT title, authors FROM user_books WHERE id = ?').get(id) + return sendSvg(res, titleCover(r?.title || book.title || 'Sin título', book.authors.map(authorName)[0])) + } + if (!id.startsWith('ws~')) throw new HttpError(404, 'Sin portada') + const card = wsCard(wsTitle(id)) // en un listado: sin descargar el libro + sendSvg(res, titleCover(card.title, card.sub)) + } + const sendSvg = (res, svg) => { + res.writeHead(200, { 'Content-Type': 'image/svg+xml; charset=utf-8', 'Cache-Control': 'private, max-age=604800', 'X-Content-Type-Options': 'nosniff' }) + res.end(svg) + } + return { search, wsSearch, ubList, upload, removeUpload, owns, manga, chapter, sendText, sendImage, sendCover } } diff --git a/app/cbz.js b/app/cbz.js index a03c701..382ae5a 100644 --- a/app/cbz.js +++ b/app/cbz.js @@ -75,6 +75,7 @@ export function readZipIndex(file) { const count = tail.readUInt16LE(eocd + 10) const cdSize = tail.readUInt32LE(eocd + 12) const cdOffset = tail.readUInt32LE(eocd + 16) + if (cdSize > size) throw new Error('ZIP inválido') const cd = Buffer.alloc(cdSize) fs.readSync(fd, cd, 0, cdSize, cdOffset) const entries = [] @@ -87,6 +88,7 @@ export function readZipIndex(file) { const extraLen = cd.readUInt16LE(p + 30) const commentLen = cd.readUInt16LE(p + 32) const localOffset = cd.readUInt32LE(p + 42) + if (compSize > size) throw new Error('ZIP inválido') const name = cd.toString('utf8', p + 46, p + 46 + nameLen) p += 46 + nameLen + extraLen + commentLen fs.readSync(fd, lh, 0, 30, localOffset) diff --git a/app/public/app.js b/app/public/app.js index eecb9b6..17496bb 100644 --- a/app/public/app.js +++ b/app/public/app.js @@ -50,9 +50,9 @@ const icon = name => `${shownSources.map(([k, v]) => ``).join('')} Buscar - ${q && !tag ? `

Resultados para “${esc(q)}”

` : `
+ ${source === 'ub' ? `
Solo tú puedes verlos. Máximo 100 MB por libro.
` : ''} + ${source === 'ub' ? (q ? `

Resultados para “${esc(q)}”

` : '') : q && !tag ? `

Resultados para “${esc(q)}”

` : `
${NO_CATS.has(source) ? '' : ``}
`} @@ -564,6 +574,23 @@ async function viewHome(params) { if (contentMode() !== 'books') loadRecs() // las recomendaciones son de manga } + $('#ubfile')?.addEventListener('change', async e => { + const files = [...e.target.files] + const label = $('#ubpick') + label.classList.add('disabled') + let added = 0 + for (const [i, f] of files.entries()) { + toast(`Subiendo ${files.length > 1 ? `${i + 1} de ${files.length}: ` : ''}${f.name}…`) + try { + const r = await fetch('/api/books', { method: 'POST', headers: { 'Content-Type': 'application/epub+zip' }, body: f }) + const d = await r.json().catch(() => ({})) + if (!r.ok) throw new Error(d.error || `Error ${r.status}`) + added++ + } catch (err) { toast(`${f.name}: ${err.message}`) } + } + label.classList.remove('disabled') + if (added) { toast(added === 1 ? 'Libro añadido' : `${added} libros añadidos`); router() } + }) const res = $('#results') if (showCats) { const cats = await api(`/api/categories?source=${source}`) @@ -574,7 +601,7 @@ async function viewHome(params) { } // Paginación: MangaDex siempre; LeerCapitulo solo en categorías; M440 en "Actualizados" (sus otros listados no paginan). - const pageable = source === 'md' || BOOK_SOURCES.has(source) || !!tag || (source === 'mi' && mode === 'latest' && !q) + const pageable = source === 'md' || source === 'gb' || source === 'ws' || !!tag || (source === 'mi' && mode === 'latest' && !q) const seen = new Set() let page = 1 const load = async () => { @@ -582,7 +609,7 @@ async function viewHome(params) { const list = (await api(`/api/search?${qs}`)).filter(m => !seen.has(m.id) && seen.add(m.id)) if (page === 1) { res.className = list.length ? 'grid' : 'loading' - res.innerHTML = list.length ? '' : 'Sin resultados.' + res.innerHTML = list.length ? '' : source === 'ub' && !q ? 'Aún no has subido ningún libro. Pulsa «Subir EPUB» para añadir el primero.' : 'Sin resultados.' } res.insertAdjacentHTML('beforeend', list.map(mangaCard).join('')) $('#more').hidden = !pageable || list.length < 20 @@ -676,6 +703,7 @@ async function viewManga(id, langOverride) { const menuItems = [ manga.source === 'lc' || isBook ? '' : `${icon('search')} Buscar en LeerCapitulo`, manga.source === 'mi' || isBook ? '' : `${icon('search')} Buscar en M440`, + manga.source === 'ub' ? `` : '', progress || reads.length ? `` : '', ].join('') @@ -708,7 +736,7 @@ async function viewManga(id, langOverride) { ${clipCount ? `${icon('scissors')} ${clipCount} recorte${clipCount > 1 ? 's' : ''}` : ''} ${menuItems}
-
+
${icon('book')} Datos de la obra
@@ -1048,6 +1076,20 @@ async function viewManga(id, langOverride) { const queued = offState.queue.filter(q => q.mangaId === id).length markLib('off', !!(run || queued)) box.hidden = false + if (isBook) { + // Un libro se guarda entero: el texto de todos sus capítulos y sus imágenes. + const status = mine.length ? `${icon('check')} Guardado · ${fmtSize(bytes)}` : '' + const note = run ? `Guardando… ${run.done}/${run.total || '?'}` : queued ? 'En cola…' : mine.length ? 'Se puede leer sin internet.' : 'Guarda el libro en este móvil o PC para leerlo sin internet.' + box.innerHTML = `${secHead('phone', 'En este dispositivo', status)}
${note}
+ ${mine.length ? `` : run || queued ? '' : ``}
` + $('#offbook')?.addEventListener('click', () => offEnqueue([{ mangaId: id, mangaTitle: manga.title, cover: manga.cover, lang: manga.languages?.[0] || 'es', chapterId: chapters[0].id, label: 'Libro completo', num: null }])) + $('#offdel')?.addEventListener('click', async () => { + if (!confirm(`¿Borrar «${manga.title}» de este dispositivo (${fmtSize(bytes)})?`)) return + await offRemove(mine.map(([cid]) => cid)) + toast('Borrado del dispositivo') + }) + return + } // Siguientes 3 sin guardar a partir de donde vas (o desde el principio). const from = Math.max(0, chapters.findIndex(c => c.id === progress?.chapter_id)) const nextIds = chapters.slice(from).filter(c => !offHas(c.id)).slice(0, 3) @@ -1136,6 +1178,15 @@ async function viewManga(id, langOverride) { renderFollow() toast(isFollowing ? 'Te avisaremos de los capítulos nuevos' : 'Ya no sigues este manga') }) + $('#ubdel')?.addEventListener('click', async () => { + if (!confirm(`¿Eliminar «${manga.title}»?\nSe borrará el archivo del servidor y tu progreso de lectura.`)) return + try { + await api(`/api/books/${id}`, { method: 'DELETE' }) + if (OFFLINE_OK) await offRemove(Object.entries(offIdx()).filter(([, c]) => c.mangaId === id).map(([cid]) => cid)) + toast('Libro eliminado') + location.hash = '#/?source=ub' + } catch (err) { toast(err.message) } + }) $('#drop')?.addEventListener('click', async () => { if (!confirm(`¿Dejar de leer «${manga.title}»?\nSe quitará del historial, se borrará el progreso de sus capítulos y dejarás de seguirlo.`)) return await api(`/api/history/${id}`, { method: 'DELETE' }) @@ -1762,7 +1813,12 @@ async function viewReader(mangaId, chapterId, startPage) { const globalSettings = { saver: false, filter: 'none', brightness: 100, anim: reduceMotion ? 'fade' : 'flip', sfx: 'mid', ...store.get('readerSettings', {}) } const [info, chap] = await Promise.all([ api(`/api/manga/${mangaId}?lang=${encodeURIComponent(lang)}`), - api(`/api/chapter/${chapterId}?saver=${globalSettings.saver ? 1 : 0}&hd=${globalSettings.hd === false ? 0 : 1}`), + api(`/api/chapter/${chapterId}?saver=${globalSettings.saver ? 1 : 0}&hd=${globalSettings.hd === false ? 0 : 1}`).catch(err => { + // Un libro guardado se guarda entero: sin conexión, cualquier capítulo abre con el texto del dispositivo. + if (!isBookId(mangaId) || !OFFLINE_OK || !Object.values(offIdx()).some(c => c.mangaId === mangaId)) throw err + const pos = store.get(`bookPos:${mangaId}`) + return { text: true, pages: [], savedPage: pos?.chapterId === chapterId ? pos.page : 0, savedTotal: pos?.chapterId === chapterId ? pos.totalPages : 0 } + }), ]) const { manga, chapters } = info const settings = { ...globalSettings, ...defaultPrefs(manga), ...(info.prefs || {}) } @@ -1822,7 +1878,7 @@ async function viewReader(mangaId, chapterId, startPage) { chapterTitle: chapter.title, lang: manga.source !== 'md' ? 'es' : chapter.lang || lang, page: lastShown(), totalPages: total, isLast: idx >= 0 && !next, // al terminarlo, el manga sale de "Continuar leyendo" }) - const saveNow = () => { clearTimeout(saveTimer); saveTimer = null; const p = payload(); api('/api/progress', { method: 'POST', body: p }).catch(() => queueProgress(p)) } + const saveNow = () => { clearTimeout(saveTimer); saveTimer = null; const p = payload(); if (isBook) store.set(`bookPos:${mangaId}`, { chapterId: p.chapterId, page: p.page, totalPages: p.totalPages }); api('/api/progress', { method: 'POST', body: p }).catch(() => queueProgress(p)) } const scheduleSave = () => { clearTimeout(saveTimer); saveTimer = setTimeout(saveNow, 700) } const beacon = () => { if (saveTimer) { clearTimeout(saveTimer); saveTimer = null; navigator.onLine ? navigator.sendBeacon('/api/progress', JSON.stringify(payload())) : queueProgress(payload()) } } const onVis = () => document.visibilityState === 'hidden' && beacon() diff --git a/app/public/sw.js b/app/public/sw.js index cf9a4c3..4073a16 100644 --- a/app/public/sw.js +++ b/app/public/sw.js @@ -74,7 +74,7 @@ self.addEventListener('fetch', e => { if (p.startsWith('/api/manga/')) return e.respondWith(networkFirst(req, OFFLINE, { store: 'existing', ignoreSearch: true })) if (p.startsWith('/api/chapter/')) return e.respondWith(networkFirst(req, OFFLINE, { store: false, ignoreSearch: true })) // Imágenes: si están guardadas, sin red (más rápido y sin gastar datos). - if (p.startsWith('/api/local/') || p.startsWith('/api/img') || p.startsWith('/api/local-cover/')) { + if (p.startsWith('/api/local/') || p.startsWith('/api/img') || p.startsWith('/api/local-cover/') || p.startsWith('/api/book/')) { e.respondWith(caches.open(OFFLINE).then(c => c.match(req)).then(hit => hit || fetch(req))) } }) diff --git a/app/server.js b/app/server.js index 494710e..1c6d7a6 100644 --- a/app/server.js +++ b/app/server.js @@ -459,7 +459,7 @@ const lcPages = async chapterId => (await lcPageUrls(chapterId)).map(imgUrl) const mi = setupM440({ cached, httpError: (status, message) => new HttpError(status, message), coverUrl, decode, BROWSER_UA, net }) const miPages = async chapterId => (await mi.pageUrls(chapterId)).map(imgUrl) // Fuente según el ID: 'lc' y 'mi' son sitios en español; lo demás es MangaDex. -const sourceOf = id => (id.startsWith('lc~') ? 'lc' : id.startsWith('mi~') ? 'mi' : id.startsWith('gb~') ? 'gb' : 'md') +const sourceOf = id => (id.startsWith('lc~') ? 'lc' : id.startsWith('mi~') ? 'mi' : /^(gb|ws|ub)~/.test(id) ? id.slice(0, 2) : 'md') const spanishOnly = id => sourceOf(id) !== 'md' // Proxy de imágenes: MangaDex bloquea el hotlinking de portadas y así el navegador no depende de CDNs ajenos. @@ -542,7 +542,7 @@ async function fetchMangaFull(mangaId, lang) { const src = sourceOf(mangaId) return src === 'lc' ? lcManga(mangaId) : src === 'mi' ? mi.manga(mangaId) - : src === 'gb' ? books.manga(mangaId) + : isBookId(mangaId) ? books.manga(mangaId) : { manga: await getManga(mangaId, lang), chapters: await getChapters(mangaId, lang) } } @@ -781,8 +781,8 @@ async function readBody(req) { } try { return JSON.parse(Buffer.concat(chunks).toString() || '{}') } catch { throw new HttpError(400, 'JSON inválido') } } -const MANGA_ID = /^([0-9a-f-]{36}|lc~[a-z0-9]+~[a-z0-9-]+|mi~[a-z0-9._-]+|gb~\d{1,7})$/ -const CHAPTER_ID = /^([0-9a-f-]{36}|lc~[a-z0-9]+~[a-z0-9-]+~[0-9a-z.-]+|mi~[a-z0-9._-]+~[a-z0-9._-]+|gb~\d{1,7}~\d{1,5})$/ +const MANGA_ID = /^([0-9a-f-]{36}|lc~[a-z0-9]+~[a-z0-9-]+|mi~[a-z0-9._-]+|gb~\d{1,7}|ws~[A-Za-z0-9_-]{1,400}|ub~[0-9a-f]{32})$/ +const CHAPTER_ID = /^([0-9a-f-]{36}|lc~[a-z0-9]+~[a-z0-9-]+~[0-9a-z.-]+|mi~[a-z0-9._-]+~[a-z0-9._-]+|gb~\d{1,7}~\d{1,5}|ws~[A-Za-z0-9_-]{1,400}~\d{1,5}|ub~[0-9a-f]{32}~\d{1,5})$/ const LANG = /^[a-z]{2}(-[a-z]{2})?$/ // Manga en el historial o en seguidos del usuario (sus portadas se reescalan). const tracked = (userId, mangaId) => !!db.prepare(`SELECT 1 FROM progress WHERE user_id = ? AND manga_id = ? @@ -873,6 +873,10 @@ async function route(req, res) { // Todo lo demás requiere sesión. const user = requireUser(req) + // Un libro subido solo existe para quien lo subió, en cualquier ruta y parámetro que lo nombre. + let rawUrl = req.url + try { rawUrl = decodeURIComponent(req.url) } catch {} + for (const [ub] of rawUrl.matchAll(/ub~[0-9a-f]{32}/g)) if (!books.owns(user.id, ub)) throw new HttpError(404, 'No encontrado') if (p.startsWith('/api/bans') && security.route(p, method, req, res, json) !== false) return if ((await features.route(p, method, q, req, res, user)) !== false) return if ((await upscale.route(p, method, req, res)) !== false) { @@ -927,12 +931,14 @@ async function route(req, res) { const lang = LANG.test(q.get('lang') || '') ? q.get('lang') : user.lang if (p === '/api/categories' && method === 'GET') { - return json(res, 200, !net.online() ? [] : q.get('source') === 'lc' ? await lcCategories() : q.get('source') === 'mi' || q.get('source') === 'gb' ? [] : await mdCategories()) + return json(res, 200, !net.online() ? [] : q.get('source') === 'lc' ? await lcCategories() : ['mi', 'gb', 'ws', 'ub'].includes(q.get('source')) ? [] : await mdCategories()) } if (p === '/api/search' && method === 'GET') { const page = Math.min(Math.max(Number(q.get('page')) | 0, 1), 500) const tag = q.get('tag') || '' + if (q.get('source') === 'ub') return json(res, 200, books.ubList(user.id, (q.get('q') || '').slice(0, 100))) // no necesita internet if (!net.online()) return json(res, 200, localSearch(q.get('q') || '')) + if (q.get('source') === 'ws') return json(res, 200, await books.wsSearch((q.get('q') || '').slice(0, 100), q.get('mode'), page)) if (q.get('source') === 'gb') return json(res, 200, await books.search((q.get('q') || '').slice(0, 100), q.get('mode'), page, user.lang)) if (q.get('source') === 'mi') return json(res, 200, await mi.search((q.get('q') || '').slice(0, 100), q.get('mode'), page)) if (q.get('source') === 'lc') return json(res, 200, await lcSearch((q.get('q') || '').slice(0, 100), q.get('mode'), tag, page)) @@ -962,6 +968,12 @@ async function route(req, res) { const chapterLangs = chapters.length || manga.source !== 'md' || offline ? [] : await getChapterLangs(m[1]).catch(() => []) return json(res, 200, { manga, chapters, lang, reads, progress, chapterLangs, offline, downloaded, download: downloadInfo(m[1], true), upscale: upscale.info(m[1]), clips: clips.count(user.id, m[1]), lists: library.listsWith(user.id, m[1]), info: isBookId(m[1]) ? null : meta.cachedInfo(m[1]), clipBanner: clips.bannerFor(user.id, m[1]), ...features.mangaExtras(user.id, manga) }) } + if (p === '/api/books' && method === 'POST') return json(res, 200, await books.upload(req, user)) + if ((m = /^\/api\/books\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && m[1].startsWith('ub~') && method === 'DELETE') { + books.removeUpload(m[1], user.id) + return json(res, 200, { ok: true }) + } + if ((m = /^\/api\/book\/([^/]+)\/cover$/.exec(p)) && MANGA_ID.test(m[1]) && isBookId(m[1]) && method === 'GET') return books.sendCover(res, m[1]) if ((m = /^\/api\/book\/([^/]+)\/text$/.exec(p)) && MANGA_ID.test(m[1]) && isBookId(m[1]) && method === 'GET') return books.sendText(req, res, m[1]) if ((m = /^\/api\/book\/([^/]+)\/img\/(\d{1,5})$/.exec(p)) && MANGA_ID.test(m[1]) && isBookId(m[1]) && method === 'GET') return books.sendImage(res, m[1], Number(m[2])) if ((m = /^\/api\/chapter\/([^/]+)$/.exec(p)) && CHAPTER_ID.test(m[1]) && method === 'GET') { @@ -1096,7 +1108,7 @@ async function route(req, res) { } const security = setupSecurity({ db }) -const books = setupBooks({ DATA_DIR, cached, HttpError, coverUrl, UA, net, safeFetch }) +const books = setupBooks({ db, DATA_DIR, cached, HttpError, coverUrl, UA, net, safeFetch }) const features = setupFeatures({ db, fetchMangaFull, enqueueDownload, downloadInfo, HttpError, json, readBody, MANGA_ID, net }) const upscale = setupUpscale({ db, MANGA_DIR, HttpError, json, readBody, MANGA_ID }) const covers = setupCovers({ DATA_DIR, upscale })