- Historial pasa a Librería: pestañas Leyendo, Siguiendo, Terminados, En el dispositivo y listas propias (Favoritos, Pendientes, Abandonados por defecto) - Recomendaciones en el inicio según tus géneros más leídos (populares de MangaDex) - Descarga anticipada de los N capítulos siguientes (opcionalmente en HD) al pasar la mitad de uno - PWA: manifest, iconos y service worker; capítulos guardados en el dispositivo para leer sin conexión, progreso offline sincronizado después - Seguridad: baneo de IP tras 6 intentos fallidos (configurable), IP real detrás de proxies de confianza, CSP y cabeceras, cookie Secure/HSTS en HTTPS, login sin diferencias de tiempo, registro cerrado por defecto, contraseñas de 8+, protección SSRF en el proxy de imágenes, errores 500 genéricos - Ajustes: sección Seguridad con IPs bloqueadas, intentos recientes y la IP vista por el servidor - LeerCapitulo: reintentos ante caídas (522/5xx), copia anterior si sigue caído y mensaje claro Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
999 lines
51 KiB
JavaScript
999 lines
51 KiB
JavaScript
// 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'
|
||
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'
|
||
|
||
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
|
||
)`)
|
||
// 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 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
|
||
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)}`
|
||
|
||
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
|
||
? 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 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 () => {
|
||
// Cloudflare de LeerCapitulo da 52x/503 a ratos cuando su servidor no responde: hasta 3 intentos.
|
||
for (let i = 0; ; i++) {
|
||
const res = await fetch(LC_BASE + pathname, { headers: { 'User-Agent': BROWSER_UA, 'Accept-Language': 'es' }, signal: AbortSignal.timeout(30e3) })
|
||
.catch(() => 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)
|
||
|
||
// 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).
|
||
async function proxyImage(req, res, u, s, isCover = false, coverish = 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')
|
||
if (isCover && covers.sendCached(res, u)) return
|
||
const r = await fetchImage(url)
|
||
if (!r.ok || !r.body) throw new HttpError(502, `Imagen ${r.status}`)
|
||
if (isCover) {
|
||
const buf = Buffer.from(await r.arrayBuffer())
|
||
res.writeHead(200, { 'Content-Type': r.headers.get('content-type') || 'image/jpeg', 'Cache-Control': covers.pendingCache() })
|
||
res.end(buf)
|
||
return covers.enqueue(u, buf)
|
||
}
|
||
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()
|
||
}
|
||
|
||
function fetchImage(url, signal) {
|
||
const isMd = /(^|\.)mangadex\.(org|network)$/.test(new URL(url).hostname)
|
||
return safeFetch(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 => ({ '<': '<', '>': '>', '&': '&', '"': '"', "'": ''' }[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())
|
||
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)
|
||
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
|
||
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
|
||
requeue.delete(dl.manga_id)
|
||
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 })
|
||
}
|
||
// 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
|
||
}
|
||
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-]+)$/
|
||
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})?$/
|
||
// 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' }
|
||
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 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)
|
||
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) return
|
||
if (p.startsWith('/api/clips') && (await clips.route(p, method, q, req, res, user)) !== 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.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') return proxyImage(req, res, q.get('u') || '', q.get('s'), MANGA_ID.test(q.get('m') || '') && tracked(user.id, q.get('m')), q.has('m'))
|
||
|
||
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), upscale: upscale.info(m[1]), clips: clips.count(user.id, m[1]), lists: library.listsWith(user.id, m[1]), ...features.mangaExtras(user.id, manga) })
|
||
}
|
||
if ((m = /^\/api\/chapter\/([^/]+)$/.exec(p)) && CHAPTER_ID.test(m[1]) && method === 'GET') {
|
||
const hdAvailable = !!upscale.hdFileOf(m[1])
|
||
const hd = hdAvailable && q.get('hd') !== '0'
|
||
const local = localChapter(m[1], hd)
|
||
const pages = local
|
||
? local.entries.map((_, i) => `/api/local/${m[1]}/${i}${hd ? '?hd=1' : ''}`)
|
||
: 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, 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/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') ORDER BY status = 'downloading' DESC, created_at`).all()
|
||
.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()
|
||
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 ORDER BY updated_at DESC`).all()
|
||
.map(({ meta_cover, ...d }) => ({ ...withCurrent(d), size: dirSize(mangaDirOf(d.folder)), cover: downloadCover(d.manga_id, d.folder, meta_cover) })))
|
||
}
|
||
if ((m = /^\/api\/download\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'GET') {
|
||
return json(res, 200, downloadInfo(m[1], true))
|
||
}
|
||
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
|
||
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)
|
||
return json(res, 200, downloadInfo(m[1], true))
|
||
}
|
||
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') {
|
||
// 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(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 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)
|
||
features.onProgress(user.id, b, page, total, prevPage)
|
||
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 features = setupFeatures({ db, fetchMangaFull, enqueueDownload, downloadInfo, HttpError, json, readBody, MANGA_ID })
|
||
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, upscale, stats: features.stats, searchManga, mdCategories })
|
||
const clips = setupClips({ db, DATA_DIR, HttpError, json, readBody, MANGA_ID, CHAPTER_ID, upscale })
|
||
|
||
// 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
|
||
if (status >= 500) 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
|
||
})
|