Files
manga-reader/app/server.js
T
ninoandClaude Opus 5.5 af90d34c95 Docker autónomo: configuración del host en .env e imagen fijada
- Puertos, carpeta de mangas, PUID/PGID, URL pública y registro se
  configuran en .env (plantilla en .env.example); sin .env arranca con
  valores portables (0.0.0.0:3352, ./mangas).
- Publicar en 0.0.0.0 evita fallar al arrancar si la IP de la LAN o de
  Tailscale aún no existe; el acceso lo siguen limitando las reglas
  DOCKER-USER del host.
- Imagen base fijada a node:24.22.2-alpine3.24 (node:sqlite es experimental).
- ALLOW_SIGNUP=false por defecto (el primer usuario puede registrarse igual).
- Comprobación de permisos al arrancar con mensaje claro (chown / PUID).
- README: qué guardar y cómo mover el servicio a otro servidor.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 00:59:55 -06:00

853 lines
41 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 las mismas fuentes que Tanko (MangaDex y LeerCapitulo)
// 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'
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') !== 'false'
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())
// ---------- 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) {
return `sid=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAge}`
}
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 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
}
// ---------- 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
const data = await fn()
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)}`
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 () => {
const res = await fetch(url, { headers: { 'User-Agent': UA } }).catch(() => { 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
? imgUrl(`https://uploads.mangadex.org/covers/${m.id}/${coverRel.attributes.fileName}.256.jpg`)
: 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 decode = s => String(s ?? '').replace(/<[^>]+>/g, '').replace(/&(#x?[0-9a-f]+|amp|lt|gt|quot|apos|nbsp|lsaquo|rsaquo);/gi, (_, e) => {
const named = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', lsaquo: '‹', rsaquo: '›' }[e.toLowerCase()]
if (named) return named
return String.fromCodePoint(e[1].toLowerCase() === 'x' ? parseInt(e.slice(2), 16) : parseInt(e.slice(1), 10))
}).trim()
async function lc(pathname, ttl = 10 * 60e3, asJson = false) {
return cached(`lc:${pathname}`, ttl, async () => {
const res = await fetch(LC_BASE + pathname, { headers: { 'User-Agent': BROWSER_UA, 'Accept-Language': 'es' } })
.catch(() => { throw new HttpError(502, 'No se pudo conectar con LeerCapitulo') })
if (!res.ok) throw new HttpError(res.status === 404 ? 404 : 502, `LeerCapitulo respondió ${res.status}`)
return asJson ? res.json() : res.text()
})
}
const lcCover = src => (src ? imgUrl(new URL(src, LC_BASE).toString()) : 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]) })
}
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), 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]),
}
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)
// Proxy de imágenes: MangaDex bloquea el hotlinking de portadas y así el navegador no depende de CDNs ajenos.
async function proxyImage(req, res, u, s) {
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 r = await fetchImage(url)
if (!r.ok || !r.body) throw new HttpError(502, `Imagen ${r.status}`)
res.writeHead(200, {
'Content-Type': r.headers.get('content-type') || 'image/jpeg',
'Cache-Control': 'private, max-age=604800, immutable',
})
for await (const chunk of r.body) res.write(chunk)
res.end()
}
function fetchImage(url, signal) {
const isMd = /(^|\.)mangadex\.(org|network)$/.test(new URL(url).hostname)
return fetch(url, { signal, headers: { 'User-Agent': isMd ? UA : BROWSER_UA, ...(isMd ? {} : { Referer: LC_BASE + '/' }) } })
}
// ---------- 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) {
return mangaId.startsWith('lc~')
? lcManga(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) : 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())
}
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)) total += fs.statSync(path.join(dir, f)).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)
db.prepare('DELETE FROM downloads WHERE manga_id = ?').run(mangaId) // borra también downloaded_chapters (CASCADE)
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))
const pending = chapters.filter(c => !have.has(c.id))
dlUpdate(dl.manga_id, { status: 'downloading', title: manga.title, meta: JSON.stringify({ manga, chapters }), total: chapters.length, done: chapters.length - 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
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 === 'lc' ? 500 : 1600)
}
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 (;;) {
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
try {
await runDownload(dl)
} catch (e) {
console.error('Descarga fallida', dl.manga_id, e)
if (!isCancelled(dl.manga_id)) dlUpdate(dl.manga_id, { status: 'error', error: e.message })
}
}
} finally {
pumping = false
}
}
function enqueueDownload(mangaId, title, lang) {
const existing = db.prepare('SELECT * FROM downloads WHERE manga_id = ?').get(mangaId)
if (existing) {
if (!['queued', 'downloading'].includes(existing.status)) dlUpdate(mangaId, { status: 'queued', lang: lang || existing.lang, error: null })
} 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, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)')
.run(mangaId, title, folder, lang, 'queued', now, now)
}
pumpDownloads()
}
const cbzIndexCache = new Map()
function localChapter(chapterId) {
const row = db.prepare(`SELECT c.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 = 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)
}
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, done, total, failed, error, updated_at FROM downloads WHERE manga_id = ?').get(mangaId))
if (d && size) d.size = dirSize(mangaDirOf(d.folder))
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-]+)$/
const CHAPTER_ID = /^([0-9a-f-]{36}|lc~[a-z0-9]+~[a-z0-9-]+~[0-9a-z.-]+)$/
const LANG = /^[a-z]{2}(-[a-z]{2})?$/
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' }
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' })
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 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(req.socket.remoteAddress)
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) 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 < 6) throw new HttpError(400, 'La contraseña debe tener al menos 6 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)
if (!row || !verifyPassword(password, row.pass_hash)) throw new HttpError(401, 'Usuario o contraseña incorrectos')
user = row
}
const token = createSession(user.id)
return json(res, 200, { ok: true }, { 'Set-Cookie': sessionCookie(token, SESSION_DAYS * 86400) })
}
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) })
}
// Todo lo demás requiere sesión.
const user = requireUser(req)
if ((await features.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.newPassword !== undefined) {
const row = db.prepare('SELECT pass_hash FROM users WHERE id = ?').get(user.id)
if (!verifyPassword(String(body.oldPassword || ''), row.pass_hash)) throw new HttpError(401, 'Contraseña actual incorrecta')
if (String(body.newPassword).length < 6) throw new HttpError(400, 'La contraseña debe tener al menos 6 caracteres')
db.prepare('UPDATE users SET pass_hash = ? WHERE id = ?').run(hashPassword(String(body.newPassword)), user.id)
}
return json(res, 200, { ok: true })
}
if (p === '/api/img' && method === 'GET') return proxyImage(req, res, q.get('u') || '', q.get('s'))
const lang = LANG.test(q.get('lang') || '') ? q.get('lang') : user.lang
if (p === '/api/categories' && method === 'GET') {
return json(res, 200, q.get('source') === 'lc' ? await lcCategories() : 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') === '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 e
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 === 'lc' || offline ? [] : await getChapterLangs(m[1]).catch(() => [])
return json(res, 200, { manga, chapters, lang, reads, progress, chapterLangs, offline, downloaded, download: downloadInfo(m[1], true), ...features.mangaExtras(user.id, manga) })
}
if ((m = /^\/api\/chapter\/([^/]+)$/.exec(p)) && CHAPTER_ID.test(m[1]) && method === 'GET') {
const local = localChapter(m[1])
const pages = local
? local.entries.map((_, i) => `/api/local/${m[1]}/${i}`)
: m[1].startsWith('lc~') ? await lcPages(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 })
}
if ((m = /^\/api\/local\/([^/]+)\/(\d+)$/.exec(p)) && CHAPTER_ID.test(m[1]) && method === 'GET') {
const local = localChapter(m[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')
res.writeHead(200, { 'Content-Type': imageType(file), 'Cache-Control': 'private, max-age=86400' })
return fs.createReadStream(file).pipe(res)
}
// --- Descargas ---
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 FROM downloads ORDER BY updated_at DESC').all()
.map(d => ({ ...withCurrent(d), size: dirSize(mangaDirOf(d.folder)) })))
}
if ((m = /^\/api\/download\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'GET') {
return json(res, 200, downloadInfo(m[1]))
}
if ((m = /^\/api\/download\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'POST') {
const b = await readBody(req)
const dlLang = m[1].startsWith('lc~') ? 'es' : LANG.test(b.lang || '') ? b.lang : user.lang
const title = String(b.title || '').slice(0, 300) || (await fetchMangaFull(m[1], dlLang)).manga.title
enqueueDownload(m[1], title, dlLang)
return json(res, 200, downloadInfo(m[1]))
}
if ((m = /^\/api\/download\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'DELETE') {
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') {
return json(res, 200, db.prepare('SELECT * FROM progress WHERE user_id = ? ORDER BY updated_at DESC LIMIT 200').all(user.id))
}
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(b.mangaId.startsWith('lc~') ? 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 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)
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`)
.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)
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)
features.onProgress(user.id, b, page, total, prevPage)
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 features = setupFeatures({ db, fetchMangaFull, enqueueDownload, downloadInfo, HttpError, json, readBody, 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(imgUrl(u), 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
if (status >= 500) console.error(req.method, req.url, err)
if (!res.headersSent) json(res, status, { error: 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
})