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:
+173
-21
@@ -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~<n>", capítulo "gb~<n>~<i>".
|
||||
// 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~<n>", capítulo "gb~<n>~<i>".
|
||||
// - Wikisource en español, con el EPUB que genera ws-export. IDs: "ws~<título en base64url>", "ws~<título>~<i>".
|
||||
// - Los EPUB que sube cada usuario, solo visibles para él. IDs: "ub~<32 hex>", "ub~<32 hex>~<i>".
|
||||
// En <DATA_DIR>/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 = /<!--[\s\S]*?-->|<!\[CDATA\[[\s\S]*?\]\]>|<[?!][^>]*>|<(\/?)([a-zA-Z][a-zA-Z0-9:_-]*)((?:[^>"']|"[^"]*"|'[^']*')*)>/g
|
||||
const TOKEN = /<!--[\s\S]*?-->|<!\[CDATA\[[\s\S]*?\]\]>|<[?!][^>]*>|<(\/?)([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 <meta>, que a veces no apunta a una imagen.
|
||||
const coverId = /<meta\b(?=[^>]*\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(/<dc:creator\b[^>]*>([\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) => `<text x="200" y="${y}" font-family="Georgia,'Times New Roman',serif" font-size="${size}" font-weight="${weight}" text-anchor="middle" fill="${fill}">${esc(t)}</text>`
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 600" width="400" height="600"><rect width="400" height="600" fill="hsl(${hue} 38% 24%)"/><rect x="18" y="18" width="364" height="564" fill="none" stroke="hsl(${hue} 45% 72%)" stroke-width="2"/>${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('')}</svg>`
|
||||
}
|
||||
|
||||
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 }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user