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>
This commit is contained in:
2026-10-30 23:26:33 -06:00
co-authored by Claude Opus 5.5
commit 9d031d4ba3
11 changed files with 1171 additions and 0 deletions
+510
View File
@@ -0,0 +1,510 @@
// 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'})`))