Files
manga-reader/app/server.js
T
ninoandClaude Opus 5.5 9d031d4ba3 Manga Reader: lector web de manga con historial de lectura
Basado en las fuentes de Tanko (MangaDex y LeerCapitulo), con usuarios
con contraseña, progreso por página guardado en SQLite, lector por página
o vertical, filtros de imagen y proxy de imágenes con URLs firmadas.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 23:26:33 -06:00

511 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Manga Reader: lector web con las mismas fuentes que Tanko (MangaDex y LeerCapitulo)
// con usuarios y historial de lectura persistente en SQLite.
import http from 'node:http'
import fs from 'node:fs'
import path from 'node:path'
import crypto from 'node:crypto'
import { DatabaseSync } from 'node:sqlite'
const PORT = Number(process.env.PORT || 3352)
const DATA_DIR = process.env.DATA_DIR || '/app/data'
const ALLOW_SIGNUP = (process.env.ALLOW_SIGNUP ?? 'true') !== 'false'
const PUBLIC_DIR = path.resolve(import.meta.dirname, 'public')
const MD_API = 'https://api.mangadex.org'
const LC_BASE = 'https://www.leercapitulo.co'
const BROWSER_UA = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0 Safari/537.36'
const UA = 'manga-reader-selfhosted/1.0'
const SESSION_DAYS = 120
// ---------- Base de datos ----------
fs.mkdirSync(DATA_DIR, { recursive: true })
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);
`)
db.prepare('DELETE FROM sessions WHERE expires_at < ?').run(Date.now())
// ---------- Contraseñas y sesiones ----------
function hashPassword(pw) {
const salt = crypto.randomBytes(16)
const hash = crypto.scryptSync(pw, salt, 64)
return `${salt.toString('hex')}:${hash.toString('hex')}`
}
function verifyPassword(pw, stored) {
const [saltHex, hashHex] = stored.split(':')
const hash = crypto.scryptSync(pw, Buffer.from(saltHex, 'hex'), 64)
return crypto.timingSafeEqual(hash, Buffer.from(hashHex, 'hex'))
}
function createSession(userId) {
const token = crypto.randomBytes(32).toString('base64url')
db.prepare('INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)')
.run(token, userId, Date.now() + SESSION_DAYS * 86400e3)
return token
}
function sessionCookie(token, maxAge) {
return `sid=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAge}`
}
function getUser(req) {
const m = /(?:^|;\s*)sid=([^;]+)/.exec(req.headers.cookie || '')
if (!m) return null
return db.prepare(`SELECT u.id, u.username, u.lang FROM sessions s JOIN users u ON u.id = s.user_id
WHERE s.token = ? AND s.expires_at > ?`).get(m[1], Date.now()) ?? null
}
// ---------- Cliente MangaDex (adaptado de Tanko src/servers/mangadex.ts) ----------
const cache = new Map()
async function cached(key, ttl, fn) {
const hit = cache.get(key)
if (hit && hit.exp > Date.now()) return hit.data
const data = await fn()
cache.set(key, { data, exp: Date.now() + ttl })
if (cache.size > 500) cache.delete(cache.keys().next().value)
return data
}
// Las imágenes pasan por /api/img firmadas con HMAC: el proxy solo descarga URLs generadas por este servidor.
const sign = u => crypto.createHmac('sha256', SECRET).update(u).digest('base64url').slice(0, 22)
const imgUrl = u => `/api/img?u=${encodeURIComponent(u)}&s=${sign(u)}`
async function md(pathname, params = {}, ttl = 5 * 60e3) {
const url = new URL(MD_API + pathname)
for (const [k, v] of Object.entries(params)) {
if (Array.isArray(v)) v.forEach(x => url.searchParams.append(k, x))
else if (v !== undefined && v !== '') url.searchParams.set(k, v)
}
return cached(url.toString(), ttl, async () => {
const res = await fetch(url, { headers: { 'User-Agent': UA } })
if (!res.ok) throw new HttpError(502, `MangaDex respondió ${res.status}`)
return res.json()
})
}
const pick = (obj, langs) => {
if (!obj) return ''
for (const l of langs) if (obj[l]) return obj[l]
return Object.values(obj)[0] ?? ''
}
function mangaSummary(m, lang) {
const a = m.attributes
const langs = [lang, 'es', 'es-la', 'en', 'ja-ro']
let title = a.title.en ?? Object.values(a.title)[0]
const alt = (a.altTitles || []).find(t => t[lang] || t.es || t['es-la'])
if (alt) title = pick(alt, [lang, 'es-la', 'es'])
const coverRel = m.relationships?.find(r => r.type === 'cover_art')
const cover = coverRel?.attributes?.fileName
? imgUrl(`https://uploads.mangadex.org/covers/${m.id}/${coverRel.attributes.fileName}.256.jpg`)
: null
return {
id: m.id,
source: 'md',
title,
description: pick(a.description, langs),
status: a.status,
year: a.year,
tags: (a.tags || []).map(t => pick(t.attributes.name, ['es', 'en'])).slice(0, 8),
authors: (m.relationships || []).filter(r => r.type === 'author' && r.attributes).map(r => r.attributes.name),
languages: a.availableTranslatedLanguages || [],
cover,
}
}
const RATINGS = ['safe', 'suggestive']
async function searchManga(q, lang, mode) {
const params = {
limit: 30,
'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))
}
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 getPages(chapterId, saver) {
// Las URLs de at-home caducan a los ~15 min: caché corta.
const data = await md(`/at-home/server/${chapterId}`, {}, 5 * 60e3)
const { baseUrl, chapter } = data
const files = saver ? chapter.dataSaver : chapter.data
const kind = saver ? 'data-saver' : 'data'
return files.map(f => imgUrl(`${baseUrl}/${kind}/${chapter.hash}/${f}`))
}
// ---------- Cliente LeerCapitulo (Tanko src/servers/leerCapitulo.ts, adaptado al sitio actual) ----------
// IDs: manga "lc~<id>~<slug>", capítulo "lc~<id>~<slug>~<número>". Ya no hace falta navegador:
// las páginas del capítulo vienen en el HTML como <img data-src>.
const decode = s => String(s ?? '').replace(/<[^>]+>/g, '').replace(/&(#x?[0-9a-f]+|amp|lt|gt|quot|apos|nbsp|lsaquo|rsaquo);/gi, (_, e) => {
const named = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', lsaquo: '‹', rsaquo: '›' }[e.toLowerCase()]
if (named) return named
return String.fromCodePoint(e[1].toLowerCase() === 'x' ? parseInt(e.slice(2), 16) : parseInt(e.slice(1), 10))
}).trim()
async function lc(pathname, ttl = 10 * 60e3, asJson = false) {
return cached(`lc:${pathname}`, ttl, async () => {
const res = await fetch(LC_BASE + pathname, { headers: { 'User-Agent': BROWSER_UA, 'Accept-Language': 'es' } })
if (!res.ok) throw new HttpError(res.status === 404 ? 404 : 502, `LeerCapitulo respondió ${res.status}`)
return asJson ? res.json() : res.text()
})
}
const lcCover = src => (src ? imgUrl(new URL(src, LC_BASE).toString()) : null)
const lcId = uri => {
const m = /\/manga\/([a-z0-9]+)\/([a-z0-9-]+)\//.exec(uri || '')
return m ? `lc~${m[1]}~${m[2]}` : null
}
function lcParseCards(html) {
const re = /class="lc-(?:slide|side-item|release|card)(?:-cover)?" href="(\/manga\/[a-z0-9]+\/[a-z0-9-]+\/)"[^>]*>\s*<img src="([^"]+)"[\s\S]*?class="lc-(?:slide|side|release|card)-(?:name|title)[^"]*"[^>]*>([^<]+)/g
const out = []
const seen = new Set()
for (const m of html.matchAll(re)) {
const id = lcId(m[1])
if (!id || seen.has(id)) continue
seen.add(id)
out.push({ id, source: 'lc', title: decode(m[3]), cover: lcCover(m[2]) })
}
return out
}
async function lcSearch(q, mode) {
if (q) {
const list = await lc(`/search/?q=${encodeURIComponent(q)}`, 10 * 60e3, true)
return list.map(e => ({ id: lcId(e.uri), source: 'lc', title: decode(e.name), cover: lcCover(e.cover_uri), sub: e.last_chapter_name }))
.filter(e => e.id)
}
const html = await lc('/')
const section = (from, to) => {
const i = html.indexOf(from)
if (i < 0) return ''
const j = html.indexOf(to, i + from.length)
return html.slice(i, j < 0 ? undefined : j)
}
if (mode === 'latest') return lcParseCards(section('<h1>Ultimos capitulos', '<footer'))
const popular = [...lcParseCards(section('<h2>Tendencias', '</section>')), ...lcParseCards(section('>Populares</h2>', 'lc-panel-title'))]
return popular.filter((m, i) => popular.findIndex(x => x.id === m.id) === i)
}
async function lcManga(id) {
const [, mid, slug] = id.split('~')
const html = await lc(`/manga/${mid}/${slug}/`)
const fact = k => decode(new RegExp(`<span class="k">${k}</span>\\s*<(?:a|span)[^>]*>([^<]*)<`).exec(html)?.[1])
const sinopsis = /id="sinopsis"[\s\S]*?<p[^>]*>([\s\S]*?)<\/p>/.exec(html)?.[1]
const chapters = []
const seen = new Set()
const re = new RegExp(`href="/leer/${mid}/${slug}/([0-9a-z.-]+)/">\\s*<span class="n">([^<]*)</span>[\\s\\S]*?<span class="d">([^<]*)</span>`, 'g')
for (const m of html.matchAll(re)) {
if (seen.has(m[1])) continue
seen.add(m[1])
const title = decode(m[2]).replace(/^Cap[ií]tulo\s+[0-9.]+\s*[:-]?\s*/i, '')
chapters.push({ id: `${id}~${m[1]}`, num: m[1], volume: null, title, pages: null, group: m[3], publishAt: m[3] })
}
chapters.sort((a, b) => (parseFloat(a.num) || 0) - (parseFloat(b.num) || 0))
const manga = {
id,
source: 'lc',
title: decode(/<h1[^>]*>([^<]+)/.exec(html)?.[1]) || slug,
description: decode(sinopsis?.replace(/<br\s*\/?>/g, '\n')),
status: fact('Estado'),
year: null,
tags: [...html.matchAll(/class="badge [^"]*" href="\/manga\/\?(?:genre|theme)=[^"]+">([^<]+)</g)].map(m => decode(m[1])).slice(0, 10),
authors: fact('Autor') ? [fact('Autor')] : [],
languages: ['es'],
cover: lcCover(/class="lc-cover-lg">\s*<img src="([^"]+)"/.exec(html)?.[1]),
}
return { manga, chapters }
}
async function lcPages(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 => imgUrl(decode(m[1])))
if (!pages.length) throw new HttpError(502, 'No se encontraron páginas en LeerCapitulo')
return pages
}
// Proxy de imágenes: MangaDex bloquea el hotlinking de portadas y así el navegador no depende de CDNs ajenos.
async function proxyImage(req, res, u, s) {
let url
try { url = new URL(u) } catch { throw new HttpError(400, 'URL inválida') }
const expected = sign(u)
if (typeof s !== 'string' || s.length !== expected.length || !crypto.timingSafeEqual(Buffer.from(s), Buffer.from(expected))) throw new HttpError(403, 'Firma inválida')
if (!/^https?:$/.test(url.protocol) || /^(localhost|\d+\.\d+\.\d+\.\d+|\[.*\])$/.test(url.hostname)) throw new HttpError(403, 'Host no permitido')
const isMd = /(^|\.)mangadex\.(org|network)$/.test(url.hostname)
const r = await fetch(url, { headers: { 'User-Agent': isMd ? UA : BROWSER_UA, ...(isMd ? {} : { Referer: LC_BASE + '/' }) } })
if (!r.ok || !r.body) throw new HttpError(502, `Imagen ${r.status}`)
res.writeHead(200, {
'Content-Type': r.headers.get('content-type') || 'image/jpeg',
'Cache-Control': 'private, max-age=604800, immutable',
})
for await (const chunk of r.body) res.write(chunk)
res.end()
}
// ---------- HTTP ----------
class HttpError extends Error {
constructor(status, message) { super(message); this.status = status }
}
function json(res, status, body, headers = {}) {
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store', ...headers })
res.end(JSON.stringify(body))
}
async function readBody(req) {
let size = 0
const chunks = []
for await (const c of req) {
size += c.length
if (size > 64 * 1024) throw new HttpError(413, 'Cuerpo demasiado grande')
chunks.push(c)
}
try { return JSON.parse(Buffer.concat(chunks).toString() || '{}') } catch { throw new HttpError(400, 'JSON inválido') }
}
const MANGA_ID = /^([0-9a-f-]{36}|lc~[a-z0-9]+~[a-z0-9-]+)$/
const CHAPTER_ID = /^([0-9a-f-]{36}|lc~[a-z0-9]+~[a-z0-9-]+~[0-9a-z.-]+)$/
const LANG = /^[a-z]{2}(-[a-z]{2})?$/
const requireUser = req => {
const u = getUser(req)
if (!u) throw new HttpError(401, 'No autenticado')
return u
}
const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.svg': 'image/svg+xml', '.webmanifest': 'application/manifest+json' }
function serveStatic(res, pathname) {
let file = path.join(PUBLIC_DIR, path.normalize(pathname).replace(/^(\.\.[/\\])+/, ''))
if (!file.startsWith(PUBLIC_DIR) || !fs.existsSync(file) || fs.statSync(file).isDirectory()) file = path.join(PUBLIC_DIR, 'index.html')
res.writeHead(200, { 'Content-Type': MIME[path.extname(file)] || 'application/octet-stream', 'Cache-Control': 'no-cache' })
fs.createReadStream(file).pipe(res)
}
const loginAttempts = new Map()
function rateLimit(ip) {
const now = Date.now()
const list = (loginAttempts.get(ip) || []).filter(t => now - t < 10 * 60e3)
if (list.length >= 10) throw new HttpError(429, 'Demasiados intentos, espera unos minutos')
list.push(now)
loginAttempts.set(ip, list)
}
async function route(req, res) {
const url = new URL(req.url, 'http://x')
const p = url.pathname
const q = url.searchParams
const method = req.method
if (!p.startsWith('/api/')) return serveStatic(res, p)
// --- Auth ---
if (p === '/api/me' && method === 'GET') {
const userCount = db.prepare('SELECT COUNT(*) n FROM users').get().n
return json(res, 200, { user: getUser(req), signup: ALLOW_SIGNUP || userCount === 0 })
}
if ((p === '/api/login' || p === '/api/register') && method === 'POST') {
rateLimit(req.socket.remoteAddress)
const { username, password } = await readBody(req)
if (typeof username !== 'string' || typeof password !== 'string') throw new HttpError(400, 'Datos incompletos')
let user
if (p === '/api/register') {
const userCount = db.prepare('SELECT COUNT(*) n FROM users').get().n
if (!ALLOW_SIGNUP && userCount > 0) throw new HttpError(403, 'El registro está deshabilitado')
if (!/^[\w.-]{3,32}$/.test(username)) throw new HttpError(400, 'Usuario: 3-32 caracteres (letras, números, . _ -)')
if (password.length < 6) throw new HttpError(400, 'La contraseña debe tener al menos 6 caracteres')
if (db.prepare('SELECT 1 FROM users WHERE username = ?').get(username)) throw new HttpError(409, 'Ese usuario ya existe')
const r = db.prepare('INSERT INTO users (username, pass_hash, created_at) VALUES (?, ?, ?)')
.run(username, hashPassword(password), Date.now())
user = { id: Number(r.lastInsertRowid) }
} else {
const row = db.prepare('SELECT id, pass_hash FROM users WHERE username = ?').get(username)
if (!row || !verifyPassword(password, row.pass_hash)) throw new HttpError(401, 'Usuario o contraseña incorrectos')
user = row
}
const token = createSession(user.id)
return json(res, 200, { ok: true }, { 'Set-Cookie': sessionCookie(token, SESSION_DAYS * 86400) })
}
if (p === '/api/logout' && method === 'POST') {
const m = /(?:^|;\s*)sid=([^;]+)/.exec(req.headers.cookie || '')
if (m) db.prepare('DELETE FROM sessions WHERE token = ?').run(m[1])
return json(res, 200, { ok: true }, { 'Set-Cookie': sessionCookie('', 0) })
}
// Todo lo demás requiere sesión.
const user = requireUser(req)
if (p === '/api/settings' && method === 'POST') {
const body = await readBody(req)
if (body.lang !== undefined) {
if (!LANG.test(body.lang)) throw new HttpError(400, 'Idioma inválido')
db.prepare('UPDATE users SET lang = ? WHERE id = ?').run(body.lang, user.id)
}
if (body.newPassword !== undefined) {
const row = db.prepare('SELECT pass_hash FROM users WHERE id = ?').get(user.id)
if (!verifyPassword(String(body.oldPassword || ''), row.pass_hash)) throw new HttpError(401, 'Contraseña actual incorrecta')
if (String(body.newPassword).length < 6) throw new HttpError(400, 'La contraseña debe tener al menos 6 caracteres')
db.prepare('UPDATE users SET pass_hash = ? WHERE id = ?').run(hashPassword(String(body.newPassword)), user.id)
}
return json(res, 200, { ok: true })
}
if (p === '/api/img' && method === 'GET') return proxyImage(req, res, q.get('u') || '', q.get('s'))
const lang = LANG.test(q.get('lang') || '') ? q.get('lang') : user.lang
if (p === '/api/search' && method === 'GET') {
if (q.get('source') === 'lc') return json(res, 200, await lcSearch((q.get('q') || '').slice(0, 100), q.get('mode')))
const anyLang = q.get('anyLang') === '1'
return json(res, 200, await searchManga((q.get('q') || '').slice(0, 100), anyLang ? null : lang, q.get('mode')))
}
let m
if ((m = /^\/api\/manga\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'GET') {
const { manga, chapters } = m[1].startsWith('lc~')
? await lcManga(m[1])
: { manga: await getManga(m[1], lang), chapters: await getChapters(m[1], lang) }
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' ? [] : await getChapterLangs(m[1])
return json(res, 200, { manga, chapters, lang, reads, progress, chapterLangs })
}
if ((m = /^\/api\/chapter\/([^/]+)$/.exec(p)) && CHAPTER_ID.test(m[1]) && method === 'GET') {
const pages = 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 })
}
// --- Historial ---
if (p === '/api/history' && method === 'GET') {
return json(res, 200, db.prepare('SELECT * FROM progress WHERE user_id = ? ORDER BY updated_at DESC LIMIT 200').all(user.id))
}
if (p === '/api/progress' && method === 'POST') {
const b = await readBody(req)
if (!MANGA_ID.test(b.mangaId || '') || !CHAPTER_ID.test(b.chapterId || '') || !b.chapterId.startsWith(b.mangaId.startsWith('lc~') ? b.mangaId + '~' : '')) throw new HttpError(400, 'IDs inválidos')
const page = Math.max(0, Number(b.page) | 0)
const total = Math.max(0, Number(b.totalPages) | 0)
const now = Date.now()
db.prepare(`INSERT INTO progress (user_id, manga_id, manga_title, cover, chapter_id, chapter_num, chapter_title, lang, page, total_pages, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (user_id, manga_id) DO UPDATE SET manga_title = excluded.manga_title, cover = excluded.cover,
chapter_id = excluded.chapter_id, chapter_num = excluded.chapter_num, chapter_title = excluded.chapter_title,
lang = excluded.lang, page = excluded.page, total_pages = excluded.total_pages, updated_at = excluded.updated_at`)
.run(user.id, b.mangaId, String(b.mangaTitle || '').slice(0, 300), b.cover ? String(b.cover).slice(0, 1000) : null,
b.chapterId, b.chapterNum != null ? String(b.chapterNum) : null, String(b.chapterTitle || '').slice(0, 300),
LANG.test(b.lang || '') ? b.lang : null, page, total, now)
db.prepare(`INSERT INTO chapter_reads (user_id, chapter_id, manga_id, page, total_pages, updated_at) VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT (user_id, chapter_id) DO UPDATE SET page = excluded.page, total_pages = excluded.total_pages, updated_at = excluded.updated_at`)
.run(user.id, b.chapterId, b.mangaId, page, total, now)
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])
return json(res, 200, { ok: true })
}
throw new HttpError(404, 'No encontrado')
}
// Migración: portadas guardadas antes de firmar las URLs del proxy.
for (const row of db.prepare("SELECT user_id, manga_id, cover FROM progress WHERE cover LIKE '/api/img?u=%' AND cover NOT LIKE '%&s=%'").all()) {
const u = new URLSearchParams(row.cover.slice('/api/img?'.length)).get('u')
if (u) db.prepare('UPDATE progress SET cover = ? WHERE user_id = ? AND manga_id = ?').run(imgUrl(u), row.user_id, row.manga_id)
}
http.createServer(async (req, res) => {
try {
await route(req, res)
} catch (err) {
const status = err instanceof HttpError ? err.status : 500
if (status >= 500) console.error(req.method, req.url, err)
if (!res.headersSent) json(res, status, { error: err.message || 'Error' })
else res.end()
}
}).listen(PORT, () => console.log(`Manga Reader escuchando en :${PORT} (registro ${ALLOW_SIGNUP ? 'abierto' : 'cerrado'})`))