// Manga Reader: lector web con fuentes MangaDex, LeerCapitulo y M440 // con usuarios y historial de lectura persistente en SQLite. import http from 'node:http' import fs from 'node:fs' import path from 'node:path' import crypto from 'node:crypto' import { DatabaseSync } from 'node:sqlite' import { writeCbz, readCbzIndex, sendCbzEntry, imageType } from './cbz.js' import { setupFeatures } from './features.js' import { setupUpscale } from './upscale.js' import { setupClips } from './clips.js' import { setupCovers } from './covers.js' import { setupLibrary } from './library.js' import { setupSecurity, safeFetch } from './security.js' import { setupM440, MI_BASE } from './m440.js' import { setupConnectivity } from './connectivity.js' import { setupMeta } from './meta.js' import { setupBooks, isBookId } from './books.js' const PORT = Number(process.env.PORT || 3352) const DATA_DIR = process.env.DATA_DIR || '/app/data' const MANGA_DIR = process.env.MANGA_DIR || '/mangas' const ALLOW_SIGNUP = process.env.ALLOW_SIGNUP === 'true' // cerrado salvo que se pida (el primer usuario siempre puede) const PUBLIC_DIR = path.resolve(import.meta.dirname, 'public') const MD_API = 'https://api.mangadex.org' const LC_BASE = 'https://www.leercapitulo.co' const BROWSER_UA = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0 Safari/537.36' const UA = 'manga-reader-selfhosted/1.0' const SESSION_DAYS = 120 // ---------- Base de datos ---------- // Comprobación de permisos al arrancar: con un UID distinto en el host, el error de SQLite sería críptico. function checkWritable(dir, fatal) { try { fs.mkdirSync(dir, { recursive: true }) const probe = path.join(dir, `.write-test-${process.pid}`) fs.writeFileSync(probe, '') fs.rmSync(probe) return true } catch (e) { const uid = process.getuid?.(), gid = process.getgid?.() console.error(`${fatal ? 'ERROR' : 'AVISO'}: no se puede escribir en ${dir} (${e.code || e.message}) como uid ${uid}:${gid}. Solución: en el host, "sudo chown -R ${uid}:${gid} " o pon PUID/PGID en .env con el dueño de la carpeta (id -u / id -g).`) if (fatal) process.exit(1) return false } } checkWritable(DATA_DIR, true) checkWritable(MANGA_DIR, false) // sin esto solo fallan las descargas const db = new DatabaseSync(path.join(DATA_DIR, 'manga.db')) const SECRET_FILE = path.join(DATA_DIR, 'secret') if (!fs.existsSync(SECRET_FILE)) fs.writeFileSync(SECRET_FILE, crypto.randomBytes(32).toString('hex'), { mode: 0o600 }) const SECRET = fs.readFileSync(SECRET_FILE, 'utf8').trim() db.exec(` PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON; CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE NOT NULL COLLATE NOCASE, pass_hash TEXT NOT NULL, lang TEXT NOT NULL DEFAULT 'es-la', created_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS sessions ( token TEXT PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, expires_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS progress ( user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, manga_id TEXT NOT NULL, manga_title TEXT NOT NULL, cover TEXT, chapter_id TEXT NOT NULL, chapter_num TEXT, chapter_title TEXT, lang TEXT, page INTEGER NOT NULL DEFAULT 0, total_pages INTEGER NOT NULL DEFAULT 0, updated_at INTEGER NOT NULL, PRIMARY KEY (user_id, manga_id) ); CREATE TABLE IF NOT EXISTS chapter_reads ( user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, chapter_id TEXT NOT NULL, manga_id TEXT NOT NULL, page INTEGER NOT NULL DEFAULT 0, total_pages INTEGER NOT NULL DEFAULT 0, updated_at INTEGER NOT NULL, PRIMARY KEY (user_id, chapter_id) ); CREATE INDEX IF NOT EXISTS idx_reads_manga ON chapter_reads(user_id, manga_id); CREATE TABLE IF NOT EXISTS downloads ( manga_id TEXT PRIMARY KEY, title TEXT NOT NULL, folder TEXT NOT NULL UNIQUE, lang TEXT, status TEXT NOT NULL, -- queued | downloading | done | error | cancelled done INTEGER NOT NULL DEFAULT 0, total INTEGER NOT NULL DEFAULT 0, failed INTEGER NOT NULL DEFAULT 0, error TEXT, meta TEXT, -- JSON de la ficha, para abrirla sin conexión created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS downloaded_chapters ( chapter_id TEXT PRIMARY KEY, manga_id TEXT NOT NULL REFERENCES downloads(manga_id) ON DELETE CASCADE, num TEXT, title TEXT, file TEXT NOT NULL, pages INTEGER NOT NULL, created_at INTEGER NOT NULL ); CREATE INDEX IF NOT EXISTS idx_dlch_manga ON downloaded_chapters(manga_id); `) db.prepare('DELETE FROM sessions WHERE expires_at < ?').run(Date.now()) // Descargas parciales: scope 'some' baja solo los capítulos pedidos (download_wanted); 'all', el manga entero. if (!db.prepare('PRAGMA table_info(downloads)').all().some(c => c.name === 'scope')) db.exec("ALTER TABLE downloads ADD COLUMN scope TEXT NOT NULL DEFAULT 'all'") db.exec(`CREATE TABLE IF NOT EXISTS download_wanted ( chapter_id TEXT PRIMARY KEY, manga_id TEXT NOT NULL REFERENCES downloads(manga_id) ON DELETE CASCADE, created_at INTEGER NOT NULL )`) // Usuarios de cada manga descargado: quién lo pidió (descarga, reescalado, descarga anticipada) o lo // empezó a leer ya descargado. Colas y "Biblioteca descargada" muestran solo lo de cada usuario; los // archivos son uno para todos, y se borran cuando ya no lo tiene nadie. db.exec(`CREATE TABLE IF NOT EXISTS download_users ( user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, manga_id TEXT NOT NULL, created_at INTEGER NOT NULL, PRIMARY KEY (user_id, manga_id) )`) const linkDownload = (userId, mangaId) => db.prepare('INSERT OR IGNORE INTO download_users (user_id, manga_id, created_at) VALUES (?, ?, ?)').run(userId, mangaId, Date.now()) const sharedWith = (userId, mangaId) => !!db.prepare('SELECT 1 FROM download_users WHERE manga_id = ? AND user_id != ?').get(mangaId, userId) const MY_DOWNLOADS = 'manga_id IN (SELECT manga_id FROM download_users WHERE user_id = ?)' // finished_at: cuándo se terminó la última página del último capítulo disponible (null = en curso). if (!db.prepare('PRAGMA table_info(progress)').all().some(c => c.name === 'finished_at')) db.exec('ALTER TABLE progress ADD COLUMN finished_at INTEGER') // ---------- Contraseñas y sesiones ---------- function hashPassword(pw) { const salt = crypto.randomBytes(16) const hash = crypto.scryptSync(pw, salt, 64) return `${salt.toString('hex')}:${hash.toString('hex')}` } function verifyPassword(pw, stored) { const [saltHex, hashHex] = stored.split(':') const hash = crypto.scryptSync(pw, Buffer.from(saltHex, 'hex'), 64) return crypto.timingSafeEqual(hash, Buffer.from(hashHex, 'hex')) } function createSession(userId) { const token = crypto.randomBytes(32).toString('base64url') db.prepare('INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)') .run(token, userId, Date.now() + SESSION_DAYS * 86400e3) return token } function sessionCookie(token, maxAge, secure = false) { return `sid=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAge}${secure ? '; Secure' : ''}` } // Hash de una contraseña cualquiera para que un usuario inexistente tarde lo mismo que uno real. const DUMMY_HASH = hashPassword(crypto.randomBytes(16).toString('hex')) function getUser(req) { const m = /(?:^|;\s*)sid=([^;]+)/.exec(req.headers.cookie || '') if (!m) return null return db.prepare(`SELECT u.id, u.username, u.lang, u.content_mode FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.token = ? AND s.expires_at > ?`).get(m[1], Date.now()) ?? null } if (!db.prepare('PRAGMA table_info(users)').all().some(c => c.name === 'content_mode')) db.exec("ALTER TABLE users ADD COLUMN content_mode TEXT NOT NULL DEFAULT 'both'") // ---------- Cliente MangaDex (adaptado de Tanko src/servers/mangadex.ts) ---------- const cache = new Map() async function cached(key, ttl, fn) { const hit = cache.get(key) if (hit && hit.exp > Date.now()) return hit.data let data try { data = await fn() } catch (e) { // Fuente caída (p. ej. 522 de Cloudflare en LeerCapitulo): mejor lo último que se tenía que un error. if (hit && (e.status ?? 500) >= 500) { console.warn(`Usando copia anterior de ${key}: ${e.message}`); return hit.data } throw e } cache.delete(key) cache.set(key, { data, exp: Date.now() + ttl }) if (cache.size > 500) cache.delete(cache.keys().next().value) return data } // Las imágenes pasan por /api/img firmadas con HMAC: el proxy solo descarga URLs generadas por este servidor. const sign = u => crypto.createHmac('sha256', SECRET).update(u).digest('base64url').slice(0, 22) const imgUrl = u => `/api/img?u=${encodeURIComponent(u)}&s=${sign(u)}` // Portadas: &m= deja al proxy servir (y generar) la versión reescalada si el manga está en el // historial o en seguidos del usuario; las demás (búsquedas) se sirven tal cual. const coverUrl = (u, mangaId) => `${imgUrl(u)}&m=${encodeURIComponent(mangaId)}` // Conexión a internet del servidor (app/connectivity.js). Sin ella, las fuentes fallan al instante. const net = setupConnectivity({ httpError: (status, message) => new HttpError(status, message) }) async function md(pathname, params = {}, ttl = 5 * 60e3) { const url = new URL(MD_API + pathname) for (const [k, v] of Object.entries(params)) { if (Array.isArray(v)) v.forEach(x => url.searchParams.append(k, x)) else if (v !== undefined && v !== '') url.searchParams.set(k, v) } return cached(url.toString(), ttl, async () => { net.guard() const res = await fetch(url, { headers: { 'User-Agent': UA }, signal: AbortSignal.timeout(30e3) }).catch(() => { net.failed(); throw new HttpError(502, 'No se pudo conectar con MangaDex') }) if (!res.ok) throw new HttpError(502, `MangaDex respondió ${res.status}`) return res.json() }) } const pick = (obj, langs) => { if (!obj) return '' for (const l of langs) if (obj[l]) return obj[l] return Object.values(obj)[0] ?? '' } function mangaSummary(m, lang) { const a = m.attributes const langs = [lang, 'es', 'es-la', 'en', 'ja-ro'] let title = a.title.en ?? Object.values(a.title)[0] const alt = (a.altTitles || []).find(t => t[lang] || t.es || t['es-la']) if (alt) title = pick(alt, [lang, 'es-la', 'es']) const coverRel = m.relationships?.find(r => r.type === 'cover_art') const cover = coverRel?.attributes?.fileName ? coverUrl(`https://uploads.mangadex.org/covers/${m.id}/${coverRel.attributes.fileName}.512.jpg`, m.id) : null return { id: m.id, source: 'md', title, description: pick(a.description, langs), status: a.status, year: a.year, tags: (a.tags || []).map(t => ({ id: t.id, name: pick(t.attributes.name, ['es', 'en']) })).slice(0, 12), authors: (m.relationships || []).filter(r => r.type === 'author' && r.attributes).map(r => r.attributes.name), languages: a.availableTranslatedLanguages || [], type: { ja: 'Manga', ko: 'Manhwa', zh: 'Manhua', 'zh-hk': 'Manhua' }[a.originalLanguage] || 'Cómic', cover, } } const RATINGS = ['safe', 'suggestive'] const PAGE_SIZE = 30 async function searchManga(q, lang, mode, tag, page = 1) { const params = { limit: PAGE_SIZE, offset: (page - 1) * PAGE_SIZE, 'includedTags[]': tag ? [tag] : undefined, 'includes[]': ['cover_art'], 'contentRating[]': RATINGS, 'availableTranslatedLanguage[]': lang ? [lang] : undefined, } if (q) { params.title = q; params['order[relevance]'] = 'desc' } else if (mode === 'latest') params['order[latestUploadedChapter]'] = 'desc' else params['order[followedCount]'] = 'desc' const data = await md('/manga', params) return data.data.map(m => mangaSummary(m, lang)) } const MD_GROUPS = { genre: 'Géneros', theme: 'Temáticas', format: 'Formato', content: 'Contenido' } async function mdCategories() { const data = await md('/manga/tag', {}, 24 * 3600e3) return data.data .map(t => ({ id: t.id, name: pick(t.attributes.name, ['es', 'en']), group: MD_GROUPS[t.attributes.group] || t.attributes.group })) .sort((a, b) => a.group.localeCompare(b.group) || a.name.localeCompare(b.name)) } async function getManga(id, lang) { const data = await md(`/manga/${id}`, { 'includes[]': ['cover_art', 'author'] }, 30 * 60e3) return mangaSummary(data.data, lang) } // Lista de capítulos en un idioma, un registro por número de capítulo (como Tanko). // El español junta es-la y es, prefiriendo la variante elegida. async function getChapters(mangaId, lang) { const langs = lang === 'es-la' ? ['es-la', 'es'] : lang === 'es' ? ['es', 'es-la'] : [lang] const all = [] for (let offset = 0; ; offset += 500) { const data = await md(`/manga/${mangaId}/feed`, { limit: 500, offset, 'translatedLanguage[]': langs, 'order[volume]': 'asc', 'order[chapter]': 'asc', 'contentRating[]': [...RATINGS, 'erotica'], includeExternalUrl: 0, includeEmptyPages: 0, includeFuturePublishAt: 0, 'includes[]': ['scanlation_group'], }) all.push(...data.data) if (offset + 500 >= data.total || data.data.length === 0) break } const byNum = new Map() for (const c of all) { const num = c.attributes.chapter ?? `oneshot-${c.id}` const prev = byNum.get(num) if (prev && langs.indexOf(prev.lang) <= langs.indexOf(c.attributes.translatedLanguage)) continue byNum.set(num, { id: c.id, num: c.attributes.chapter, volume: c.attributes.volume, title: c.attributes.title || '', pages: c.attributes.pages, group: c.relationships.find(r => r.type === 'scanlation_group')?.attributes?.name || '', publishAt: c.attributes.publishAt, lang: c.attributes.translatedLanguage, }) } const out = [...byNum.values()] out.sort((a, b) => (parseFloat(a.num) || 0) - (parseFloat(b.num) || 0)) return out } // Idiomas con capítulos legibles de verdad (availableTranslatedLanguages incluye capítulos retirados o externos). async function getChapterLangs(mangaId) { const data = await md(`/manga/${mangaId}/feed`, { limit: 500, includeExternalUrl: 0, includeEmptyPages: 0, includeFuturePublishAt: 0, 'contentRating[]': [...RATINGS, 'erotica'], }, 30 * 60e3) const count = {} for (const c of data.data) count[c.attributes.translatedLanguage] = (count[c.attributes.translatedLanguage] || 0) + 1 return Object.entries(count).sort((a, b) => b[1] - a[1]).map(([l]) => l) } async function getPageUrls(chapterId, saver, ttl = 5 * 60e3) { // Las URLs de at-home caducan a los ~15 min: caché corta. const data = await md(`/at-home/server/${chapterId}`, {}, ttl) const { baseUrl, chapter } = data const files = saver ? chapter.dataSaver : chapter.data const kind = saver ? 'data-saver' : 'data' return files.map(f => `${baseUrl}/${kind}/${chapter.hash}/${f}`) } const getPages = async (chapterId, saver) => (await getPageUrls(chapterId, saver)).map(imgUrl) // ---------- Cliente LeerCapitulo (Tanko src/servers/leerCapitulo.ts, adaptado al sitio actual) ---------- // IDs: manga "lc~~", capítulo "lc~~~". Ya no hace falta navegador: // las páginas del capítulo vienen en el HTML como . const ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', lsaquo: '‹', rsaquo: '›', laquo: '«', raquo: '»', iexcl: '¡', iquest: '¿', hellip: '…', mdash: '—', ndash: '–', ldquo: '“', rdquo: '”', lsquo: '‘', rsquo: '’', middot: '·', deg: '°', copy: '©', reg: '®', trade: '™', aacute: 'á', eacute: 'é', iacute: 'í', oacute: 'ó', uacute: 'ú', ntilde: 'ñ', uuml: 'ü', ccedil: 'ç', agrave: 'à', egrave: 'è', ograve: 'ò', Aacute: 'Á', Eacute: 'É', Iacute: 'Í', Oacute: 'Ó', Uacute: 'Ú', Ntilde: 'Ñ', Uuml: 'Ü', Ccedil: 'Ç', } const decode = s => String(s ?? '').replace(/<[^>]+>/g, '').replace(/&(#x?[0-9a-f]+|[a-z]+);/gi, (all, e) => { if (e[0] !== '#') return ENTITIES[e] ?? ENTITIES[e.toLowerCase()] ?? all const n = e[1].toLowerCase() === 'x' ? parseInt(e.slice(2), 16) : parseInt(e.slice(1), 10) return n > 0 && n <= 0x10ffff ? String.fromCodePoint(n) : all }).trim() async function lc(pathname, ttl = 10 * 60e3, asJson = false) { return cached(`lc:${pathname}`, ttl, async () => { // Cloudflare de LeerCapitulo da 52x/503 a ratos cuando su servidor no responde: hasta 3 intentos. for (let i = 0; ; i++) { net.guard() const res = await fetch(LC_BASE + pathname, { headers: { 'User-Agent': BROWSER_UA, 'Accept-Language': 'es' }, signal: AbortSignal.timeout(30e3) }) .catch(() => { net.failed(); return null }) if (res?.ok) return asJson ? res.json() : res.text() if (res?.status === 404) throw new HttpError(404, 'LeerCapitulo respondió 404') const down = !res || res.status >= 500 if (!down || i >= 2) { throw new HttpError(502, down ? `LeerCapitulo no responde ahora mismo${res ? ` (error ${res.status} de su servidor)` : ''}. Prueba en un rato.` : `LeerCapitulo respondió ${res.status}`) } await new Promise(r => setTimeout(r, 1500 * (i + 1))) } }) } const lcCover = (src, id) => (src ? coverUrl(new URL(src, LC_BASE).toString(), id) : null) const lcId = uri => { const m = /\/manga\/([a-z0-9]+)\/([a-z0-9-]+)\//.exec(uri || '') return m ? `lc~${m[1]}~${m[2]}` : null } function lcParseCards(html) { const re = /class="lc-(?:slide|side-item|release|card)(?:-cover)?" href="(\/manga\/[a-z0-9]+\/[a-z0-9-]+\/)"[^>]*>\s*]*>([^<]+)/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-]+)"[^>]*>([^<]+) 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(''))) } 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('

Ultimos capitulos', 'Tendencias', '')), ...lcParseCards(section('>Populares

', '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(`${k}\\s*<(?:a|span)[^>]*>([^<]*)<`).exec(html)?.[1]) const sinopsis = /id="sinopsis"[\s\S]*?]*>([\s\S]*?)<\/p>/.exec(html)?.[1] const chapters = [] const seen = new Set() const re = new RegExp(`href="/leer/${mid}/${slug}/([0-9a-z.-]+)/">\\s*([^<]*)[\\s\\S]*?([^<]*)`, '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(/]*>([^<]+)/.exec(html)?.[1]) || slug, description: decode(sinopsis?.replace(//g, '\n')), status: fact('Estado'), type: fact('Tipo') || null, year: null, tags: [...html.matchAll(/class="badge [^"]*" href="\/manga\/\?(genre|theme)=([a-z0-9-]+)">([^<]+) ({ 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* decode(m[1])) if (!pages.length) throw new HttpError(502, 'No se encontraron páginas en LeerCapitulo') return pages } const lcPages = async chapterId => (await lcPageUrls(chapterId)).map(imgUrl) // ---------- M440 (app/m440.js) ---------- const mi = setupM440({ cached, httpError: (status, message) => new HttpError(status, message), coverUrl, decode, BROWSER_UA, net }) const miPages = async chapterId => (await mi.pageUrls(chapterId)).map(imgUrl) // Fuente según el ID: 'lc' y 'mi' son sitios en español; lo demás es MangaDex. const sourceOf = id => (id.startsWith('lc~') ? 'lc' : id.startsWith('mi~') ? 'mi' : /^(gb|ws|ub)~/.test(id) ? id.slice(0, 2) : 'md') const spanishOnly = id => sourceOf(id) !== 'md' // Proxy de imágenes: MangaDex bloquea el hotlinking de portadas y así el navegador no depende de CDNs ajenos. // isCover: portada a servir en HD; coverish: portada sin HD (caché corta por si luego se sigue el manga); // banner: imagen de fondo de la ficha, siempre en HD. async function proxyImage(req, res, u, s, isCover = false, coverish = false, banner = false) { let url try { url = new URL(u) } catch { throw new HttpError(400, 'URL inválida') } const expected = sign(u) if (typeof s !== 'string' || s.length !== expected.length || !crypto.timingSafeEqual(Buffer.from(s), Buffer.from(expected))) throw new HttpError(403, 'Firma inválida') if (!/^https?:$/.test(url.protocol) || /^(localhost|\d+\.\d+\.\d+\.\d+|\[.*\])$/.test(url.hostname)) throw new HttpError(403, 'Host no permitido') const hdKey = banner ? bannerKey(u) : isCover ? u : null if (hdKey && covers.sendCached(res, hdKey)) return // Los CDN de las fuentes fallan a ratos (520, conexión cortada, respuestas que no llegan): hasta 3 // intentos con tiempo límite, para no dejar al navegador esperando para siempre. net.guard() // sin internet: fallar ya (la portada local entra como respaldo), sin reintentos const r = await withRetry(async () => { const r = await fetchImage(url, AbortSignal.timeout(60e3)) if (r.status >= 500) throw new HttpError(502, `Imagen ${r.status}`) return r }).catch(e => { throw e instanceof HttpError ? e : new HttpError(502, `Imagen: ${e.message}`) }) if (!r.ok || !r.body) throw new HttpError(502, `Imagen ${r.status}`) if (hdKey) { const buf = Buffer.from(await r.arrayBuffer()) res.writeHead(200, { 'Content-Type': r.headers.get('content-type') || 'image/jpeg', 'Cache-Control': covers.pendingCache(banner) }) res.end(buf) return covers.enqueue(hdKey, buf, banner) } res.writeHead(200, { 'Content-Type': r.headers.get('content-type') || 'image/jpeg', 'Cache-Control': coverish ? 'private, max-age=3600' : 'private, max-age=604800, immutable', }) for await (const chunk of r.body) res.write(chunk) res.end() } const bannerKey = u => `banner:${u}` // Reescala un banner sin esperar a que alguien abra la ficha (recibe la URL del proxy, como se guarda). async function prepareBanner(proxyUrl) { const u = rawFromProxy(proxyUrl) if (!u || !covers.wants(bannerKey(u))) return const r = await fetchImage(new URL(u), AbortSignal.timeout(60e3)) if (r.ok) covers.enqueue(bannerKey(u), Buffer.from(await r.arrayBuffer()), true) } function fetchImage(url, signal) { net.guard() const host = new URL(url).hostname const isMd = /(^|\.)mangadex\.(org|network)$/.test(host) const referer = isMd ? null : /(^|\.)m440\.in$/.test(host) ? MI_BASE + '/' : LC_BASE + '/' return safeFetch(url, { signal, headers: { 'User-Agent': isMd ? UA : BROWSER_UA, ...(referer ? { Referer: referer } : {}) } }) .catch(e => { if (e.message !== 'Host no permitido') net.failed(); throw e }) } // ---------- Descargas a MANGA_DIR//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) { const src = sourceOf(mangaId) return src === 'lc' ? lcManga(mangaId) : src === 'mi' ? mi.manga(mangaId) : isBookId(mangaId) ? books.manga(mangaId) : { manga: await getManga(mangaId, lang), chapters: await getChapters(mangaId, lang) } } // Progreso del capítulo en curso (solo en memoria): manga_id -> { chapter, pages, pagesTotal } const dlCurrent = new Map() async function downloadChapter(dl, manga, c, dir) { dlCurrent.set(dl.manga_id, { chapter: c.num, pages: 0, pagesTotal: 0 }) const urls = await withRetry(() => (manga.source === 'lc' ? lcPageUrls(c.id) : manga.source === 'mi' ? mi.pageUrls(c.id) : getPageUrls(c.id, false, 0))) dlCurrent.set(dl.manga_id, { chapter: c.num, pages: 0, pagesTotal: urls.length }) const entries = [] // 4 imágenes en paralelo. for (let i = 0; i < urls.length; i += 4) { const batch = await Promise.all(urls.slice(i, i + 4).map(downloadBuffer)) batch.forEach(({ data, type }, k) => { const n = i + k const ext = EXT_BY_TYPE[type.split(';')[0]] || urls[n].split('?')[0].split('.').pop().toLowerCase().replace(/[^a-z]/g, '') || 'jpg' entries.push({ name: `${String(n + 1).padStart(3, '0')}.${ext}`, data }) }) dlCurrent.set(dl.manga_id, { chapter: c.num, pages: entries.length, pagesTotal: urls.length }) if (isCancelled(dl.manga_id)) throw new Error('cancelada') } entries.push({ name: 'ComicInfo.xml', data: Buffer.from(` ${xmlEsc(manga.title)} ${c.num ? `${xmlEsc(c.num)}` : ''} ${c.volume ? `${xmlEsc(c.volume)}` : ''} ${c.title ? `${xmlEsc(c.title)}` : ''} ${xmlEsc(manga.description)} ${xmlEsc(manga.authors.join(', '))} ${xmlEsc(manga.tags.map(t => t.name ?? t).join(', '))} ${xmlEsc((dl.lang || 'es').split('-')[0])} ${urls.length} YesAndRightToLeft `) }) const file = chapterFileName(c) writeCbz(path.join(dir, file), entries) if (!db.prepare('SELECT 1 FROM downloads WHERE manga_id = ?').get(dl.manga_id)) { // La copia local se eliminó mientras se descargaba este capítulo. fs.rmSync(path.join(dir, file), { force: true }) throw new Error('eliminada') } db.prepare(`INSERT OR REPLACE INTO downloaded_chapters (chapter_id, manga_id, num, title, file, pages, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`) .run(c.id, dl.manga_id, c.num ?? null, c.title || '', file, urls.length, Date.now()) db.prepare('DELETE FROM download_wanted WHERE chapter_id = ?').run(c.id) upscale.onChapterDownloaded(dl.manga_id, c.id) } const dlUpdate = (mangaId, fields) => { const keys = Object.keys(fields) db.prepare(`UPDATE downloads SET ${keys.map(k => `${k} = ?`).join(', ')}, updated_at = ? WHERE manga_id = ?`) .run(...keys.map(k => fields[k]), Date.now(), mangaId) } // Cancelada o eliminada (sin fila) cuentan igual: hay que parar. const isCancelled = mangaId => (db.prepare('SELECT status FROM downloads WHERE manga_id = ?').get(mangaId)?.status ?? 'cancelled') === 'cancelled' function mangaDirOf(folder) { const dir = path.resolve(MANGA_DIR, folder) if (!folder || !dir.startsWith(path.resolve(MANGA_DIR) + path.sep)) throw new HttpError(400, 'Carpeta inválida') return dir } function dirSize(dir) { let total = 0 try { for (const f of fs.readdirSync(dir, { withFileTypes: true })) total += f.isDirectory() ? dirSize(path.join(dir, f.name)) : fs.statSync(path.join(dir, f.name)).size } catch {} return total } function deleteDownload(mangaId) { const dl = db.prepare('SELECT folder FROM downloads WHERE manga_id = ?').get(mangaId) if (!dl) return false const dir = mangaDirOf(dl.folder) upscale.cancel(mangaId) db.prepare('DELETE FROM downloads WHERE manga_id = ?').run(mangaId) // borra también downloaded_chapters (CASCADE) db.prepare('DELETE FROM download_users WHERE manga_id = ?').run(mangaId) dlCurrent.delete(mangaId) fs.rmSync(dir, { recursive: true, force: true }) return true } async function runDownload(dl) { const { manga, chapters } = await fetchMangaFull(dl.manga_id, dl.lang) const dir = path.join(MANGA_DIR, dl.folder) fs.mkdirSync(dir, { recursive: true }) const coverRaw = rawFromProxy(manga.cover) if (coverRaw && !fs.existsSync(path.join(dir, 'cover.jpg'))) { try { fs.writeFileSync(path.join(dir, 'cover.jpg'), (await downloadBuffer(coverRaw)).data) } catch (e) { console.warn('Portada:', e.message) } } const have = new Set(db.prepare('SELECT chapter_id, file FROM downloaded_chapters WHERE manga_id = ?').all(dl.manga_id) .filter(r => fs.existsSync(path.join(dir, r.file))).map(r => r.chapter_id)) let pending = chapters.filter(c => !have.has(c.id)) let total = chapters.length if (dl.scope === 'some') { const wanted = new Set(db.prepare('SELECT chapter_id FROM download_wanted WHERE manga_id = ?').all(dl.manga_id).map(r => r.chapter_id)) pending = pending.filter(c => wanted.has(c.id)) total = have.size + pending.length } dlUpdate(dl.manga_id, { status: 'downloading', title: manga.title, meta: JSON.stringify({ manga, chapters }), total, done: total - pending.length, failed: 0, error: null }) let failed = 0 let lastError = null for (const c of pending) { if (isCancelled(dl.manga_id)) break try { await downloadChapter(dl, manga, c, dir) } catch (e) { if (isCancelled(dl.manga_id)) break // Se cayó internet: no cuenta como fallo; la descarga queda en cola y sigue al volver la conexión. if (!(await net.check())) { dlCurrent.delete(dl.manga_id) dlUpdate(dl.manga_id, { status: 'queued', error: null }) return } failed++ lastError = `Cap. ${c.num ?? '?'}: ${e.message}` console.warn('Descarga', manga.title, lastError) } const done = db.prepare('SELECT COUNT(*) n FROM downloaded_chapters WHERE manga_id = ?').get(dl.manga_id).n dlUpdate(dl.manga_id, { done, failed, error: lastError }) // MangaDex limita /at-home/server a 40 peticiones por minuto. await sleep(manga.source === 'md' ? 1600 : manga.source === 'mi' ? 1000 : 500) } dlCurrent.delete(dl.manga_id) if (isCancelled(dl.manga_id)) { if (!db.prepare('SELECT 1 FROM downloads WHERE manga_id = ?').get(dl.manga_id)) fs.rmSync(dir, { recursive: true, force: true }) return } dlUpdate(dl.manga_id, { status: failed ? 'error' : 'done', error: failed ? `${failed} capítulo(s) fallaron. Último error: ${lastError}` : null }) } let pumping = false async function pumpDownloads() { if (pumping) return pumping = true try { for (;;) { if (!net.online()) break // se reanuda con net.whenOnline const dl = db.prepare("SELECT * FROM downloads WHERE status IN ('downloading', 'queued') ORDER BY status = 'downloading' DESC, created_at LIMIT 1").get() if (!dl) break requeue.delete(dl.manga_id) try { await runDownload(dl) if (!net.online()) break } catch (e) { if (!net.online()) { dlUpdate(dl.manga_id, { status: 'queued', error: null }); break } console.error('Descarga fallida', dl.manga_id, e) if (!isCancelled(dl.manga_id)) dlUpdate(dl.manga_id, { status: 'error', error: e.message }) } // Pidieron más capítulos mientras se bajaba: otra vuelta. if (requeue.delete(dl.manga_id) && !isCancelled(dl.manga_id)) dlUpdate(dl.manga_id, { status: 'queued' }) } } finally { pumping = false } } // chapterIds: solo esos capítulos (si el manga ya se baja entero, no cambia nada); sin ellos, el manga entero. function enqueueDownload(mangaId, title, lang, chapterIds = null) { const existing = db.prepare('SELECT * FROM downloads WHERE manga_id = ?').get(mangaId) if (existing) { if (!chapterIds && existing.scope !== 'all') dlUpdate(mangaId, { scope: 'all' }) // Si ya se estaba descargando, runDownload no verá lo nuevo: se vuelve a encolar al acabar. if (!['queued', 'downloading'].includes(existing.status)) dlUpdate(mangaId, { status: 'queued', lang: lang || existing.lang, error: null }) else if (existing.status === 'downloading') requeue.add(mangaId) } else { let folder = safeName(title) for (let i = 2; db.prepare('SELECT 1 FROM downloads WHERE folder = ?').get(folder); i++) folder = `${safeName(title)} (${i})` const now = Date.now() db.prepare('INSERT INTO downloads (manga_id, title, folder, lang, status, scope, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)') .run(mangaId, title, folder, lang, 'queued', chapterIds ? 'some' : 'all', now, now) } if (chapterIds) { const ins = db.prepare('INSERT OR IGNORE INTO download_wanted (chapter_id, manga_id, created_at) VALUES (?, ?, ?)') const now = Date.now() for (const id of chapterIds) ins.run(id, mangaId, now) } pumpDownloads() } const requeue = new Set() const cbzIndexCache = new Map() // hd: usar la versión reescalada si existe. function localChapter(chapterId, hd = false) { const row = db.prepare(`SELECT c.file, c.hd_file, d.folder FROM downloaded_chapters c JOIN downloads d ON d.manga_id = c.manga_id WHERE c.chapter_id = ?`).get(chapterId) if (!row) return null const file = (hd && upscale.hdFileOf(chapterId)) || path.join(MANGA_DIR, row.folder, row.file) let st try { st = fs.statSync(file) } catch { return null } const key = `${file}:${st.mtimeMs}` if (!cbzIndexCache.has(key)) { cbzIndexCache.set(key, { file, entries: readCbzIndex(file) }) if (cbzIndexCache.size > 100) cbzIndexCache.delete(cbzIndexCache.keys().next().value) } return cbzIndexCache.get(key) } // Portada de un manga descargado: la cover.jpg de su carpeta, la de la ficha guardada o la del historial. // Las URLs del proxy se vuelven a firmar (con &m= para la versión HD) por si se guardaron con otra clave. function downloadCover(mangaId, folder, metaCover) { if (fs.existsSync(path.join(mangaDirOf(folder), 'cover.jpg'))) return `/api/local-cover/${mangaId}` const c = metaCover || db.prepare('SELECT cover FROM progress WHERE manga_id = ? AND cover IS NOT NULL UNION ALL SELECT cover FROM follows WHERE manga_id = ? AND cover IS NOT NULL LIMIT 1').get(mangaId, mangaId)?.cover const u = rawFromProxy(c) return u ? coverUrl(u, mangaId) : c || null } // Sin conexión: la búsqueda va sobre lo descargado en el servidor. const fold = s => String(s).toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '') function localSearch(text) { const words = fold(text).split(/\s+/).filter(Boolean) return db.prepare(`SELECT manga_id, title, folder, done, json_extract(meta, '$.manga.cover') meta_cover FROM downloads WHERE done > 0 ORDER BY updated_at DESC`).all() .filter(d => words.every(w => fold(d.title).includes(w))) .map(d => ({ id: d.manga_id, source: sourceOf(d.manga_id), title: d.title, cover: downloadCover(d.manga_id, d.folder, d.meta_cover), sub: `${d.done} cap. descargado${d.done === 1 ? '' : 's'}` })) } const withCurrent = d => (d ? { ...d, current: ['queued', 'downloading'].includes(d.status) ? dlCurrent.get(d.manga_id) ?? null : null } : null) const downloadInfo = (mangaId, size = false) => { const d = withCurrent(db.prepare('SELECT manga_id, title, folder, lang, status, scope, done, total, failed, error, updated_at FROM downloads WHERE manga_id = ?').get(mangaId)) if (d && size) d.size = dirSize(mangaDirOf(d.folder)) if (d) d.wanted = db.prepare('SELECT chapter_id FROM download_wanted WHERE manga_id = ?').all(mangaId).map(r => r.chapter_id) return d } // ---------- HTTP ---------- class HttpError extends Error { constructor(status, message) { super(message); this.status = status } } function json(res, status, body, headers = {}) { res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store', ...headers }) res.end(JSON.stringify(body)) } async function readBody(req) { let size = 0 const chunks = [] for await (const c of req) { size += c.length if (size > 64 * 1024) throw new HttpError(413, 'Cuerpo demasiado grande') chunks.push(c) } try { return JSON.parse(Buffer.concat(chunks).toString() || '{}') } catch { throw new HttpError(400, 'JSON inválido') } } const MANGA_ID = /^([0-9a-f-]{36}|lc~[a-z0-9]+~[a-z0-9-]+|mi~[a-z0-9._-]+|gb~\d{1,7}|ws~[A-Za-z0-9_-]{1,400}|ub~[0-9a-f]{32})$/ const CHAPTER_ID = /^([0-9a-f-]{36}|lc~[a-z0-9]+~[a-z0-9-]+~[0-9a-z.-]+|mi~[a-z0-9._-]+~[a-z0-9._-]+|gb~\d{1,7}~\d{1,5}|ws~[A-Za-z0-9_-]{1,400}~\d{1,5}|ub~[0-9a-f]{32}~\d{1,5})$/ const LANG = /^[a-z]{2}(-[a-z]{2})?$/ // Manga en el historial o en seguidos del usuario (sus portadas se reescalan). const tracked = (userId, mangaId) => !!db.prepare(`SELECT 1 FROM progress WHERE user_id = ? AND manga_id = ? UNION ALL SELECT 1 FROM follows WHERE user_id = ? AND manga_id = ? AND active = 1 LIMIT 1`).get(userId, mangaId, userId, mangaId) const requireUser = req => { const u = getUser(req) if (!u) throw new HttpError(401, 'No autenticado') return u } const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.svg': 'image/svg+xml', '.webmanifest': 'application/manifest+json', '.png': 'image/png' } // index.html enlaza app.js y style.css con ?v=: si un proxy delante cambia el // no-cache por una caché larga (Nginx Proxy Manager con «Cache Assets»), cada versión nueva tiene otra // URL y el navegador no se queda con la vieja. Los archivos no cambian mientras el contenedor vive. const assetVersion = name => crypto.createHash('sha256').update(fs.readFileSync(path.join(PUBLIC_DIR, name))).digest('hex').slice(0, 10) const INDEX_HTML = fs.readFileSync(path.join(PUBLIC_DIR, 'index.html'), 'utf8') .replace('src="/app.js"', `src="/app.js?v=${assetVersion('app.js')}"`) .replace('href="/style.css"', `href="/style.css?v=${assetVersion('style.css')}"`) .replace('', ``) function serveStatic(res, pathname) { let file = path.join(PUBLIC_DIR, path.normalize(pathname).replace(/^(\.\.[/\\])+/, '')) if (!file.startsWith(PUBLIC_DIR) || !fs.existsSync(file) || fs.statSync(file).isDirectory()) file = path.join(PUBLIC_DIR, 'index.html') res.writeHead(200, { 'Content-Type': MIME[path.extname(file)] || 'application/octet-stream', 'Cache-Control': 'no-cache' }) if (file === path.join(PUBLIC_DIR, 'index.html')) return res.end(INDEX_HTML) fs.createReadStream(file).pipe(res) } const loginAttempts = new Map() function rateLimit(ip) { const now = Date.now() const list = (loginAttempts.get(ip) || []).filter(t => now - t < 10 * 60e3) if (list.length >= 10) throw new HttpError(429, 'Demasiados intentos, espera unos minutos') list.push(now) loginAttempts.set(ip, list) } async function route(req, res) { const ip = security.clientIp(req) security.headers(req, res) if (security.banned(ip)) { res.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store' }) return res.end('Acceso bloqueado temporalmente por demasiados intentos fallidos.') } const url = new URL(req.url, 'http://x') const p = url.pathname const q = url.searchParams const method = req.method if (!p.startsWith('/api/')) return serveStatic(res, p) // --- Auth --- if (p === '/api/me' && method === 'GET') { const userCount = db.prepare('SELECT COUNT(*) n FROM users').get().n return json(res, 200, { user: getUser(req), signup: ALLOW_SIGNUP || userCount === 0 }) } if ((p === '/api/login' || p === '/api/register') && method === 'POST') { rateLimit(ip) const { username, password } = await readBody(req) if (typeof username !== 'string' || typeof password !== 'string') throw new HttpError(400, 'Datos incompletos') let user if (p === '/api/register') { const userCount = db.prepare('SELECT COUNT(*) n FROM users').get().n if (!ALLOW_SIGNUP && userCount > 0) { security.fail(ip, 'registro cerrado'); throw new HttpError(403, 'El registro está deshabilitado') } if (!/^[\w.-]{3,32}$/.test(username)) throw new HttpError(400, 'Usuario: 3-32 caracteres (letras, números, . _ -)') if (password.length < 8) throw new HttpError(400, 'La contraseña debe tener al menos 8 caracteres') if (db.prepare('SELECT 1 FROM users WHERE username = ?').get(username)) throw new HttpError(409, 'Ese usuario ya existe') const r = db.prepare('INSERT INTO users (username, pass_hash, created_at) VALUES (?, ?, ?)') .run(username, hashPassword(password), Date.now()) user = { id: Number(r.lastInsertRowid) } } else { const row = db.prepare('SELECT id, pass_hash FROM users WHERE username = ?').get(username.slice(0, 64)) if (!verifyPassword(password.slice(0, 256), row?.pass_hash || DUMMY_HASH) || !row) { security.fail(ip, 'contraseña incorrecta') const left = security.remaining(ip) throw new HttpError(401, `Usuario o contraseña incorrectos${left != null ? ` (${left ? `quedan ${left} intento${left > 1 ? 's' : ''} antes de bloquear esta IP` : 'IP bloqueada'})` : ''}`) } user = row } security.success(ip) const token = createSession(user.id) return json(res, 200, { ok: true }, { 'Set-Cookie': sessionCookie(token, SESSION_DAYS * 86400, security.isHttps(req)) }) } if (p === '/api/logout' && method === 'POST') { const m = /(?:^|;\s*)sid=([^;]+)/.exec(req.headers.cookie || '') if (m) db.prepare('DELETE FROM sessions WHERE token = ?').run(m[1]) return json(res, 200, { ok: true }, { 'Set-Cookie': sessionCookie('', 0, security.isHttps(req)) }) } // Todo lo demás requiere sesión. const user = requireUser(req) // Un libro subido solo existe para quien lo subió, en cualquier ruta y parámetro que lo nombre. let rawUrl = req.url try { rawUrl = decodeURIComponent(req.url) } catch {} for (const [ub] of rawUrl.matchAll(/ub~[0-9a-f]{32}/g)) if (!books.owns(user.id, ub)) throw new HttpError(404, 'No encontrado') if (p.startsWith('/api/bans') && security.route(p, method, req, res, json) !== false) return if ((await features.route(p, method, q, req, res, user)) !== false) return if ((await upscale.route(p, method, req, res)) !== false) { // Pedir el reescalado (o el automático) lo pone en la biblioteca de quien lo pide. if (method === 'POST' && (m = /^\/api\/upscale\/([^/]+)(\/auto)?$/.exec(p)) && db.prepare('SELECT 1 FROM downloads WHERE manga_id = ?').get(m[1])) linkDownload(user.id, m[1]) return } if (p.startsWith('/api/clips') && (await clips.route(p, method, q, req, res, user)) !== false) return if (p.startsWith('/api/meta/') && MANGA_ID.test(decodeURIComponent(p.split('/')[3] || '')) && !isBookId(decodeURIComponent(p.split('/')[3] || '')) && (await meta.route(p, method, res)) !== false) return if (/^\/api\/(lists|prefetch|recommendations)\b/.test(p) && (await library.route(p, method, q, req, res, user)) !== false) return if (p === '/api/settings' && method === 'POST') { const body = await readBody(req) if (body.lang !== undefined) { if (!LANG.test(body.lang)) throw new HttpError(400, 'Idioma inválido') db.prepare('UPDATE users SET lang = ? WHERE id = ?').run(body.lang, user.id) } if (body.contentMode !== undefined) { if (!['both', 'manga', 'books'].includes(body.contentMode)) throw new HttpError(400, 'Valor inválido') db.prepare('UPDATE users SET content_mode = ? WHERE id = ?').run(body.contentMode, user.id) } if (body.newPassword !== undefined) { const row = db.prepare('SELECT pass_hash FROM users WHERE id = ?').get(user.id) if (!verifyPassword(String(body.oldPassword || '').slice(0, 256), row.pass_hash)) { security.fail(ip, 'contraseña actual incorrecta'); throw new HttpError(401, 'Contraseña actual incorrecta') } if (String(body.newPassword).length < 8 || String(body.newPassword).length > 256) throw new HttpError(400, 'La contraseña debe tener entre 8 y 256 caracteres') db.prepare('UPDATE users SET pass_hash = ? WHERE id = ?').run(hashPassword(String(body.newPassword)), user.id) // Cerrar las demás sesiones (por si la contraseña se cambió por estar comprometida). const mine = /(?:^|;\s*)sid=([^;]+)/.exec(req.headers.cookie || '')?.[1] db.prepare('DELETE FROM sessions WHERE user_id = ? AND token != ?').run(user.id, mine || '') } return json(res, 200, { ok: true }) } if (p === '/api/img' && method === 'GET') { const mid = MANGA_ID.test(q.get('m') || '') ? q.get('m') : null try { return await proxyImage(req, res, q.get('u') || '', q.get('s'), !!mid && tracked(user.id, mid), q.has('m'), q.has('b')) } catch (e) { // Portada sin conexión con la fuente: la cover.jpg de la copia local, si la hay. if (mid && !res.headersSent && e.status !== 403) { const dl = downloadInfo(mid) const file = dl && path.join(MANGA_DIR, dl.folder, 'cover.jpg') if (file && fs.existsSync(file)) { res.writeHead(200, { 'Content-Type': imageType(file), 'Cache-Control': 'private, max-age=300' }) return fs.createReadStream(file).pipe(res) } } throw e } } const lang = LANG.test(q.get('lang') || '') ? q.get('lang') : user.lang if (p === '/api/categories' && method === 'GET') { return json(res, 200, !net.online() ? [] : q.get('source') === 'lc' ? await lcCategories() : ['mi', 'gb', 'ws', 'ub'].includes(q.get('source')) ? [] : await mdCategories()) } if (p === '/api/search' && method === 'GET') { const page = Math.min(Math.max(Number(q.get('page')) | 0, 1), 500) const tag = q.get('tag') || '' if (q.get('source') === 'ub') return json(res, 200, books.ubList(user.id, (q.get('q') || '').slice(0, 100))) // no necesita internet if (!net.online()) return json(res, 200, localSearch(q.get('q') || '')) if (q.get('source') === 'ws') return json(res, 200, await books.wsSearch((q.get('q') || '').slice(0, 100), q.get('mode'), page)) if (q.get('source') === 'gb') return json(res, 200, await books.search((q.get('q') || '').slice(0, 100), q.get('mode'), page, user.lang)) if (q.get('source') === 'mi') return json(res, 200, await mi.search((q.get('q') || '').slice(0, 100), q.get('mode'), page)) if (q.get('source') === 'lc') return json(res, 200, await lcSearch((q.get('q') || '').slice(0, 100), q.get('mode'), tag, page)) const anyLang = q.get('anyLang') === '1' const mdTag = /^[0-9a-f-]{36}$/.test(tag) ? tag : null return json(res, 200, await searchManga((q.get('q') || '').slice(0, 100), anyLang ? null : lang, q.get('mode'), mdTag, page)) } let m if ((m = /^\/api\/manga\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'GET') { let manga, chapters, offline = false try { ({ manga, chapters } = await fetchMangaFull(m[1], lang)) } catch (e) { // Sin conexión con la fuente: usar la ficha guardada y solo los capítulos descargados. const dl = db.prepare('SELECT meta FROM downloads WHERE manga_id = ? AND meta IS NOT NULL').get(m[1]) if (!dl) throw net.online() ? e : new HttpError(503, 'El servidor no tiene conexión a internet y este manga no está descargado en el servidor.') const saved = JSON.parse(dl.meta) const have = new Set(db.prepare('SELECT chapter_id FROM downloaded_chapters WHERE manga_id = ?').all(m[1]).map(r => r.chapter_id)) manga = { ...saved.manga, cover: `/api/local-cover/${m[1]}` } chapters = saved.chapters.filter(c => have.has(c.id)) offline = true } const downloaded = db.prepare('SELECT chapter_id FROM downloaded_chapters WHERE manga_id = ?').all(m[1]).map(r => r.chapter_id) const reads = db.prepare('SELECT chapter_id, page, total_pages FROM chapter_reads WHERE user_id = ? AND manga_id = ?').all(user.id, m[1]) const progress = db.prepare('SELECT * FROM progress WHERE user_id = ? AND manga_id = ?').get(user.id, m[1]) ?? null const chapterLangs = chapters.length || manga.source !== 'md' || offline ? [] : await getChapterLangs(m[1]).catch(() => []) return json(res, 200, { manga, chapters, lang, reads, progress, chapterLangs, offline, downloaded, download: downloadInfo(m[1], true), upscale: upscale.info(m[1]), clips: clips.count(user.id, m[1]), lists: library.listsWith(user.id, m[1]), info: isBookId(m[1]) ? null : meta.cachedInfo(m[1]), clipBanner: clips.bannerFor(user.id, m[1]), ...features.mangaExtras(user.id, manga) }) } if (p === '/api/books' && method === 'POST') return json(res, 200, await books.upload(req, user)) if ((m = /^\/api\/books\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && m[1].startsWith('ub~') && method === 'DELETE') { books.removeUpload(m[1], user.id) return json(res, 200, { ok: true }) } if ((m = /^\/api\/book\/([^/]+)\/cover$/.exec(p)) && MANGA_ID.test(m[1]) && isBookId(m[1]) && method === 'GET') return books.sendCover(res, m[1]) if ((m = /^\/api\/book\/([^/]+)\/text$/.exec(p)) && MANGA_ID.test(m[1]) && isBookId(m[1]) && method === 'GET') return books.sendText(req, res, m[1]) if ((m = /^\/api\/book\/([^/]+)\/img\/(\d{1,5})$/.exec(p)) && MANGA_ID.test(m[1]) && isBookId(m[1]) && method === 'GET') return books.sendImage(res, m[1], Number(m[2])) if ((m = /^\/api\/chapter\/([^/]+)$/.exec(p)) && CHAPTER_ID.test(m[1]) && method === 'GET') { if (isBookId(m[1])) { const read = db.prepare('SELECT page, total_pages FROM chapter_reads WHERE user_id = ? AND chapter_id = ?').get(user.id, m[1]) return json(res, 200, { text: true, ...(await books.chapter(m[1])), pages: [], savedPage: read?.page ?? 0, savedTotal: read?.total_pages ?? 0, local: false, hd: false, hdAvailable: false }) } const hdAvailable = !!upscale.hdFileOf(m[1]) const hd = hdAvailable && q.get('hd') !== '0' const local = localChapter(m[1], hd) if (!local && !net.online()) throw new HttpError(503, 'El servidor no tiene conexión a internet y este capítulo no está descargado en el servidor.') const pages = local ? local.entries.map((_, i) => `/api/local/${m[1]}/${i}${hd ? '?hd=1' : ''}`) : m[1].startsWith('lc~') ? await lcPages(m[1]) : m[1].startsWith('mi~') ? await miPages(m[1]) : await getPages(m[1], q.get('saver') === '1') const read = db.prepare('SELECT page FROM chapter_reads WHERE user_id = ? AND chapter_id = ?').get(user.id, m[1]) return json(res, 200, { pages, savedPage: read?.page ?? 0, local: !!local, hd, hdAvailable }) } if ((m = /^\/api\/local\/([^/]+)\/(\d+)$/.exec(p)) && CHAPTER_ID.test(m[1]) && method === 'GET') { const local = localChapter(m[1], q.get('hd') === '1') const entry = local?.entries[Number(m[2])] if (!entry) throw new HttpError(404, 'Página no descargada') return sendCbzEntry(res, local.file, entry) } if ((m = /^\/api\/local-cover\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'GET') { const dl = downloadInfo(m[1]) const file = dl && path.join(MANGA_DIR, dl.folder, 'cover.jpg') if (!file || !fs.existsSync(file)) throw new HttpError(404, 'Sin portada') if (!tracked(user.id, m[1])) { res.writeHead(200, { 'Content-Type': imageType(file), 'Cache-Control': 'private, max-age=86400' }) return fs.createReadStream(file).pipe(res) } const key = `local:${file}:${fs.statSync(file).mtimeMs}` if (covers.sendCached(res, key)) return const buf = fs.readFileSync(file) res.writeHead(200, { 'Content-Type': imageType(file), 'Cache-Control': covers.pendingCache() }) res.end(buf) return covers.enqueue(key, buf) } // --- Descargas --- if (p === '/api/status' && method === 'GET') return json(res, 200, net.status()) if (p === '/api/queues' && method === 'GET') { const downloads = db.prepare(`SELECT manga_id, title, folder, lang, status, scope, done, total, failed, error, updated_at, json_extract(meta, '$.manga.cover') meta_cover FROM downloads WHERE status IN ('downloading', 'queued') AND ${MY_DOWNLOADS} ORDER BY status = 'downloading' DESC, created_at`).all(user.id) .map(({ meta_cover, ...d }) => ({ ...withCurrent(d), cover: downloadCover(d.manga_id, d.folder, meta_cover) })) const c = clips.queueInfo(user.id) const up = upscale.queue() const mine = new Set(db.prepare('SELECT manga_id FROM download_users WHERE user_id = ?').all(user.id).map(r => r.manga_id)) up.groups = up.groups.filter(g => mine.has(g.mangaId)) if (up.current && !mine.has(up.current.mangaId)) up.current = null for (const g of up.groups) { const d = db.prepare(`SELECT folder, json_extract(meta, '$.manga.cover') meta_cover FROM downloads WHERE manga_id = ?`).get(g.mangaId) g.cover = d ? downloadCover(g.mangaId, d.folder, d.meta_cover) : null } return json(res, 200, { downloads, upscale: up, clips: { queued: c.queued || 0, running: c.running || 0, errors: c.errors || 0 }, covers: covers.pendingCount() }) } if (p === '/api/downloads' && method === 'GET') { return json(res, 200, db.prepare(`SELECT manga_id, title, folder, lang, status, done, total, failed, error, updated_at, json_extract(meta, '$.manga.cover') meta_cover FROM downloads WHERE ${MY_DOWNLOADS} ORDER BY updated_at DESC`).all(user.id) .map(({ meta_cover, ...d }) => ({ ...withCurrent(d), size: dirSize(mangaDirOf(d.folder)), cover: downloadCover(d.manga_id, d.folder, meta_cover), shared: sharedWith(user.id, d.manga_id) }))) } if ((m = /^\/api\/download\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'GET') { const d = downloadInfo(m[1], true) return json(res, 200, d && { ...d, shared: sharedWith(user.id, m[1]) }) } if ((m = /^\/api\/download\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'POST') { if (isBookId(m[1])) throw new HttpError(400, 'Los libros no se descargan como capítulos') const b = await readBody(req) const dlLang = spanishOnly(m[1]) ? 'es' : LANG.test(b.lang || '') ? b.lang : user.lang const title = String(b.title || '').slice(0, 300) || (await fetchMangaFull(m[1], dlLang)).manga.title const ids = Array.isArray(b.chapterIds) ? b.chapterIds.map(String).filter(id => CHAPTER_ID.test(id)).slice(0, 5000) : null if (ids && !ids.length) throw new HttpError(400, 'Ningún capítulo válido') enqueueDownload(m[1], title, dlLang, ids) linkDownload(user.id, m[1]) return json(res, 200, downloadInfo(m[1], true)) } if ((m = /^\/api\/download\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'DELETE') { // Si otro usuario también lo tiene, solo se quita de la biblioteca de este (los archivos se quedan). if (sharedWith(user.id, m[1]) && db.prepare('DELETE FROM download_users WHERE user_id = ? AND manga_id = ?').run(user.id, m[1]).changes) return json(res, 200, { ok: true, kept: true }) if (!deleteDownload(m[1])) throw new HttpError(404, 'No hay copia local') return json(res, 200, { ok: true }) } if ((m = /^\/api\/download\/([^/]+)\/cancel$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'POST') { db.prepare("UPDATE downloads SET status = 'cancelled', updated_at = ? WHERE manga_id = ? AND status IN ('queued', 'downloading')").run(Date.now(), m[1]) return json(res, 200, downloadInfo(m[1])) } // --- Historial --- if (p === '/api/history' && method === 'GET') { // Un manga terminado vuelve a "en curso" si después aparecieron capítulos nuevos. const rows = db.prepare(`SELECT p.*, f.last_new_at FROM progress p LEFT JOIN follows f ON f.user_id = p.user_id AND f.manga_id = p.manga_id AND f.active = 1 WHERE p.user_id = ? ORDER BY p.updated_at DESC LIMIT 200`).all(user.id) return json(res, 200, rows.map(({ last_new_at, ...r }) => { const hasNew = !!(r.finished_at && last_new_at && last_new_at > r.finished_at) return { ...r, finished: !!r.finished_at && !hasNew, hasNew } })) } if (p === '/api/progress' && method === 'POST') { const b = await readBody(req) if (!MANGA_ID.test(b.mangaId || '') || !CHAPTER_ID.test(b.chapterId || '') || !b.chapterId.startsWith(spanishOnly(b.mangaId) ? b.mangaId + '~' : '')) throw new HttpError(400, 'IDs inválidos') const page = Math.max(0, Number(b.page) | 0) const total = Math.max(0, Number(b.totalPages) | 0) const now = Date.now() const finishedAt = b.isLast === true && total && page >= total - 1 ? now : null const prevPage = db.prepare('SELECT page FROM chapter_reads WHERE user_id = ? AND chapter_id = ?').get(user.id, b.chapterId)?.page db.prepare(`INSERT INTO progress (user_id, manga_id, manga_title, cover, chapter_id, chapter_num, chapter_title, lang, page, total_pages, updated_at, finished_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (user_id, manga_id) DO UPDATE SET manga_title = excluded.manga_title, cover = excluded.cover, chapter_id = excluded.chapter_id, chapter_num = excluded.chapter_num, chapter_title = excluded.chapter_title, lang = excluded.lang, page = excluded.page, total_pages = excluded.total_pages, updated_at = excluded.updated_at, finished_at = excluded.finished_at`) .run(user.id, b.mangaId, String(b.mangaTitle || '').slice(0, 300), b.cover ? String(b.cover).slice(0, 1000) : null, b.chapterId, b.chapterNum != null ? String(b.chapterNum) : null, String(b.chapterTitle || '').slice(0, 300), LANG.test(b.lang || '') ? b.lang : null, page, total, now, finishedAt) db.prepare(`INSERT INTO chapter_reads (user_id, chapter_id, manga_id, page, total_pages, updated_at) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT (user_id, chapter_id) DO UPDATE SET page = excluded.page, total_pages = excluded.total_pages, updated_at = excluded.updated_at`) .run(user.id, b.chapterId, b.mangaId, page, total, now) if (db.prepare('SELECT 1 FROM downloads WHERE manga_id = ?').get(b.mangaId)) linkDownload(user.id, b.mangaId) features.onProgress(user.id, b, page, total, prevPage) if (!isBookId(b.mangaId)) library.onProgress(user.id, b, page, total) return json(res, 200, { ok: true }) } if ((m = /^\/api\/history\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'DELETE') { db.prepare('DELETE FROM progress WHERE user_id = ? AND manga_id = ?').run(user.id, m[1]) db.prepare('DELETE FROM chapter_reads WHERE user_id = ? AND manga_id = ?').run(user.id, m[1]) features.unfollow(user.id, m[1]) return json(res, 200, { ok: true }) } throw new HttpError(404, 'No encontrado') } const security = setupSecurity({ db }) const books = setupBooks({ db, DATA_DIR, cached, HttpError, coverUrl, UA, net, safeFetch }) const features = setupFeatures({ db, fetchMangaFull, enqueueDownload, downloadInfo, HttpError, json, readBody, MANGA_ID, net }) const upscale = setupUpscale({ db, MANGA_DIR, HttpError, json, readBody, MANGA_ID }) const covers = setupCovers({ DATA_DIR, upscale }) const library = setupLibrary({ db, HttpError, json, readBody, MANGA_ID, CHAPTER_ID, fetchMangaFull, enqueueDownload, linkDownload, upscale, stats: features.stats, searchManga, mdCategories, net }) // Al volver internet: reanudar las descargas en cola. net.whenOnline(() => pumpDownloads()) const meta = setupMeta({ db, md, sourceOf, fetchMangaFull, HttpError, json, net, decode, imgUrl, UA, prepareBanner }) const clips = setupClips({ db, DATA_DIR, HttpError, json, readBody, MANGA_ID, CHAPTER_ID, upscale }) // Migración: lo descargado antes de que hubiera dueños pasa a quien lo lee, lo sigue o lo tiene en una // lista; si no es de nadie, al primer usuario (el que montó el servidor). if (!db.prepare("SELECT 1 FROM kv WHERE k = 'download_users_init'").get()) { const first = db.prepare('SELECT id FROM users ORDER BY id LIMIT 1').get()?.id for (const { manga_id } of db.prepare('SELECT manga_id FROM downloads').all()) { const users = db.prepare(`SELECT user_id FROM progress WHERE manga_id = ? UNION SELECT user_id FROM follows WHERE manga_id = ? UNION SELECT l.user_id FROM list_items i JOIN lists l ON l.id = i.list_id WHERE i.manga_id = ?`).all(manga_id, manga_id, manga_id).map(r => r.user_id) for (const u of users.length ? users : first ? [first] : []) linkDownload(u, manga_id) } if (first) db.prepare("INSERT INTO kv (k, v) VALUES ('download_users_init', '1')").run() } // Migración: portadas guardadas sin &m= (reescalado de portadas) y con la miniatura de 256 px de MangaDex. for (const t of ['progress', 'follows', 'clips']) { for (const row of db.prepare(`SELECT DISTINCT manga_id, cover FROM ${t} WHERE cover LIKE '/api/img?u=%&s=%' AND cover NOT LIKE '%&m=%'`).all()) { const u = new URLSearchParams(row.cover.slice('/api/img?'.length)).get('u') if (u) db.prepare(`UPDATE ${t} SET cover = ? WHERE cover = ? AND manga_id = ?`).run(coverUrl(u.replace(/(\/covers\/[^/]+\/[^/]+)\.256\.jpg$/, '$1.512.jpg'), row.manga_id), row.cover, row.manga_id) } } // Migración: portadas guardadas antes de firmar las URLs del proxy. for (const row of db.prepare("SELECT user_id, manga_id, cover FROM progress WHERE cover LIKE '/api/img?u=%' AND cover NOT LIKE '%&s=%'").all()) { const u = new URLSearchParams(row.cover.slice('/api/img?'.length)).get('u') if (u) db.prepare('UPDATE progress SET cover = ? WHERE user_id = ? AND manga_id = ?').run(coverUrl(u, row.manga_id), row.user_id, row.manga_id) } http.createServer(async (req, res) => { try { await route(req, res) } catch (err) { const status = err instanceof HttpError ? err.status : 500 // Sin internet los 503 son lo esperado: no llenar el log de trazas. if (status >= 500 && !(status === 503 && !net.online())) console.error(req.method, req.url, err) // Los errores internos no se detallan al cliente (rutas, nombres de archivo…): solo en el log. if (!res.headersSent) json(res, status, { error: status >= 500 && status !== 502 && status !== 503 ? 'Error interno del servidor' : err.message || 'Error' }) else res.end() } }).listen(PORT, () => { console.log(`Manga Reader escuchando en :${PORT} (registro ${ALLOW_SIGNUP ? 'abierto' : 'cerrado'}, descargas en ${MANGA_DIR})`) pumpDownloads() // reanuda descargas pendientes tras un reinicio })