Files
manga-reader/app/server.js
T
ninoandClaude Opus 5.5 bb1f3ff8e4 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>
2026-11-02 22:00:44 -06:00

1161 lines
64 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Manga Reader: lector web con fuentes MangaDex, LeerCapitulo y M440
// con usuarios y historial de lectura persistente en SQLite.
import http from 'node:http'
import fs from 'node:fs'
import path from 'node:path'
import crypto from 'node:crypto'
import { DatabaseSync } from 'node:sqlite'
import { writeCbz, readCbzIndex, sendCbzEntry, imageType } from './cbz.js'
import { setupFeatures } from './features.js'
import { setupUpscale } from './upscale.js'
import { setupClips } from './clips.js'
import { setupCovers } from './covers.js'
import { setupLibrary } from './library.js'
import { setupSecurity, safeFetch } from './security.js'
import { setupM440, MI_BASE } from './m440.js'
import { setupConnectivity } from './connectivity.js'
import { setupMeta } from './meta.js'
import { setupBooks, isBookId } from './books.js'
const PORT = Number(process.env.PORT || 3352)
const DATA_DIR = process.env.DATA_DIR || '/app/data'
const MANGA_DIR = process.env.MANGA_DIR || '/mangas'
const ALLOW_SIGNUP = process.env.ALLOW_SIGNUP === 'true' // cerrado salvo que se pida (el primer usuario siempre puede)
const PUBLIC_DIR = path.resolve(import.meta.dirname, 'public')
const MD_API = 'https://api.mangadex.org'
const LC_BASE = 'https://www.leercapitulo.co'
const BROWSER_UA = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0 Safari/537.36'
const UA = 'manga-reader-selfhosted/1.0'
const SESSION_DAYS = 120
// ---------- Base de datos ----------
// Comprobación de permisos al arrancar: con un UID distinto en el host, el error de SQLite sería críptico.
function checkWritable(dir, fatal) {
try {
fs.mkdirSync(dir, { recursive: true })
const probe = path.join(dir, `.write-test-${process.pid}`)
fs.writeFileSync(probe, '')
fs.rmSync(probe)
return true
} catch (e) {
const uid = process.getuid?.(), gid = process.getgid?.()
console.error(`${fatal ? 'ERROR' : 'AVISO'}: no se puede escribir en ${dir} (${e.code || e.message}) como uid ${uid}:${gid}.
Solución: en el host, "sudo chown -R ${uid}:${gid} <carpeta>" o pon PUID/PGID en .env con el dueño de la carpeta (id -u / id -g).`)
if (fatal) process.exit(1)
return false
}
}
checkWritable(DATA_DIR, true)
checkWritable(MANGA_DIR, false) // sin esto solo fallan las descargas
const db = new DatabaseSync(path.join(DATA_DIR, 'manga.db'))
const SECRET_FILE = path.join(DATA_DIR, 'secret')
if (!fs.existsSync(SECRET_FILE)) fs.writeFileSync(SECRET_FILE, crypto.randomBytes(32).toString('hex'), { mode: 0o600 })
const SECRET = fs.readFileSync(SECRET_FILE, 'utf8').trim()
db.exec(`
PRAGMA journal_mode = WAL;
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL COLLATE NOCASE,
pass_hash TEXT NOT NULL,
lang TEXT NOT NULL DEFAULT 'es-la',
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS progress (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
manga_id TEXT NOT NULL,
manga_title TEXT NOT NULL,
cover TEXT,
chapter_id TEXT NOT NULL,
chapter_num TEXT,
chapter_title TEXT,
lang TEXT,
page INTEGER NOT NULL DEFAULT 0,
total_pages INTEGER NOT NULL DEFAULT 0,
updated_at INTEGER NOT NULL,
PRIMARY KEY (user_id, manga_id)
);
CREATE TABLE IF NOT EXISTS chapter_reads (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
chapter_id TEXT NOT NULL,
manga_id TEXT NOT NULL,
page INTEGER NOT NULL DEFAULT 0,
total_pages INTEGER NOT NULL DEFAULT 0,
updated_at INTEGER NOT NULL,
PRIMARY KEY (user_id, chapter_id)
);
CREATE INDEX IF NOT EXISTS idx_reads_manga ON chapter_reads(user_id, manga_id);
CREATE TABLE IF NOT EXISTS downloads (
manga_id TEXT PRIMARY KEY,
title TEXT NOT NULL,
folder TEXT NOT NULL UNIQUE,
lang TEXT,
status TEXT NOT NULL, -- queued | downloading | done | error | cancelled
done INTEGER NOT NULL DEFAULT 0,
total INTEGER NOT NULL DEFAULT 0,
failed INTEGER NOT NULL DEFAULT 0,
error TEXT,
meta TEXT, -- JSON de la ficha, para abrirla sin conexión
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS downloaded_chapters (
chapter_id TEXT PRIMARY KEY,
manga_id TEXT NOT NULL REFERENCES downloads(manga_id) ON DELETE CASCADE,
num TEXT,
title TEXT,
file TEXT NOT NULL,
pages INTEGER NOT NULL,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_dlch_manga ON downloaded_chapters(manga_id);
`)
db.prepare('DELETE FROM sessions WHERE expires_at < ?').run(Date.now())
// Descargas parciales: scope 'some' baja solo los capítulos pedidos (download_wanted); 'all', el manga entero.
if (!db.prepare('PRAGMA table_info(downloads)').all().some(c => c.name === 'scope')) db.exec("ALTER TABLE downloads ADD COLUMN scope TEXT NOT NULL DEFAULT 'all'")
db.exec(`CREATE TABLE IF NOT EXISTS download_wanted (
chapter_id TEXT PRIMARY KEY,
manga_id TEXT NOT NULL REFERENCES downloads(manga_id) ON DELETE CASCADE,
created_at INTEGER NOT NULL
)`)
// Usuarios de cada manga descargado: quién lo pidió (descarga, reescalado, descarga anticipada) o lo
// empezó a leer ya descargado. Colas y "Biblioteca descargada" muestran solo lo de cada usuario; los
// archivos son uno para todos, y se borran cuando ya no lo tiene nadie.
db.exec(`CREATE TABLE IF NOT EXISTS download_users (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
manga_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
PRIMARY KEY (user_id, manga_id)
)`)
const linkDownload = (userId, mangaId) => db.prepare('INSERT OR IGNORE INTO download_users (user_id, manga_id, created_at) VALUES (?, ?, ?)').run(userId, mangaId, Date.now())
const sharedWith = (userId, mangaId) => !!db.prepare('SELECT 1 FROM download_users WHERE manga_id = ? AND user_id != ?').get(mangaId, userId)
const MY_DOWNLOADS = 'manga_id IN (SELECT manga_id FROM download_users WHERE user_id = ?)'
// finished_at: cuándo se terminó la última página del último capítulo disponible (null = en curso).
if (!db.prepare('PRAGMA table_info(progress)').all().some(c => c.name === 'finished_at')) db.exec('ALTER TABLE progress ADD COLUMN finished_at INTEGER')
// ---------- Contraseñas y sesiones ----------
function hashPassword(pw) {
const salt = crypto.randomBytes(16)
const hash = crypto.scryptSync(pw, salt, 64)
return `${salt.toString('hex')}:${hash.toString('hex')}`
}
function verifyPassword(pw, stored) {
const [saltHex, hashHex] = stored.split(':')
const hash = crypto.scryptSync(pw, Buffer.from(saltHex, 'hex'), 64)
return crypto.timingSafeEqual(hash, Buffer.from(hashHex, 'hex'))
}
function createSession(userId) {
const token = crypto.randomBytes(32).toString('base64url')
db.prepare('INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)')
.run(token, userId, Date.now() + SESSION_DAYS * 86400e3)
return token
}
function sessionCookie(token, maxAge, secure = false) {
return `sid=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAge}${secure ? '; Secure' : ''}`
}
// Hash de una contraseña cualquiera para que un usuario inexistente tarde lo mismo que uno real.
const DUMMY_HASH = hashPassword(crypto.randomBytes(16).toString('hex'))
function getUser(req) {
const m = /(?:^|;\s*)sid=([^;]+)/.exec(req.headers.cookie || '')
if (!m) return null
return db.prepare(`SELECT u.id, u.username, u.lang, u.content_mode FROM sessions s JOIN users u ON u.id = s.user_id
WHERE s.token = ? AND s.expires_at > ?`).get(m[1], Date.now()) ?? null
}
if (!db.prepare('PRAGMA table_info(users)').all().some(c => c.name === 'content_mode')) db.exec("ALTER TABLE users ADD COLUMN content_mode TEXT NOT NULL DEFAULT 'both'")
// ---------- Cliente MangaDex (adaptado de Tanko src/servers/mangadex.ts) ----------
const cache = new Map()
async function cached(key, ttl, fn) {
const hit = cache.get(key)
if (hit && hit.exp > Date.now()) return hit.data
let data
try {
data = await fn()
} catch (e) {
// Fuente caída (p. ej. 522 de Cloudflare en LeerCapitulo): mejor lo último que se tenía que un error.
if (hit && (e.status ?? 500) >= 500) { console.warn(`Usando copia anterior de ${key}: ${e.message}`); return hit.data }
throw e
}
cache.delete(key)
cache.set(key, { data, exp: Date.now() + ttl })
if (cache.size > 500) cache.delete(cache.keys().next().value)
return data
}
// Las imágenes pasan por /api/img firmadas con HMAC: el proxy solo descarga URLs generadas por este servidor.
const sign = u => crypto.createHmac('sha256', SECRET).update(u).digest('base64url').slice(0, 22)
const imgUrl = u => `/api/img?u=${encodeURIComponent(u)}&s=${sign(u)}`
// Portadas: &m=<manga> deja al proxy servir (y generar) la versión reescalada si el manga está en el
// historial o en seguidos del usuario; las demás (búsquedas) se sirven tal cual.
const coverUrl = (u, mangaId) => `${imgUrl(u)}&m=${encodeURIComponent(mangaId)}`
// Conexión a internet del servidor (app/connectivity.js). Sin ella, las fuentes fallan al instante.
const net = setupConnectivity({ httpError: (status, message) => new HttpError(status, message) })
async function md(pathname, params = {}, ttl = 5 * 60e3) {
const url = new URL(MD_API + pathname)
for (const [k, v] of Object.entries(params)) {
if (Array.isArray(v)) v.forEach(x => url.searchParams.append(k, x))
else if (v !== undefined && v !== '') url.searchParams.set(k, v)
}
return cached(url.toString(), ttl, async () => {
net.guard()
const res = await fetch(url, { headers: { 'User-Agent': UA }, signal: AbortSignal.timeout(30e3) }).catch(() => { net.failed(); throw new HttpError(502, 'No se pudo conectar con MangaDex') })
if (!res.ok) throw new HttpError(502, `MangaDex respondió ${res.status}`)
return res.json()
})
}
const pick = (obj, langs) => {
if (!obj) return ''
for (const l of langs) if (obj[l]) return obj[l]
return Object.values(obj)[0] ?? ''
}
function mangaSummary(m, lang) {
const a = m.attributes
const langs = [lang, 'es', 'es-la', 'en', 'ja-ro']
let title = a.title.en ?? Object.values(a.title)[0]
const alt = (a.altTitles || []).find(t => t[lang] || t.es || t['es-la'])
if (alt) title = pick(alt, [lang, 'es-la', 'es'])
const coverRel = m.relationships?.find(r => r.type === 'cover_art')
const cover = coverRel?.attributes?.fileName
? coverUrl(`https://uploads.mangadex.org/covers/${m.id}/${coverRel.attributes.fileName}.512.jpg`, m.id)
: null
return {
id: m.id,
source: 'md',
title,
description: pick(a.description, langs),
status: a.status,
year: a.year,
tags: (a.tags || []).map(t => ({ id: t.id, name: pick(t.attributes.name, ['es', 'en']) })).slice(0, 12),
authors: (m.relationships || []).filter(r => r.type === 'author' && r.attributes).map(r => r.attributes.name),
languages: a.availableTranslatedLanguages || [],
type: { ja: 'Manga', ko: 'Manhwa', zh: 'Manhua', 'zh-hk': 'Manhua' }[a.originalLanguage] || 'Cómic',
cover,
}
}
const RATINGS = ['safe', 'suggestive']
const PAGE_SIZE = 30
async function searchManga(q, lang, mode, tag, page = 1) {
const params = {
limit: PAGE_SIZE,
offset: (page - 1) * PAGE_SIZE,
'includedTags[]': tag ? [tag] : undefined,
'includes[]': ['cover_art'],
'contentRating[]': RATINGS,
'availableTranslatedLanguage[]': lang ? [lang] : undefined,
}
if (q) { params.title = q; params['order[relevance]'] = 'desc' }
else if (mode === 'latest') params['order[latestUploadedChapter]'] = 'desc'
else params['order[followedCount]'] = 'desc'
const data = await md('/manga', params)
return data.data.map(m => mangaSummary(m, lang))
}
const MD_GROUPS = { genre: 'Géneros', theme: 'Temáticas', format: 'Formato', content: 'Contenido' }
async function mdCategories() {
const data = await md('/manga/tag', {}, 24 * 3600e3)
return data.data
.map(t => ({ id: t.id, name: pick(t.attributes.name, ['es', 'en']), group: MD_GROUPS[t.attributes.group] || t.attributes.group }))
.sort((a, b) => a.group.localeCompare(b.group) || a.name.localeCompare(b.name))
}
async function getManga(id, lang) {
const data = await md(`/manga/${id}`, { 'includes[]': ['cover_art', 'author'] }, 30 * 60e3)
return mangaSummary(data.data, lang)
}
// Lista de capítulos en un idioma, un registro por número de capítulo (como Tanko).
// El español junta es-la y es, prefiriendo la variante elegida.
async function getChapters(mangaId, lang) {
const langs = lang === 'es-la' ? ['es-la', 'es'] : lang === 'es' ? ['es', 'es-la'] : [lang]
const all = []
for (let offset = 0; ; offset += 500) {
const data = await md(`/manga/${mangaId}/feed`, {
limit: 500, offset,
'translatedLanguage[]': langs,
'order[volume]': 'asc', 'order[chapter]': 'asc',
'contentRating[]': [...RATINGS, 'erotica'],
includeExternalUrl: 0, includeEmptyPages: 0, includeFuturePublishAt: 0,
'includes[]': ['scanlation_group'],
})
all.push(...data.data)
if (offset + 500 >= data.total || data.data.length === 0) break
}
const byNum = new Map()
for (const c of all) {
const num = c.attributes.chapter ?? `oneshot-${c.id}`
const prev = byNum.get(num)
if (prev && langs.indexOf(prev.lang) <= langs.indexOf(c.attributes.translatedLanguage)) continue
byNum.set(num, {
id: c.id,
num: c.attributes.chapter,
volume: c.attributes.volume,
title: c.attributes.title || '',
pages: c.attributes.pages,
group: c.relationships.find(r => r.type === 'scanlation_group')?.attributes?.name || '',
publishAt: c.attributes.publishAt,
lang: c.attributes.translatedLanguage,
})
}
const out = [...byNum.values()]
out.sort((a, b) => (parseFloat(a.num) || 0) - (parseFloat(b.num) || 0))
return out
}
// Idiomas con capítulos legibles de verdad (availableTranslatedLanguages incluye capítulos retirados o externos).
async function getChapterLangs(mangaId) {
const data = await md(`/manga/${mangaId}/feed`, {
limit: 500, includeExternalUrl: 0, includeEmptyPages: 0, includeFuturePublishAt: 0,
'contentRating[]': [...RATINGS, 'erotica'],
}, 30 * 60e3)
const count = {}
for (const c of data.data) count[c.attributes.translatedLanguage] = (count[c.attributes.translatedLanguage] || 0) + 1
return Object.entries(count).sort((a, b) => b[1] - a[1]).map(([l]) => l)
}
async function getPageUrls(chapterId, saver, ttl = 5 * 60e3) {
// Las URLs de at-home caducan a los ~15 min: caché corta.
const data = await md(`/at-home/server/${chapterId}`, {}, ttl)
const { baseUrl, chapter } = data
const files = saver ? chapter.dataSaver : chapter.data
const kind = saver ? 'data-saver' : 'data'
return files.map(f => `${baseUrl}/${kind}/${chapter.hash}/${f}`)
}
const getPages = async (chapterId, saver) => (await getPageUrls(chapterId, saver)).map(imgUrl)
// ---------- Cliente LeerCapitulo (Tanko src/servers/leerCapitulo.ts, adaptado al sitio actual) ----------
// IDs: manga "lc~<id>~<slug>", capítulo "lc~<id>~<slug>~<número>". Ya no hace falta navegador:
// las páginas del capítulo vienen en el HTML como <img data-src>.
const ENTITIES = {
amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', lsaquo: '‹', rsaquo: '›', laquo: '«', raquo: '»', iexcl: '¡', iquest: '¿',
hellip: '…', mdash: '—', ndash: '–', ldquo: '“', rdquo: '”', lsquo: '‘', rsquo: '’', middot: '·', deg: '°', copy: '©', reg: '®', trade: '™',
aacute: 'á', eacute: 'é', iacute: 'í', oacute: 'ó', uacute: 'ú', ntilde: 'ñ', uuml: 'ü', ccedil: 'ç', agrave: 'à', egrave: 'è', ograve: 'ò',
Aacute: 'Á', Eacute: 'É', Iacute: 'Í', Oacute: 'Ó', Uacute: 'Ú', Ntilde: 'Ñ', Uuml: 'Ü', Ccedil: 'Ç',
}
const decode = s => String(s ?? '').replace(/<[^>]+>/g, '').replace(/&(#x?[0-9a-f]+|[a-z]+);/gi, (all, e) => {
if (e[0] !== '#') return ENTITIES[e] ?? ENTITIES[e.toLowerCase()] ?? all
const n = e[1].toLowerCase() === 'x' ? parseInt(e.slice(2), 16) : parseInt(e.slice(1), 10)
return n > 0 && n <= 0x10ffff ? String.fromCodePoint(n) : all
}).trim()
async function lc(pathname, ttl = 10 * 60e3, asJson = false) {
return cached(`lc:${pathname}`, ttl, async () => {
// Cloudflare de LeerCapitulo da 52x/503 a ratos cuando su servidor no responde: hasta 3 intentos.
for (let i = 0; ; i++) {
net.guard()
const res = await fetch(LC_BASE + pathname, { headers: { 'User-Agent': BROWSER_UA, 'Accept-Language': 'es' }, signal: AbortSignal.timeout(30e3) })
.catch(() => { net.failed(); return null })
if (res?.ok) return asJson ? res.json() : res.text()
if (res?.status === 404) throw new HttpError(404, 'LeerCapitulo respondió 404')
const down = !res || res.status >= 500
if (!down || i >= 2) {
throw new HttpError(502, down ? `LeerCapitulo no responde ahora mismo${res ? ` (error ${res.status} de su servidor)` : ''}. Prueba en un rato.` : `LeerCapitulo respondió ${res.status}`)
}
await new Promise(r => setTimeout(r, 1500 * (i + 1)))
}
})
}
const lcCover = (src, id) => (src ? coverUrl(new URL(src, LC_BASE).toString(), id) : null)
const lcId = uri => {
const m = /\/manga\/([a-z0-9]+)\/([a-z0-9-]+)\//.exec(uri || '')
return m ? `lc~${m[1]}~${m[2]}` : null
}
function lcParseCards(html) {
const re = /class="lc-(?:slide|side-item|release|card)(?:-cover)?" href="(\/manga\/[a-z0-9]+\/[a-z0-9-]+\/)"[^>]*>\s*<img src="([^"]+)"[\s\S]*?class="lc-(?:slide|side|release|card)-(?:name|title)[^"]*"[^>]*>([^<]+)/g
const out = []
const seen = new Set()
for (const m of html.matchAll(re)) {
const id = lcId(m[1])
if (!id || seen.has(id)) continue
seen.add(id)
out.push({ id, source: 'lc', title: decode(m[3]), cover: lcCover(m[2], id) })
}
return out
}
const LC_TAG = /^(genre|theme):([a-z0-9-]+)$/
async function lcCategories() {
const html = await lc('/manga/', 24 * 3600e3)
const out = []
const seen = new Set()
for (const m of html.matchAll(/(?:href|value)="\/manga\/\?(genre|theme)=([a-z0-9-]+)"[^>]*>([^<]+)</g)) {
const id = `${m[1]}:${m[2]}`
if (seen.has(id)) continue
seen.add(id)
out.push({ id, name: decode(m[3]), group: m[1] === 'genre' ? 'Géneros' : 'Temáticas' })
}
return out.sort((a, b) => a.group.localeCompare(b.group) || a.name.localeCompare(b.name))
}
async function lcSearch(q, mode, tag, page = 1) {
const t = LC_TAG.exec(tag || '')
if (t) {
const html = await lc(`/manga/?${t[1]}=${t[2]}${q ? `&q=${encodeURIComponent(q)}` : ''}${page > 1 ? `&page=${page}` : ''}`)
return lcParseCards(html.slice(html.indexOf('</form>')))
}
if (q) {
const list = await lc(`/search/?q=${encodeURIComponent(q)}`, 10 * 60e3, true)
return list.map(e => ({ id: lcId(e.uri), source: 'lc', title: decode(e.name), cover: lcCover(e.cover_uri, lcId(e.uri)), sub: e.last_chapter_name }))
.filter(e => e.id)
}
const html = await lc('/')
const section = (from, to) => {
const i = html.indexOf(from)
if (i < 0) return ''
const j = html.indexOf(to, i + from.length)
return html.slice(i, j < 0 ? undefined : j)
}
if (mode === 'latest') return lcParseCards(section('<h1>Ultimos capitulos', '<footer'))
const popular = [...lcParseCards(section('<h2>Tendencias', '</section>')), ...lcParseCards(section('>Populares</h2>', 'lc-panel-title'))]
return popular.filter((m, i) => popular.findIndex(x => x.id === m.id) === i)
}
async function lcManga(id) {
const [, mid, slug] = id.split('~')
const html = await lc(`/manga/${mid}/${slug}/`)
const fact = k => decode(new RegExp(`<span class="k">${k}</span>\\s*<(?:a|span)[^>]*>([^<]*)<`).exec(html)?.[1])
const sinopsis = /id="sinopsis"[\s\S]*?<p[^>]*>([\s\S]*?)<\/p>/.exec(html)?.[1]
const chapters = []
const seen = new Set()
const re = new RegExp(`href="/leer/${mid}/${slug}/([0-9a-z.-]+)/">\\s*<span class="n">([^<]*)</span>[\\s\\S]*?<span class="d">([^<]*)</span>`, 'g')
for (const m of html.matchAll(re)) {
if (seen.has(m[1])) continue
seen.add(m[1])
const title = decode(m[2]).replace(/^Cap[ií]tulo\s+[0-9.]+\s*[:-]?\s*/i, '')
chapters.push({ id: `${id}~${m[1]}`, num: m[1], volume: null, title, pages: null, group: m[3], publishAt: m[3] })
}
chapters.sort((a, b) => (parseFloat(a.num) || 0) - (parseFloat(b.num) || 0))
const manga = {
id,
source: 'lc',
title: decode(/<h1[^>]*>([^<]+)/.exec(html)?.[1]) || slug,
description: decode(sinopsis?.replace(/<br\s*\/?>/g, '\n')),
status: fact('Estado'),
type: fact('Tipo') || null,
year: null,
tags: [...html.matchAll(/class="badge [^"]*" href="\/manga\/\?(genre|theme)=([a-z0-9-]+)">([^<]+)</g)].map(m => ({ id: `${m[1]}:${m[2]}`, name: decode(m[3]) })).slice(0, 12),
authors: fact('Autor') ? [fact('Autor')] : [],
languages: ['es'],
cover: lcCover(/class="lc-cover-lg">\s*<img src="([^"]+)"/.exec(html)?.[1], id),
}
return { manga, chapters }
}
async function lcPageUrls(chapterId) {
const [, mid, slug, num] = chapterId.split('~')
const html = await lc(`/leer/${mid}/${slug}/${num}/`, 30 * 60e3)
const main = html.slice(html.indexOf('id="lcPages"'))
const pages = [...main.matchAll(/<img data-src="(https?:\/\/[^"]+)"/g)].map(m => decode(m[1]))
if (!pages.length) throw new HttpError(502, 'No se encontraron páginas en LeerCapitulo')
return pages
}
const lcPages = async chapterId => (await lcPageUrls(chapterId)).map(imgUrl)
// ---------- M440 (app/m440.js) ----------
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' : /^(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.
// isCover: portada a servir en HD; coverish: portada sin HD (caché corta por si luego se sigue el manga);
// banner: imagen de fondo de la ficha, siempre en HD.
async function proxyImage(req, res, u, s, isCover = false, coverish = false, banner = false) {
let url
try { url = new URL(u) } catch { throw new HttpError(400, 'URL inválida') }
const expected = sign(u)
if (typeof s !== 'string' || s.length !== expected.length || !crypto.timingSafeEqual(Buffer.from(s), Buffer.from(expected))) throw new HttpError(403, 'Firma inválida')
if (!/^https?:$/.test(url.protocol) || /^(localhost|\d+\.\d+\.\d+\.\d+|\[.*\])$/.test(url.hostname)) throw new HttpError(403, 'Host no permitido')
const hdKey = banner ? bannerKey(u) : isCover ? u : null
if (hdKey && covers.sendCached(res, hdKey)) return
// Los CDN de las fuentes fallan a ratos (520, conexión cortada, respuestas que no llegan): hasta 3
// intentos con tiempo límite, para no dejar al navegador esperando para siempre.
net.guard() // sin internet: fallar ya (la portada local entra como respaldo), sin reintentos
const r = await withRetry(async () => {
const r = await fetchImage(url, AbortSignal.timeout(60e3))
if (r.status >= 500) throw new HttpError(502, `Imagen ${r.status}`)
return r
}).catch(e => { throw e instanceof HttpError ? e : new HttpError(502, `Imagen: ${e.message}`) })
if (!r.ok || !r.body) throw new HttpError(502, `Imagen ${r.status}`)
if (hdKey) {
const buf = Buffer.from(await r.arrayBuffer())
res.writeHead(200, { 'Content-Type': r.headers.get('content-type') || 'image/jpeg', 'Cache-Control': covers.pendingCache(banner) })
res.end(buf)
return covers.enqueue(hdKey, buf, banner)
}
res.writeHead(200, {
'Content-Type': r.headers.get('content-type') || 'image/jpeg',
'Cache-Control': coverish ? 'private, max-age=3600' : 'private, max-age=604800, immutable',
})
for await (const chunk of r.body) res.write(chunk)
res.end()
}
const bannerKey = u => `banner:${u}`
// Reescala un banner sin esperar a que alguien abra la ficha (recibe la URL del proxy, como se guarda).
async function prepareBanner(proxyUrl) {
const u = rawFromProxy(proxyUrl)
if (!u || !covers.wants(bannerKey(u))) return
const r = await fetchImage(new URL(u), AbortSignal.timeout(60e3))
if (r.ok) covers.enqueue(bannerKey(u), Buffer.from(await r.arrayBuffer()), true)
}
function fetchImage(url, signal) {
net.guard()
const host = new URL(url).hostname
const isMd = /(^|\.)mangadex\.(org|network)$/.test(host)
const referer = isMd ? null : /(^|\.)m440\.in$/.test(host) ? MI_BASE + '/' : LC_BASE + '/'
return safeFetch(url, { signal, headers: { 'User-Agent': isMd ? UA : BROWSER_UA, ...(referer ? { Referer: referer } : {}) } })
.catch(e => { if (e.message !== 'Host no permitido') net.failed(); throw e })
}
// ---------- Descargas a MANGA_DIR/<Título>/Capitulo NNNN.cbz ----------
const sleep = ms => new Promise(r => setTimeout(r, ms))
const safeName = s => String(s).normalize('NFC').replace(/[\/\\:*?"<>|\x00-\x1f]/g, ' ').replace(/\s+/g, ' ').replace(/^[.\s]+|[.\s]+$/g, '').slice(0, 150) || 'Sin titulo'
const xmlEsc = s => String(s ?? '').replace(/[<>&"']/g, c => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;', "'": '&apos;' }[c]))
const rawFromProxy = u => (u?.startsWith('/api/img?') ? new URLSearchParams(u.slice(9)).get('u') : null)
const chapterFileName = c => {
if (!c.num) return `Oneshot ${c.id.slice(-8)}.cbz`
const [int, dec] = String(c.num).split('.')
return safeName(`Capitulo ${int.padStart(4, '0')}${dec ? '.' + dec : ''}`) + '.cbz'
}
async function withRetry(fn, tries = 3) {
for (let i = 1; ; i++) {
try { return await fn() } catch (e) { if (i >= tries) throw e; await sleep(1500 * i) }
}
}
async function downloadBuffer(url) {
return withRetry(async () => {
const r = await fetchImage(url, AbortSignal.timeout(60e3))
if (!r.ok) throw new Error(`HTTP ${r.status} en ${url}`)
return { data: Buffer.from(await r.arrayBuffer()), type: r.headers.get('content-type') || '' }
})
}
const EXT_BY_TYPE = { 'image/jpeg': 'jpg', 'image/png': 'png', 'image/webp': 'webp', 'image/gif': 'gif', 'image/avif': 'avif' }
async function fetchMangaFull(mangaId, lang) {
const src = sourceOf(mangaId)
return src === 'lc' ? lcManga(mangaId)
: src === 'mi' ? mi.manga(mangaId)
: isBookId(mangaId) ? books.manga(mangaId)
: { manga: await getManga(mangaId, lang), chapters: await getChapters(mangaId, lang) }
}
// Progreso del capítulo en curso (solo en memoria): manga_id -> { chapter, pages, pagesTotal }
const dlCurrent = new Map()
async function downloadChapter(dl, manga, c, dir) {
dlCurrent.set(dl.manga_id, { chapter: c.num, pages: 0, pagesTotal: 0 })
const urls = await withRetry(() => (manga.source === 'lc' ? lcPageUrls(c.id) : manga.source === 'mi' ? mi.pageUrls(c.id) : getPageUrls(c.id, false, 0)))
dlCurrent.set(dl.manga_id, { chapter: c.num, pages: 0, pagesTotal: urls.length })
const entries = []
// 4 imágenes en paralelo.
for (let i = 0; i < urls.length; i += 4) {
const batch = await Promise.all(urls.slice(i, i + 4).map(downloadBuffer))
batch.forEach(({ data, type }, k) => {
const n = i + k
const ext = EXT_BY_TYPE[type.split(';')[0]] || urls[n].split('?')[0].split('.').pop().toLowerCase().replace(/[^a-z]/g, '') || 'jpg'
entries.push({ name: `${String(n + 1).padStart(3, '0')}.${ext}`, data })
})
dlCurrent.set(dl.manga_id, { chapter: c.num, pages: entries.length, pagesTotal: urls.length })
if (isCancelled(dl.manga_id)) throw new Error('cancelada')
}
entries.push({ name: 'ComicInfo.xml', data: Buffer.from(`<?xml version="1.0" encoding="utf-8"?>
<ComicInfo xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
<Series>${xmlEsc(manga.title)}</Series>
${c.num ? `<Number>${xmlEsc(c.num)}</Number>` : ''}
${c.volume ? `<Volume>${xmlEsc(c.volume)}</Volume>` : ''}
${c.title ? `<Title>${xmlEsc(c.title)}</Title>` : ''}
<Summary>${xmlEsc(manga.description)}</Summary>
<Writer>${xmlEsc(manga.authors.join(', '))}</Writer>
<Genre>${xmlEsc(manga.tags.map(t => t.name ?? t).join(', '))}</Genre>
<LanguageISO>${xmlEsc((dl.lang || 'es').split('-')[0])}</LanguageISO>
<PageCount>${urls.length}</PageCount>
<Manga>YesAndRightToLeft</Manga>
</ComicInfo>
`) })
const file = chapterFileName(c)
writeCbz(path.join(dir, file), entries)
if (!db.prepare('SELECT 1 FROM downloads WHERE manga_id = ?').get(dl.manga_id)) {
// La copia local se eliminó mientras se descargaba este capítulo.
fs.rmSync(path.join(dir, file), { force: true })
throw new Error('eliminada')
}
db.prepare(`INSERT OR REPLACE INTO downloaded_chapters (chapter_id, manga_id, num, title, file, pages, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`)
.run(c.id, dl.manga_id, c.num ?? null, c.title || '', file, urls.length, Date.now())
db.prepare('DELETE FROM download_wanted WHERE chapter_id = ?').run(c.id)
upscale.onChapterDownloaded(dl.manga_id, c.id)
}
const dlUpdate = (mangaId, fields) => {
const keys = Object.keys(fields)
db.prepare(`UPDATE downloads SET ${keys.map(k => `${k} = ?`).join(', ')}, updated_at = ? WHERE manga_id = ?`)
.run(...keys.map(k => fields[k]), Date.now(), mangaId)
}
// Cancelada o eliminada (sin fila) cuentan igual: hay que parar.
const isCancelled = mangaId => (db.prepare('SELECT status FROM downloads WHERE manga_id = ?').get(mangaId)?.status ?? 'cancelled') === 'cancelled'
function mangaDirOf(folder) {
const dir = path.resolve(MANGA_DIR, folder)
if (!folder || !dir.startsWith(path.resolve(MANGA_DIR) + path.sep)) throw new HttpError(400, 'Carpeta inválida')
return dir
}
function dirSize(dir) {
let total = 0
try {
for (const f of fs.readdirSync(dir, { withFileTypes: true })) total += f.isDirectory() ? dirSize(path.join(dir, f.name)) : fs.statSync(path.join(dir, f.name)).size
} catch {}
return total
}
function deleteDownload(mangaId) {
const dl = db.prepare('SELECT folder FROM downloads WHERE manga_id = ?').get(mangaId)
if (!dl) return false
const dir = mangaDirOf(dl.folder)
upscale.cancel(mangaId)
db.prepare('DELETE FROM downloads WHERE manga_id = ?').run(mangaId) // borra también downloaded_chapters (CASCADE)
db.prepare('DELETE FROM download_users WHERE manga_id = ?').run(mangaId)
dlCurrent.delete(mangaId)
fs.rmSync(dir, { recursive: true, force: true })
return true
}
async function runDownload(dl) {
const { manga, chapters } = await fetchMangaFull(dl.manga_id, dl.lang)
const dir = path.join(MANGA_DIR, dl.folder)
fs.mkdirSync(dir, { recursive: true })
const coverRaw = rawFromProxy(manga.cover)
if (coverRaw && !fs.existsSync(path.join(dir, 'cover.jpg'))) {
try { fs.writeFileSync(path.join(dir, 'cover.jpg'), (await downloadBuffer(coverRaw)).data) } catch (e) { console.warn('Portada:', e.message) }
}
const have = new Set(db.prepare('SELECT chapter_id, file FROM downloaded_chapters WHERE manga_id = ?').all(dl.manga_id)
.filter(r => fs.existsSync(path.join(dir, r.file))).map(r => r.chapter_id))
let pending = chapters.filter(c => !have.has(c.id))
let total = chapters.length
if (dl.scope === 'some') {
const wanted = new Set(db.prepare('SELECT chapter_id FROM download_wanted WHERE manga_id = ?').all(dl.manga_id).map(r => r.chapter_id))
pending = pending.filter(c => wanted.has(c.id))
total = have.size + pending.length
}
dlUpdate(dl.manga_id, { status: 'downloading', title: manga.title, meta: JSON.stringify({ manga, chapters }), total, done: total - pending.length, failed: 0, error: null })
let failed = 0
let lastError = null
for (const c of pending) {
if (isCancelled(dl.manga_id)) break
try {
await downloadChapter(dl, manga, c, dir)
} catch (e) {
if (isCancelled(dl.manga_id)) break
// Se cayó internet: no cuenta como fallo; la descarga queda en cola y sigue al volver la conexión.
if (!(await net.check())) {
dlCurrent.delete(dl.manga_id)
dlUpdate(dl.manga_id, { status: 'queued', error: null })
return
}
failed++
lastError = `Cap. ${c.num ?? '?'}: ${e.message}`
console.warn('Descarga', manga.title, lastError)
}
const done = db.prepare('SELECT COUNT(*) n FROM downloaded_chapters WHERE manga_id = ?').get(dl.manga_id).n
dlUpdate(dl.manga_id, { done, failed, error: lastError })
// MangaDex limita /at-home/server a 40 peticiones por minuto.
await sleep(manga.source === 'md' ? 1600 : manga.source === 'mi' ? 1000 : 500)
}
dlCurrent.delete(dl.manga_id)
if (isCancelled(dl.manga_id)) {
if (!db.prepare('SELECT 1 FROM downloads WHERE manga_id = ?').get(dl.manga_id)) fs.rmSync(dir, { recursive: true, force: true })
return
}
dlUpdate(dl.manga_id, { status: failed ? 'error' : 'done', error: failed ? `${failed} capítulo(s) fallaron. Último error: ${lastError}` : null })
}
let pumping = false
async function pumpDownloads() {
if (pumping) return
pumping = true
try {
for (;;) {
if (!net.online()) break // se reanuda con net.whenOnline
const dl = db.prepare("SELECT * FROM downloads WHERE status IN ('downloading', 'queued') ORDER BY status = 'downloading' DESC, created_at LIMIT 1").get()
if (!dl) break
requeue.delete(dl.manga_id)
try {
await runDownload(dl)
if (!net.online()) break
} catch (e) {
if (!net.online()) { dlUpdate(dl.manga_id, { status: 'queued', error: null }); break }
console.error('Descarga fallida', dl.manga_id, e)
if (!isCancelled(dl.manga_id)) dlUpdate(dl.manga_id, { status: 'error', error: e.message })
}
// Pidieron más capítulos mientras se bajaba: otra vuelta.
if (requeue.delete(dl.manga_id) && !isCancelled(dl.manga_id)) dlUpdate(dl.manga_id, { status: 'queued' })
}
} finally {
pumping = false
}
}
// chapterIds: solo esos capítulos (si el manga ya se baja entero, no cambia nada); sin ellos, el manga entero.
function enqueueDownload(mangaId, title, lang, chapterIds = null) {
const existing = db.prepare('SELECT * FROM downloads WHERE manga_id = ?').get(mangaId)
if (existing) {
if (!chapterIds && existing.scope !== 'all') dlUpdate(mangaId, { scope: 'all' })
// Si ya se estaba descargando, runDownload no verá lo nuevo: se vuelve a encolar al acabar.
if (!['queued', 'downloading'].includes(existing.status)) dlUpdate(mangaId, { status: 'queued', lang: lang || existing.lang, error: null })
else if (existing.status === 'downloading') requeue.add(mangaId)
} else {
let folder = safeName(title)
for (let i = 2; db.prepare('SELECT 1 FROM downloads WHERE folder = ?').get(folder); i++) folder = `${safeName(title)} (${i})`
const now = Date.now()
db.prepare('INSERT INTO downloads (manga_id, title, folder, lang, status, scope, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)')
.run(mangaId, title, folder, lang, 'queued', chapterIds ? 'some' : 'all', now, now)
}
if (chapterIds) {
const ins = db.prepare('INSERT OR IGNORE INTO download_wanted (chapter_id, manga_id, created_at) VALUES (?, ?, ?)')
const now = Date.now()
for (const id of chapterIds) ins.run(id, mangaId, now)
}
pumpDownloads()
}
const requeue = new Set()
const cbzIndexCache = new Map()
// hd: usar la versión reescalada si existe.
function localChapter(chapterId, hd = false) {
const row = db.prepare(`SELECT c.file, c.hd_file, d.folder FROM downloaded_chapters c JOIN downloads d ON d.manga_id = c.manga_id WHERE c.chapter_id = ?`).get(chapterId)
if (!row) return null
const file = (hd && upscale.hdFileOf(chapterId)) || path.join(MANGA_DIR, row.folder, row.file)
let st
try { st = fs.statSync(file) } catch { return null }
const key = `${file}:${st.mtimeMs}`
if (!cbzIndexCache.has(key)) {
cbzIndexCache.set(key, { file, entries: readCbzIndex(file) })
if (cbzIndexCache.size > 100) cbzIndexCache.delete(cbzIndexCache.keys().next().value)
}
return cbzIndexCache.get(key)
}
// Portada de un manga descargado: la cover.jpg de su carpeta, la de la ficha guardada o la del historial.
// Las URLs del proxy se vuelven a firmar (con &m= para la versión HD) por si se guardaron con otra clave.
function downloadCover(mangaId, folder, metaCover) {
if (fs.existsSync(path.join(mangaDirOf(folder), 'cover.jpg'))) return `/api/local-cover/${mangaId}`
const c = metaCover || db.prepare('SELECT cover FROM progress WHERE manga_id = ? AND cover IS NOT NULL UNION ALL SELECT cover FROM follows WHERE manga_id = ? AND cover IS NOT NULL LIMIT 1').get(mangaId, mangaId)?.cover
const u = rawFromProxy(c)
return u ? coverUrl(u, mangaId) : c || null
}
// Sin conexión: la búsqueda va sobre lo descargado en el servidor.
const fold = s => String(s).toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '')
function localSearch(text) {
const words = fold(text).split(/\s+/).filter(Boolean)
return db.prepare(`SELECT manga_id, title, folder, done, json_extract(meta, '$.manga.cover') meta_cover FROM downloads
WHERE done > 0 ORDER BY updated_at DESC`).all()
.filter(d => words.every(w => fold(d.title).includes(w)))
.map(d => ({ id: d.manga_id, source: sourceOf(d.manga_id), title: d.title, cover: downloadCover(d.manga_id, d.folder, d.meta_cover), sub: `${d.done} cap. descargado${d.done === 1 ? '' : 's'}` }))
}
const withCurrent = d => (d ? { ...d, current: ['queued', 'downloading'].includes(d.status) ? dlCurrent.get(d.manga_id) ?? null : null } : null)
const downloadInfo = (mangaId, size = false) => {
const d = withCurrent(db.prepare('SELECT manga_id, title, folder, lang, status, scope, done, total, failed, error, updated_at FROM downloads WHERE manga_id = ?').get(mangaId))
if (d && size) d.size = dirSize(mangaDirOf(d.folder))
if (d) d.wanted = db.prepare('SELECT chapter_id FROM download_wanted WHERE manga_id = ?').all(mangaId).map(r => r.chapter_id)
return d
}
// ---------- HTTP ----------
class HttpError extends Error {
constructor(status, message) { super(message); this.status = status }
}
function json(res, status, body, headers = {}) {
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store', ...headers })
res.end(JSON.stringify(body))
}
async function readBody(req) {
let size = 0
const chunks = []
for await (const c of req) {
size += c.length
if (size > 64 * 1024) throw new HttpError(413, 'Cuerpo demasiado grande')
chunks.push(c)
}
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}|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 = ?
UNION ALL SELECT 1 FROM follows WHERE user_id = ? AND manga_id = ? AND active = 1 LIMIT 1`).get(userId, mangaId, userId, mangaId)
const requireUser = req => {
const u = getUser(req)
if (!u) throw new HttpError(401, 'No autenticado')
return u
}
const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.svg': 'image/svg+xml', '.webmanifest': 'application/manifest+json', '.png': 'image/png' }
// index.html enlaza app.js y style.css con ?v=<hash del contenido>: si un proxy delante cambia el
// no-cache por una caché larga (Nginx Proxy Manager con «Cache Assets»), cada versión nueva tiene otra
// URL y el navegador no se queda con la vieja. Los archivos no cambian mientras el contenedor vive.
const assetVersion = name => crypto.createHash('sha256').update(fs.readFileSync(path.join(PUBLIC_DIR, name))).digest('hex').slice(0, 10)
const INDEX_HTML = fs.readFileSync(path.join(PUBLIC_DIR, 'index.html'), 'utf8')
.replace('src="/app.js"', `src="/app.js?v=${assetVersion('app.js')}"`)
.replace('href="/style.css"', `href="/style.css?v=${assetVersion('style.css')}"`)
.replace('<meta name="sw-version" content="">', `<meta name="sw-version" content="${assetVersion('sw.js')}">`)
function serveStatic(res, pathname) {
let file = path.join(PUBLIC_DIR, path.normalize(pathname).replace(/^(\.\.[/\\])+/, ''))
if (!file.startsWith(PUBLIC_DIR) || !fs.existsSync(file) || fs.statSync(file).isDirectory()) file = path.join(PUBLIC_DIR, 'index.html')
res.writeHead(200, { 'Content-Type': MIME[path.extname(file)] || 'application/octet-stream', 'Cache-Control': 'no-cache' })
if (file === path.join(PUBLIC_DIR, 'index.html')) return res.end(INDEX_HTML)
fs.createReadStream(file).pipe(res)
}
const loginAttempts = new Map()
function rateLimit(ip) {
const now = Date.now()
const list = (loginAttempts.get(ip) || []).filter(t => now - t < 10 * 60e3)
if (list.length >= 10) throw new HttpError(429, 'Demasiados intentos, espera unos minutos')
list.push(now)
loginAttempts.set(ip, list)
}
async function route(req, res) {
const ip = security.clientIp(req)
security.headers(req, res)
if (security.banned(ip)) {
res.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store' })
return res.end('Acceso bloqueado temporalmente por demasiados intentos fallidos.')
}
const url = new URL(req.url, 'http://x')
const p = url.pathname
const q = url.searchParams
const method = req.method
if (!p.startsWith('/api/')) return serveStatic(res, p)
// --- Auth ---
if (p === '/api/me' && method === 'GET') {
const userCount = db.prepare('SELECT COUNT(*) n FROM users').get().n
return json(res, 200, { user: getUser(req), signup: ALLOW_SIGNUP || userCount === 0 })
}
if ((p === '/api/login' || p === '/api/register') && method === 'POST') {
rateLimit(ip)
const { username, password } = await readBody(req)
if (typeof username !== 'string' || typeof password !== 'string') throw new HttpError(400, 'Datos incompletos')
let user
if (p === '/api/register') {
const userCount = db.prepare('SELECT COUNT(*) n FROM users').get().n
if (!ALLOW_SIGNUP && userCount > 0) { security.fail(ip, 'registro cerrado'); throw new HttpError(403, 'El registro está deshabilitado') }
if (!/^[\w.-]{3,32}$/.test(username)) throw new HttpError(400, 'Usuario: 3-32 caracteres (letras, números, . _ -)')
if (password.length < 8) throw new HttpError(400, 'La contraseña debe tener al menos 8 caracteres')
if (db.prepare('SELECT 1 FROM users WHERE username = ?').get(username)) throw new HttpError(409, 'Ese usuario ya existe')
const r = db.prepare('INSERT INTO users (username, pass_hash, created_at) VALUES (?, ?, ?)')
.run(username, hashPassword(password), Date.now())
user = { id: Number(r.lastInsertRowid) }
} else {
const row = db.prepare('SELECT id, pass_hash FROM users WHERE username = ?').get(username.slice(0, 64))
if (!verifyPassword(password.slice(0, 256), row?.pass_hash || DUMMY_HASH) || !row) {
security.fail(ip, 'contraseña incorrecta')
const left = security.remaining(ip)
throw new HttpError(401, `Usuario o contraseña incorrectos${left != null ? ` (${left ? `quedan ${left} intento${left > 1 ? 's' : ''} antes de bloquear esta IP` : 'IP bloqueada'})` : ''}`)
}
user = row
}
security.success(ip)
const token = createSession(user.id)
return json(res, 200, { ok: true }, { 'Set-Cookie': sessionCookie(token, SESSION_DAYS * 86400, security.isHttps(req)) })
}
if (p === '/api/logout' && method === 'POST') {
const m = /(?:^|;\s*)sid=([^;]+)/.exec(req.headers.cookie || '')
if (m) db.prepare('DELETE FROM sessions WHERE token = ?').run(m[1])
return json(res, 200, { ok: true }, { 'Set-Cookie': sessionCookie('', 0, security.isHttps(req)) })
}
// 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) {
// Pedir el reescalado (o el automático) lo pone en la biblioteca de quien lo pide.
if (method === 'POST' && (m = /^\/api\/upscale\/([^/]+)(\/auto)?$/.exec(p)) && db.prepare('SELECT 1 FROM downloads WHERE manga_id = ?').get(m[1])) linkDownload(user.id, m[1])
return
}
if (p.startsWith('/api/clips') && (await clips.route(p, method, q, req, res, user)) !== false) return
if (p.startsWith('/api/meta/') && MANGA_ID.test(decodeURIComponent(p.split('/')[3] || '')) && !isBookId(decodeURIComponent(p.split('/')[3] || '')) && (await meta.route(p, method, res)) !== false) return
if (/^\/api\/(lists|prefetch|recommendations)\b/.test(p) && (await library.route(p, method, q, req, res, user)) !== false) return
if (p === '/api/settings' && method === 'POST') {
const body = await readBody(req)
if (body.lang !== undefined) {
if (!LANG.test(body.lang)) throw new HttpError(400, 'Idioma inválido')
db.prepare('UPDATE users SET lang = ? WHERE id = ?').run(body.lang, user.id)
}
if (body.contentMode !== undefined) {
if (!['both', 'manga', 'books'].includes(body.contentMode)) throw new HttpError(400, 'Valor inválido')
db.prepare('UPDATE users SET content_mode = ? WHERE id = ?').run(body.contentMode, user.id)
}
if (body.newPassword !== undefined) {
const row = db.prepare('SELECT pass_hash FROM users WHERE id = ?').get(user.id)
if (!verifyPassword(String(body.oldPassword || '').slice(0, 256), row.pass_hash)) { security.fail(ip, 'contraseña actual incorrecta'); throw new HttpError(401, 'Contraseña actual incorrecta') }
if (String(body.newPassword).length < 8 || String(body.newPassword).length > 256) throw new HttpError(400, 'La contraseña debe tener entre 8 y 256 caracteres')
db.prepare('UPDATE users SET pass_hash = ? WHERE id = ?').run(hashPassword(String(body.newPassword)), user.id)
// Cerrar las demás sesiones (por si la contraseña se cambió por estar comprometida).
const mine = /(?:^|;\s*)sid=([^;]+)/.exec(req.headers.cookie || '')?.[1]
db.prepare('DELETE FROM sessions WHERE user_id = ? AND token != ?').run(user.id, mine || '')
}
return json(res, 200, { ok: true })
}
if (p === '/api/img' && method === 'GET') {
const mid = MANGA_ID.test(q.get('m') || '') ? q.get('m') : null
try {
return await proxyImage(req, res, q.get('u') || '', q.get('s'), !!mid && tracked(user.id, mid), q.has('m'), q.has('b'))
} catch (e) {
// Portada sin conexión con la fuente: la cover.jpg de la copia local, si la hay.
if (mid && !res.headersSent && e.status !== 403) {
const dl = downloadInfo(mid)
const file = dl && path.join(MANGA_DIR, dl.folder, 'cover.jpg')
if (file && fs.existsSync(file)) {
res.writeHead(200, { 'Content-Type': imageType(file), 'Cache-Control': 'private, max-age=300' })
return fs.createReadStream(file).pipe(res)
}
}
throw e
}
}
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() : ['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))
const anyLang = q.get('anyLang') === '1'
const mdTag = /^[0-9a-f-]{36}$/.test(tag) ? tag : null
return json(res, 200, await searchManga((q.get('q') || '').slice(0, 100), anyLang ? null : lang, q.get('mode'), mdTag, page))
}
let m
if ((m = /^\/api\/manga\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'GET') {
let manga, chapters, offline = false
try {
({ manga, chapters } = await fetchMangaFull(m[1], lang))
} catch (e) {
// Sin conexión con la fuente: usar la ficha guardada y solo los capítulos descargados.
const dl = db.prepare('SELECT meta FROM downloads WHERE manga_id = ? AND meta IS NOT NULL').get(m[1])
if (!dl) throw net.online() ? e : new HttpError(503, 'El servidor no tiene conexión a internet y este manga no está descargado en el servidor.')
const saved = JSON.parse(dl.meta)
const have = new Set(db.prepare('SELECT chapter_id FROM downloaded_chapters WHERE manga_id = ?').all(m[1]).map(r => r.chapter_id))
manga = { ...saved.manga, cover: `/api/local-cover/${m[1]}` }
chapters = saved.chapters.filter(c => have.has(c.id))
offline = true
}
const downloaded = db.prepare('SELECT chapter_id FROM downloaded_chapters WHERE manga_id = ?').all(m[1]).map(r => r.chapter_id)
const reads = db.prepare('SELECT chapter_id, page, total_pages FROM chapter_reads WHERE user_id = ? AND manga_id = ?').all(user.id, m[1])
const progress = db.prepare('SELECT * FROM progress WHERE user_id = ? AND manga_id = ?').get(user.id, m[1]) ?? null
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') {
if (isBookId(m[1])) {
const read = db.prepare('SELECT page, total_pages FROM chapter_reads WHERE user_id = ? AND chapter_id = ?').get(user.id, m[1])
return json(res, 200, { text: true, ...(await books.chapter(m[1])), pages: [], savedPage: read?.page ?? 0, savedTotal: read?.total_pages ?? 0, local: false, hd: false, hdAvailable: false })
}
const hdAvailable = !!upscale.hdFileOf(m[1])
const hd = hdAvailable && q.get('hd') !== '0'
const local = localChapter(m[1], hd)
if (!local && !net.online()) throw new HttpError(503, 'El servidor no tiene conexión a internet y este capítulo no está descargado en el servidor.')
const pages = local
? local.entries.map((_, i) => `/api/local/${m[1]}/${i}${hd ? '?hd=1' : ''}`)
: m[1].startsWith('lc~') ? await lcPages(m[1]) : m[1].startsWith('mi~') ? await miPages(m[1]) : await getPages(m[1], q.get('saver') === '1')
const read = db.prepare('SELECT page FROM chapter_reads WHERE user_id = ? AND chapter_id = ?').get(user.id, m[1])
return json(res, 200, { pages, savedPage: read?.page ?? 0, local: !!local, hd, hdAvailable })
}
if ((m = /^\/api\/local\/([^/]+)\/(\d+)$/.exec(p)) && CHAPTER_ID.test(m[1]) && method === 'GET') {
const local = localChapter(m[1], q.get('hd') === '1')
const entry = local?.entries[Number(m[2])]
if (!entry) throw new HttpError(404, 'Página no descargada')
return sendCbzEntry(res, local.file, entry)
}
if ((m = /^\/api\/local-cover\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'GET') {
const dl = downloadInfo(m[1])
const file = dl && path.join(MANGA_DIR, dl.folder, 'cover.jpg')
if (!file || !fs.existsSync(file)) throw new HttpError(404, 'Sin portada')
if (!tracked(user.id, m[1])) {
res.writeHead(200, { 'Content-Type': imageType(file), 'Cache-Control': 'private, max-age=86400' })
return fs.createReadStream(file).pipe(res)
}
const key = `local:${file}:${fs.statSync(file).mtimeMs}`
if (covers.sendCached(res, key)) return
const buf = fs.readFileSync(file)
res.writeHead(200, { 'Content-Type': imageType(file), 'Cache-Control': covers.pendingCache() })
res.end(buf)
return covers.enqueue(key, buf)
}
// --- Descargas ---
if (p === '/api/status' && method === 'GET') return json(res, 200, net.status())
if (p === '/api/queues' && method === 'GET') {
const downloads = db.prepare(`SELECT manga_id, title, folder, lang, status, scope, done, total, failed, error, updated_at, json_extract(meta, '$.manga.cover') meta_cover
FROM downloads WHERE status IN ('downloading', 'queued') AND ${MY_DOWNLOADS} ORDER BY status = 'downloading' DESC, created_at`).all(user.id)
.map(({ meta_cover, ...d }) => ({ ...withCurrent(d), cover: downloadCover(d.manga_id, d.folder, meta_cover) }))
const c = clips.queueInfo(user.id)
const up = upscale.queue()
const mine = new Set(db.prepare('SELECT manga_id FROM download_users WHERE user_id = ?').all(user.id).map(r => r.manga_id))
up.groups = up.groups.filter(g => mine.has(g.mangaId))
if (up.current && !mine.has(up.current.mangaId)) up.current = null
for (const g of up.groups) {
const d = db.prepare(`SELECT folder, json_extract(meta, '$.manga.cover') meta_cover FROM downloads WHERE manga_id = ?`).get(g.mangaId)
g.cover = d ? downloadCover(g.mangaId, d.folder, d.meta_cover) : null
}
return json(res, 200, { downloads, upscale: up, clips: { queued: c.queued || 0, running: c.running || 0, errors: c.errors || 0 }, covers: covers.pendingCount() })
}
if (p === '/api/downloads' && method === 'GET') {
return json(res, 200, db.prepare(`SELECT manga_id, title, folder, lang, status, done, total, failed, error, updated_at, json_extract(meta, '$.manga.cover') meta_cover
FROM downloads WHERE ${MY_DOWNLOADS} ORDER BY updated_at DESC`).all(user.id)
.map(({ meta_cover, ...d }) => ({ ...withCurrent(d), size: dirSize(mangaDirOf(d.folder)), cover: downloadCover(d.manga_id, d.folder, meta_cover), shared: sharedWith(user.id, d.manga_id) })))
}
if ((m = /^\/api\/download\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'GET') {
const d = downloadInfo(m[1], true)
return json(res, 200, d && { ...d, shared: sharedWith(user.id, m[1]) })
}
if ((m = /^\/api\/download\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'POST') {
if (isBookId(m[1])) throw new HttpError(400, 'Los libros no se descargan como capítulos')
const b = await readBody(req)
const dlLang = spanishOnly(m[1]) ? 'es' : LANG.test(b.lang || '') ? b.lang : user.lang
const title = String(b.title || '').slice(0, 300) || (await fetchMangaFull(m[1], dlLang)).manga.title
const ids = Array.isArray(b.chapterIds) ? b.chapterIds.map(String).filter(id => CHAPTER_ID.test(id)).slice(0, 5000) : null
if (ids && !ids.length) throw new HttpError(400, 'Ningún capítulo válido')
enqueueDownload(m[1], title, dlLang, ids)
linkDownload(user.id, m[1])
return json(res, 200, downloadInfo(m[1], true))
}
if ((m = /^\/api\/download\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'DELETE') {
// Si otro usuario también lo tiene, solo se quita de la biblioteca de este (los archivos se quedan).
if (sharedWith(user.id, m[1]) && db.prepare('DELETE FROM download_users WHERE user_id = ? AND manga_id = ?').run(user.id, m[1]).changes) return json(res, 200, { ok: true, kept: true })
if (!deleteDownload(m[1])) throw new HttpError(404, 'No hay copia local')
return json(res, 200, { ok: true })
}
if ((m = /^\/api\/download\/([^/]+)\/cancel$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'POST') {
db.prepare("UPDATE downloads SET status = 'cancelled', updated_at = ? WHERE manga_id = ? AND status IN ('queued', 'downloading')").run(Date.now(), m[1])
return json(res, 200, downloadInfo(m[1]))
}
// --- Historial ---
if (p === '/api/history' && method === 'GET') {
// Un manga terminado vuelve a "en curso" si después aparecieron capítulos nuevos.
const rows = db.prepare(`SELECT p.*, f.last_new_at FROM progress p
LEFT JOIN follows f ON f.user_id = p.user_id AND f.manga_id = p.manga_id AND f.active = 1
WHERE p.user_id = ? ORDER BY p.updated_at DESC LIMIT 200`).all(user.id)
return json(res, 200, rows.map(({ last_new_at, ...r }) => {
const hasNew = !!(r.finished_at && last_new_at && last_new_at > r.finished_at)
return { ...r, finished: !!r.finished_at && !hasNew, hasNew }
}))
}
if (p === '/api/progress' && method === 'POST') {
const b = await readBody(req)
if (!MANGA_ID.test(b.mangaId || '') || !CHAPTER_ID.test(b.chapterId || '') || !b.chapterId.startsWith(spanishOnly(b.mangaId) ? b.mangaId + '~' : '')) throw new HttpError(400, 'IDs inválidos')
const page = Math.max(0, Number(b.page) | 0)
const total = Math.max(0, Number(b.totalPages) | 0)
const now = Date.now()
const finishedAt = b.isLast === true && total && page >= total - 1 ? now : null
const prevPage = db.prepare('SELECT page FROM chapter_reads WHERE user_id = ? AND chapter_id = ?').get(user.id, b.chapterId)?.page
db.prepare(`INSERT INTO progress (user_id, manga_id, manga_title, cover, chapter_id, chapter_num, chapter_title, lang, page, total_pages, updated_at, finished_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (user_id, manga_id) DO UPDATE SET manga_title = excluded.manga_title, cover = excluded.cover,
chapter_id = excluded.chapter_id, chapter_num = excluded.chapter_num, chapter_title = excluded.chapter_title,
lang = excluded.lang, page = excluded.page, total_pages = excluded.total_pages, updated_at = excluded.updated_at,
finished_at = excluded.finished_at`)
.run(user.id, b.mangaId, String(b.mangaTitle || '').slice(0, 300), b.cover ? String(b.cover).slice(0, 1000) : null,
b.chapterId, b.chapterNum != null ? String(b.chapterNum) : null, String(b.chapterTitle || '').slice(0, 300),
LANG.test(b.lang || '') ? b.lang : null, page, total, now, finishedAt)
db.prepare(`INSERT INTO chapter_reads (user_id, chapter_id, manga_id, page, total_pages, updated_at) VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT (user_id, chapter_id) DO UPDATE SET page = excluded.page, total_pages = excluded.total_pages, updated_at = excluded.updated_at`)
.run(user.id, b.chapterId, b.mangaId, page, total, now)
if (db.prepare('SELECT 1 FROM downloads WHERE manga_id = ?').get(b.mangaId)) linkDownload(user.id, b.mangaId)
features.onProgress(user.id, b, page, total, prevPage)
if (!isBookId(b.mangaId)) library.onProgress(user.id, b, page, total)
return json(res, 200, { ok: true })
}
if ((m = /^\/api\/history\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'DELETE') {
db.prepare('DELETE FROM progress WHERE user_id = ? AND manga_id = ?').run(user.id, m[1])
db.prepare('DELETE FROM chapter_reads WHERE user_id = ? AND manga_id = ?').run(user.id, m[1])
features.unfollow(user.id, m[1])
return json(res, 200, { ok: true })
}
throw new HttpError(404, 'No encontrado')
}
const security = setupSecurity({ db })
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 })
const library = setupLibrary({ db, HttpError, json, readBody, MANGA_ID, CHAPTER_ID, fetchMangaFull, enqueueDownload, linkDownload, upscale, stats: features.stats, searchManga, mdCategories, net })
// Al volver internet: reanudar las descargas en cola.
net.whenOnline(() => pumpDownloads())
const meta = setupMeta({ db, md, sourceOf, fetchMangaFull, HttpError, json, net, decode, imgUrl, UA, prepareBanner })
const clips = setupClips({ db, DATA_DIR, HttpError, json, readBody, MANGA_ID, CHAPTER_ID, upscale })
// Migración: lo descargado antes de que hubiera dueños pasa a quien lo lee, lo sigue o lo tiene en una
// lista; si no es de nadie, al primer usuario (el que montó el servidor).
if (!db.prepare("SELECT 1 FROM kv WHERE k = 'download_users_init'").get()) {
const first = db.prepare('SELECT id FROM users ORDER BY id LIMIT 1').get()?.id
for (const { manga_id } of db.prepare('SELECT manga_id FROM downloads').all()) {
const users = db.prepare(`SELECT user_id FROM progress WHERE manga_id = ? UNION SELECT user_id FROM follows WHERE manga_id = ?
UNION SELECT l.user_id FROM list_items i JOIN lists l ON l.id = i.list_id WHERE i.manga_id = ?`).all(manga_id, manga_id, manga_id).map(r => r.user_id)
for (const u of users.length ? users : first ? [first] : []) linkDownload(u, manga_id)
}
if (first) db.prepare("INSERT INTO kv (k, v) VALUES ('download_users_init', '1')").run()
}
// Migración: portadas guardadas sin &m= (reescalado de portadas) y con la miniatura de 256 px de MangaDex.
for (const t of ['progress', 'follows', 'clips']) {
for (const row of db.prepare(`SELECT DISTINCT manga_id, cover FROM ${t} WHERE cover LIKE '/api/img?u=%&s=%' AND cover NOT LIKE '%&m=%'`).all()) {
const u = new URLSearchParams(row.cover.slice('/api/img?'.length)).get('u')
if (u) db.prepare(`UPDATE ${t} SET cover = ? WHERE cover = ? AND manga_id = ?`).run(coverUrl(u.replace(/(\/covers\/[^/]+\/[^/]+)\.256\.jpg$/, '$1.512.jpg'), row.manga_id), row.cover, row.manga_id)
}
}
// Migración: portadas guardadas antes de firmar las URLs del proxy.
for (const row of db.prepare("SELECT user_id, manga_id, cover FROM progress WHERE cover LIKE '/api/img?u=%' AND cover NOT LIKE '%&s=%'").all()) {
const u = new URLSearchParams(row.cover.slice('/api/img?'.length)).get('u')
if (u) db.prepare('UPDATE progress SET cover = ? WHERE user_id = ? AND manga_id = ?').run(coverUrl(u, row.manga_id), row.user_id, row.manga_id)
}
http.createServer(async (req, res) => {
try {
await route(req, res)
} catch (err) {
const status = err instanceof HttpError ? err.status : 500
// Sin internet los 503 son lo esperado: no llenar el log de trazas.
if (status >= 500 && !(status === 503 && !net.online())) console.error(req.method, req.url, err)
// Los errores internos no se detallan al cliente (rutas, nombres de archivo…): solo en el log.
if (!res.headersSent) json(res, status, { error: status >= 500 && status !== 502 && status !== 503 ? 'Error interno del servidor' : err.message || 'Error' })
else res.end()
}
}).listen(PORT, () => {
console.log(`Manga Reader escuchando en :${PORT} (registro ${ALLOW_SIGNUP ? 'abierto' : 'cerrado'}, descargas en ${MANGA_DIR})`)
pumpDownloads() // reanuda descargas pendientes tras un reinicio
})