// 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~~", capítulo "lc~~~". Ya no hace falta navegador: // las páginas del capítulo vienen en el HTML como . 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*]*>([^<]+)/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('

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'), year: null, tags: [...html.matchAll(/class="badge [^"]*" href="\/manga\/\?(?:genre|theme)=[^"]+">([^<]+) decode(m[1])).slice(0, 10), authors: fact('Autor') ? [fact('Autor')] : [], languages: ['es'], cover: lcCover(/class="lc-cover-lg">\s* 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'})`))