Libros: Wikisource, EPUB propios y lectura sin conexión

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 <noreply@anthropic.com>
This commit is contained in:
2026-11-02 22:00:44 -06:00
co-authored by Claude Opus 5.5
parent be19e35156
commit bb1f3ff8e4
6 changed files with 274 additions and 38 deletions
+18 -6
View File
@@ -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 })