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:
+18
-6
@@ -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 })
|
||||
|
||||
Reference in New Issue
Block a user