Librería con listas, recomendaciones, descarga anticipada, app instalable con lectura offline y seguridad para exponerlo a internet
- Historial pasa a Librería: pestañas Leyendo, Siguiendo, Terminados, En el dispositivo y listas propias (Favoritos, Pendientes, Abandonados por defecto) - Recomendaciones en el inicio según tus géneros más leídos (populares de MangaDex) - Descarga anticipada de los N capítulos siguientes (opcionalmente en HD) al pasar la mitad de uno - PWA: manifest, iconos y service worker; capítulos guardados en el dispositivo para leer sin conexión, progreso offline sincronizado después - Seguridad: baneo de IP tras 6 intentos fallidos (configurable), IP real detrás de proxies de confianza, CSP y cabeceras, cookie Secure/HSTS en HTTPS, login sin diferencias de tiempo, registro cerrado por defecto, contraseñas de 8+, protección SSRF en el proxy de imágenes, errores 500 genéricos - Ajustes: sección Seguridad con IPs bloqueadas, intentos recientes y la IP vista por el servidor - LeerCapitulo: reintentos ante caídas (522/5xx), copia anterior si sigue caído y mensaje claro Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+61
-21
@@ -10,11 +10,13 @@ import { setupFeatures } from './features.js'
|
||||
import { setupUpscale } from './upscale.js'
|
||||
import { setupClips } from './clips.js'
|
||||
import { setupCovers } from './covers.js'
|
||||
import { setupLibrary } from './library.js'
|
||||
import { setupSecurity, safeFetch } from './security.js'
|
||||
|
||||
const PORT = Number(process.env.PORT || 3352)
|
||||
const DATA_DIR = process.env.DATA_DIR || '/app/data'
|
||||
const MANGA_DIR = process.env.MANGA_DIR || '/mangas'
|
||||
const ALLOW_SIGNUP = (process.env.ALLOW_SIGNUP ?? 'true') !== 'false'
|
||||
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'
|
||||
@@ -137,9 +139,11 @@ function createSession(userId) {
|
||||
.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 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
|
||||
@@ -152,7 +156,15 @@ 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()
|
||||
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
|
||||
@@ -308,10 +320,18 @@ const decode = s => String(s ?? '').replace(/<[^>]+>/g, '').replace(/&(#x?[0-9a-
|
||||
}).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' } })
|
||||
.catch(() => { throw new HttpError(502, 'No se pudo conectar con LeerCapitulo') })
|
||||
if (!res.ok) throw new HttpError(res.status === 404 ? 404 : 502, `LeerCapitulo respondió ${res.status}`)
|
||||
return asJson ? res.json() : res.text()
|
||||
// Cloudflare de LeerCapitulo da 52x/503 a ratos cuando su servidor no responde: hasta 3 intentos.
|
||||
for (let i = 0; ; i++) {
|
||||
const res = await fetch(LC_BASE + pathname, { headers: { 'User-Agent': BROWSER_UA, 'Accept-Language': 'es' }, signal: AbortSignal.timeout(30e3) })
|
||||
.catch(() => null)
|
||||
if (res?.ok) return asJson ? res.json() : res.text()
|
||||
if (res?.status === 404) throw new HttpError(404, 'LeerCapitulo respondió 404')
|
||||
const down = !res || res.status >= 500
|
||||
if (!down || i >= 2) {
|
||||
throw new HttpError(502, down ? `LeerCapitulo no responde ahora mismo${res ? ` (error ${res.status} de su servidor)` : ''}. Prueba en un rato.` : `LeerCapitulo respondió ${res.status}`)
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 1500 * (i + 1)))
|
||||
}
|
||||
})
|
||||
}
|
||||
const lcCover = (src, id) => (src ? coverUrl(new URL(src, LC_BASE).toString(), id) : null)
|
||||
@@ -433,7 +453,7 @@ async function proxyImage(req, res, u, s, isCover = false, coverish = false) {
|
||||
|
||||
function fetchImage(url, signal) {
|
||||
const isMd = /(^|\.)mangadex\.(org|network)$/.test(new URL(url).hostname)
|
||||
return fetch(url, { signal, headers: { 'User-Agent': isMd ? UA : BROWSER_UA, ...(isMd ? {} : { Referer: LC_BASE + '/' }) } })
|
||||
return safeFetch(url, { signal, headers: { 'User-Agent': isMd ? UA : BROWSER_UA, ...(isMd ? {} : { Referer: LC_BASE + '/' }) } })
|
||||
}
|
||||
|
||||
// ---------- Descargas a MANGA_DIR/<Título>/Capitulo NNNN.cbz ----------
|
||||
@@ -694,7 +714,7 @@ const requireUser = req => {
|
||||
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' }
|
||||
const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.svg': 'image/svg+xml', '.webmanifest': 'application/manifest+json', '.png': 'image/png' }
|
||||
function serveStatic(res, pathname) {
|
||||
let file = path.join(PUBLIC_DIR, path.normalize(pathname).replace(/^(\.\.[/\\])+/, ''))
|
||||
if (!file.startsWith(PUBLIC_DIR) || !fs.existsSync(file) || fs.statSync(file).isDirectory()) file = path.join(PUBLIC_DIR, 'index.html')
|
||||
@@ -712,6 +732,12 @@ function rateLimit(ip) {
|
||||
}
|
||||
|
||||
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
|
||||
@@ -725,38 +751,45 @@ async function route(req, res) {
|
||||
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)
|
||||
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) throw new HttpError(403, 'El registro está deshabilitado')
|
||||
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 < 6) throw new HttpError(400, 'La contraseña debe tener al menos 6 caracteres')
|
||||
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)
|
||||
if (!row || !verifyPassword(password, row.pass_hash)) throw new HttpError(401, 'Usuario o contraseña incorrectos')
|
||||
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) })
|
||||
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) })
|
||||
return json(res, 200, { ok: true }, { 'Set-Cookie': sessionCookie('', 0, security.isHttps(req)) })
|
||||
}
|
||||
|
||||
// Todo lo demás requiere sesión.
|
||||
const user = requireUser(req)
|
||||
if (p.startsWith('/api/bans') && security.route(p, method, req, res, json) !== false) return
|
||||
if ((await features.route(p, method, q, req, res, user)) !== false) return
|
||||
if ((await upscale.route(p, method, req, res)) !== false) return
|
||||
if (p.startsWith('/api/clips') && (await clips.route(p, method, q, req, res, user)) !== false) return
|
||||
if (/^\/api\/(lists|prefetch|recommendations)\b/.test(p) && (await library.route(p, method, q, req, res, user)) !== false) return
|
||||
|
||||
if (p === '/api/settings' && method === 'POST') {
|
||||
const body = await readBody(req)
|
||||
@@ -766,9 +799,12 @@ async function route(req, res) {
|
||||
}
|
||||
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')
|
||||
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 })
|
||||
}
|
||||
@@ -808,7 +844,7 @@ async function route(req, res) {
|
||||
const reads = db.prepare('SELECT chapter_id, page, total_pages FROM chapter_reads WHERE user_id = ? AND manga_id = ?').all(user.id, m[1])
|
||||
const progress = db.prepare('SELECT * FROM progress WHERE user_id = ? AND manga_id = ?').get(user.id, m[1]) ?? null
|
||||
const chapterLangs = chapters.length || manga.source === 'lc' || offline ? [] : await getChapterLangs(m[1]).catch(() => [])
|
||||
return json(res, 200, { manga, chapters, lang, reads, progress, chapterLangs, offline, downloaded, download: downloadInfo(m[1], true), upscale: upscale.info(m[1]), clips: clips.count(user.id, m[1]), ...features.mangaExtras(user.id, manga) })
|
||||
return json(res, 200, { manga, chapters, lang, reads, progress, chapterLangs, offline, downloaded, download: downloadInfo(m[1], true), upscale: upscale.info(m[1]), clips: clips.count(user.id, m[1]), lists: library.listsWith(user.id, m[1]), ...features.mangaExtras(user.id, manga) })
|
||||
}
|
||||
if ((m = /^\/api\/chapter\/([^/]+)$/.exec(p)) && CHAPTER_ID.test(m[1]) && method === 'GET') {
|
||||
const hdAvailable = !!upscale.hdFileOf(m[1])
|
||||
@@ -913,6 +949,7 @@ async function route(req, res) {
|
||||
ON CONFLICT (user_id, chapter_id) DO UPDATE SET page = excluded.page, total_pages = excluded.total_pages, updated_at = excluded.updated_at`)
|
||||
.run(user.id, b.chapterId, b.mangaId, page, total, now)
|
||||
features.onProgress(user.id, b, page, total, prevPage)
|
||||
library.onProgress(user.id, b, page, total)
|
||||
return json(res, 200, { ok: true })
|
||||
}
|
||||
if ((m = /^\/api\/history\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'DELETE') {
|
||||
@@ -925,9 +962,11 @@ async function route(req, res) {
|
||||
throw new HttpError(404, 'No encontrado')
|
||||
}
|
||||
|
||||
const security = setupSecurity({ db })
|
||||
const features = setupFeatures({ db, fetchMangaFull, enqueueDownload, downloadInfo, HttpError, json, readBody, MANGA_ID })
|
||||
const upscale = setupUpscale({ db, MANGA_DIR, HttpError, json, readBody, MANGA_ID })
|
||||
const covers = setupCovers({ DATA_DIR, upscale })
|
||||
const library = setupLibrary({ db, HttpError, json, readBody, MANGA_ID, CHAPTER_ID, fetchMangaFull, enqueueDownload, upscale, stats: features.stats, searchManga, mdCategories })
|
||||
const clips = setupClips({ db, DATA_DIR, HttpError, json, readBody, MANGA_ID, CHAPTER_ID, upscale })
|
||||
|
||||
// Migración: portadas guardadas sin &m= (reescalado de portadas) y con la miniatura de 256 px de MangaDex.
|
||||
@@ -949,7 +988,8 @@ http.createServer(async (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' })
|
||||
// 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, () => {
|
||||
|
||||
Reference in New Issue
Block a user