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:
2026-11-01 07:52:13 -06:00
co-authored by Claude Opus 5.5
parent 9086896a89
commit d3e196a2f3
16 changed files with 931 additions and 59 deletions
+11
View File
@@ -22,3 +22,14 @@ RENDER_GID=992
TZ=America/El_Salvador
CHECK_INTERVAL_HOURS=3
# --- Exponer a internet ---
# Baneo de IP: tras 6 intentos fallidos (contraseña o registro cerrado) en 60 min, 24 h bloqueada.
# La red local, loopback y Tailscale (100.64.0.0/10) nunca se bloquean. BAN_IGNORE: más IPs, separadas por comas.
BAN_MAX_FAILS=6
BAN_WINDOW_MIN=60
BAN_HOURS=24
# BAN_IGNORE=203.0.113.7
# Si hay un proxy inverso delante (recomendado, con HTTPS): cabecera con la IP real del cliente.
# Caddy/Nginx: x-forwarded-for · Cloudflare Tunnel: cf-connecting-ip. Sin proxy, déjalo vacío.
# REAL_IP_HEADER=x-forwarded-for
+28
View File
@@ -40,6 +40,17 @@ Lector web de manga autoalojado, con las dos fuentes de
(capítulo y página en curso, siguientes, tiempo restante, errores con "Reintentar", "Detener"),
recortes y portadas pendientes, y debajo la biblioteca descargada con su tamaño; todo en tarjetas
verticales con portada, como el historial.
- **Librería** (antes "Historial"): pestañas Leyendo, Siguiendo, Terminados, En el dispositivo y
listas propias (Favoritos, Pendientes, Abandonados y las que crees). Desde la ficha, el botón
"Listas" añade o quita el manga de cada lista.
- **Recomendado para ti** (inicio): populares de MangaDex con capítulos en tu idioma dentro de tus 3
géneros más leídos, sin lo que ya tienes en la librería.
- **Descarga anticipada** (Ajustes): al pasar la mitad de un capítulo, el servidor baja los N
siguientes (por defecto 2) a la copia local y, si se marca, los reescala a HD.
- **App instalable y lectura sin conexión**: manifest + service worker. En la ficha, "En este
dispositivo" guarda capítulos (páginas incluidas) en el navegador para leerlos sin internet; el
progreso leído sin red se envía al volver. **Requiere HTTPS** (o `localhost`): por `http://IP:puerto`
el navegador no permite service workers. Por ejemplo `tailscale serve` o un proxy con certificado.
- **Estadísticas**: rachas, capítulos y páginas por semana, calendario de actividad, géneros
favoritos y lo más leído.
- Las imágenes pasan por el servidor (`/api/img`), solo con URLs firmadas por él (clave en `data/secret`).
@@ -59,6 +70,23 @@ Sin `.env` arranca igual: puerto 3352 en todas las interfaces y descargas en `./
El primer usuario puede registrarse siempre; después el registro queda cerrado salvo
`ALLOW_SIGNUP=true` en `.env`.
## Exponerlo a internet
- **Pon HTTPS delante** (Caddy, Nginx, Cloudflare Tunnel o `tailscale funnel`): sin HTTPS la contraseña
y la cookie viajan en claro. Con HTTPS la cookie lleva `Secure` y se envía HSTS.
- Con un proxy inverso, define `REAL_IP_HEADER` en `.env` (`x-forwarded-for` en Caddy/Nginx,
`cf-connecting-ip` en Cloudflare) para que el baneo vea la IP real. Solo se cree esa cabecera si la
conexión llega desde una IP privada (el proxy), así que desde internet no se puede falsificar.
- **Baneo de IP**: 6 intentos fallidos (contraseña incorrecta, registro con el registro cerrado,
contraseña actual incorrecta) en 60 min bloquean la IP 24 h para toda la web. Ajustable con
`BAN_MAX_FAILS`, `BAN_WINDOW_MIN`, `BAN_HOURS`; red local, loopback y Tailscale nunca se bloquean.
En Ajustes se ven las IPs bloqueadas y se pueden desbloquear.
- Registro cerrado por defecto (`ALLOW_SIGNUP=false`); contraseñas de 8+ caracteres; cambiar la
contraseña cierra las demás sesiones.
- Cabeceras: CSP estricta (sin scripts en línea), `X-Frame-Options: DENY`, `nosniff`, `Referrer-Policy`.
- El proxy de imágenes solo descarga URLs firmadas por el servidor y nunca hacia IPs privadas
(comprobado también en cada redirección). Los errores internos no se muestran al cliente.
## Qué guardar / mover a otro servidor
| Qué | Dónde | Contenido |
+2 -2
View File
@@ -32,7 +32,7 @@ const GENRE_ES = {
// Etiquetas de formato que no son géneros.
const NOT_GENRE = new Set(['long strip', 'full color', 'web comic', 'official colored', 'fan colored', 'adaptation', 'anthology',
'award winning', 'doujinshi', 'oneshot', '4-koma', 'self-published', 'user created', 'manga', 'manhwa', 'manhua', 'webtoon'])
const genreName = raw => {
export const genreName = raw => {
const k = String(raw).trim().toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '')
if (NOT_GENRE.has(k)) return null
return GENRE_ES[k] || String(raw).trim()
@@ -374,5 +374,5 @@ export function setupFeatures({ db, fetchMangaFull, enqueueDownload, downloadInf
return false
}
return { route, onProgress, mangaExtras, unfollow, checkFollows }
return { route, onProgress, mangaExtras, unfollow, checkFollows, stats }
}
+166
View File
@@ -0,0 +1,166 @@
// Librería: listas propias del usuario, recomendaciones según sus géneros y descarga anticipada
// de los capítulos siguientes a lo que se está leyendo.
const PREFETCH_DEFAULT = { n: 2, hd: false }
const DEFAULT_LISTS = ['Favoritos', 'Pendientes', 'Abandonados']
const REC_TTL = 6 * 3600e3
import { genreName } from './features.js'
const norm = s => String(s).trim().toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '')
export function setupLibrary({ db, HttpError, json, readBody, MANGA_ID, fetchMangaFull, enqueueDownload, upscale, stats, searchManga, mdCategories }) {
db.exec(`
CREATE TABLE IF NOT EXISTS lists (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS list_items (
list_id INTEGER NOT NULL REFERENCES lists(id) ON DELETE CASCADE,
manga_id TEXT NOT NULL,
title TEXT NOT NULL,
cover TEXT,
added_at INTEGER NOT NULL,
PRIMARY KEY (list_id, manga_id)
);
`)
if (!db.prepare('PRAGMA table_info(users)').all().some(c => c.name === 'prefetch')) db.exec('ALTER TABLE users ADD COLUMN prefetch TEXT')
// ---------- Listas ----------
function listsOf(userId) {
let rows = db.prepare('SELECT id, name FROM lists WHERE user_id = ? ORDER BY id').all(userId)
if (!rows.length && !db.prepare('SELECT 1 FROM kv WHERE k = ?').get(`lists_init_${userId}`)) {
const ins = db.prepare('INSERT INTO lists (user_id, name, created_at) VALUES (?, ?, ?)')
for (const n of DEFAULT_LISTS) ins.run(userId, n, Date.now())
db.prepare('INSERT INTO kv (k, v) VALUES (?, ?)').run(`lists_init_${userId}`, '1')
rows = db.prepare('SELECT id, name FROM lists WHERE user_id = ? ORDER BY id').all(userId)
}
const count = db.prepare('SELECT COUNT(*) n FROM list_items WHERE list_id = ?')
return rows.map(l => ({ ...l, count: count.get(l.id).n }))
}
const ownList = (userId, id) => {
const l = db.prepare('SELECT * FROM lists WHERE id = ? AND user_id = ?').get(id, userId)
if (!l) throw new HttpError(404, 'Lista no encontrada')
return l
}
// Listas que contienen un manga (para la ficha).
const listsWith = (userId, mangaId) => db.prepare(`SELECT l.id FROM lists l JOIN list_items i ON i.list_id = l.id
WHERE l.user_id = ? AND i.manga_id = ?`).all(userId, mangaId).map(r => r.id)
// ---------- Descarga anticipada ----------
function prefetchCfg(userId) {
try { return { ...PREFETCH_DEFAULT, ...JSON.parse(db.prepare('SELECT prefetch FROM users WHERE id = ?').get(userId)?.prefetch || '{}') } } catch { return { ...PREFETCH_DEFAULT } }
}
const recent = new Map() // `${manga}|${chapter}` -> ts, para no repetir en cada guardado de progreso
// Al pasar la mitad de un capítulo, encola los N siguientes (y, si se pidió, su versión HD).
function onProgress(userId, b, page, total) {
const cfg = prefetchCfg(userId)
if (!cfg.n || !total || page < Math.floor(total / 2)) return
const key = `${b.mangaId}|${b.chapterId}|${cfg.n}|${cfg.hd}`
if (Date.now() - (recent.get(key) || 0) < 30 * 60e3) return
recent.set(key, Date.now())
if (recent.size > 2000) recent.delete(recent.keys().next().value)
const lang = b.mangaId.startsWith('lc~') ? 'es' : b.lang
fetchMangaFull(b.mangaId, lang).then(({ manga, chapters }) => {
const i = chapters.findIndex(c => c.id === b.chapterId)
if (i < 0) return
const next = chapters.slice(i + 1, i + 1 + cfg.n).map(c => c.id)
if (!next.length) return
const have = new Set(db.prepare('SELECT chapter_id FROM downloaded_chapters WHERE manga_id = ?').all(b.mangaId).map(r => r.chapter_id))
const missing = next.filter(id => !have.has(id))
if (missing.length) {
enqueueDownload(b.mangaId, manga.title, lang, missing)
console.log(`Descarga anticipada: ${manga.title} → ${missing.length} cap(s).`)
}
if (cfg.hd && upscale.available()) upscale.request(next)
}).catch(e => console.warn('Descarga anticipada:', e.message))
}
// ---------- Recomendaciones ----------
const recCache = new Map() // userId -> { at, data }
// Populares de MangaDex (por seguidores) en tus 3 géneros principales, con capítulos en tu idioma.
// LeerCapitulo no ordena sus géneros por popularidad, por eso la fuente es MangaDex.
async function recommendations(userId, refresh) {
const hit = recCache.get(userId)
if (hit && !refresh && Date.now() - hit.at < REC_TTL) return hit.data
const lang = db.prepare('SELECT lang FROM users WHERE id = ?').get(userId)?.lang || 'es-la'
const genres = stats(userId).genres.slice(0, 5)
const cats = (await mdCategories().catch(() => [])).filter(c => c.group === 'Géneros' || c.group === 'Temáticas')
const byName = new Map(cats.map(c => [norm(genreName(c.name) || c.name), c]))
const picked = genres.map(g => ({ g, cat: byName.get(norm(g.name)) })).filter(x => x.cat).slice(0, 3)
const mine = db.prepare(`SELECT manga_id, manga_title title FROM progress WHERE user_id = ? UNION SELECT manga_id, title FROM follows WHERE user_id = ?
UNION SELECT i.manga_id, i.title FROM list_items i JOIN lists l ON l.id = i.list_id WHERE l.user_id = ?`).all(userId, userId, userId)
const knownIds = new Set(mine.map(r => r.manga_id))
const knownTitles = new Set(mine.map(r => norm(r.title).replace(/[^a-z0-9]/g, '')))
// Puntuación: aparecer en varios de tus géneros y salir arriba en cada lista.
const score = new Map()
const page = refresh ? 1 + Math.floor(Math.random() * 3) : 1 // "otras recomendaciones": otra página
const results = await Promise.all(picked.map(({ cat }) => searchManga('', lang, 'popular', cat.id, page).catch(() => [])))
results.forEach((list, gi) => list.forEach((m, rank) => {
if (!m.id || knownIds.has(m.id) || knownTitles.has(norm(m.title).replace(/[^a-z0-9]/g, ''))) return
const s = score.get(m.id) || { m, pts: 0, why: [] }
s.pts += (picked.length - gi) * 2 + Math.max(0, 30 - rank) / 10 + 3
s.why.push(picked[gi].g.name)
score.set(m.id, s)
}))
const items = [...score.values()].sort((a, b) => b.pts - a.pts).slice(0, 18)
.map(({ m, why }) => ({ ...m, sub: `Por ${why.slice(0, 2).join(' y ')}` }))
const data = { genres: picked.map(x => ({ name: x.g.name, id: x.cat.id, source: 'md' })), items }
recCache.set(userId, { at: Date.now(), data })
return data
}
// ---------- Rutas ----------
async function route(p, method, q, req, res, user) {
let m
if (p === '/api/lists' && method === 'GET') return json(res, 200, listsOf(user.id)), true
if (p === '/api/lists' && method === 'POST') {
const b = await readBody(req)
const name = String(b.name || '').trim().slice(0, 60)
if (!name) throw new HttpError(400, 'Ponle un nombre a la lista')
if (db.prepare('SELECT COUNT(*) n FROM lists WHERE user_id = ?').get(user.id).n >= 50) throw new HttpError(400, 'Demasiadas listas')
const id = Number(db.prepare('INSERT INTO lists (user_id, name, created_at) VALUES (?, ?, ?)').run(user.id, name, Date.now()).lastInsertRowid)
return json(res, 200, { id, name, count: 0 }), true
}
if ((m = /^\/api\/lists\/(\d+)$/.exec(p))) {
const l = ownList(user.id, Number(m[1]))
if (method === 'GET') {
const items = db.prepare('SELECT manga_id, title, cover, added_at FROM list_items WHERE list_id = ? ORDER BY added_at DESC').all(l.id)
return json(res, 200, { id: l.id, name: l.name, items }), true
}
if (method === 'PATCH') {
const name = String((await readBody(req)).name || '').trim().slice(0, 60)
if (!name) throw new HttpError(400, 'Ponle un nombre a la lista')
db.prepare('UPDATE lists SET name = ? WHERE id = ?').run(name, l.id)
return json(res, 200, { ok: true }), true
}
if (method === 'DELETE') { db.prepare('DELETE FROM lists WHERE id = ?').run(l.id); return json(res, 200, { ok: true }), true }
}
if ((m = /^\/api\/lists\/(\d+)\/items$/.exec(p)) && method === 'POST') {
const l = ownList(user.id, Number(m[1]))
const b = await readBody(req)
if (!MANGA_ID.test(b.mangaId || '')) throw new HttpError(400, 'Manga inválido')
const cover = String(b.cover || '').slice(0, 1000)
db.prepare(`INSERT INTO list_items (list_id, manga_id, title, cover, added_at) VALUES (?, ?, ?, ?, ?)
ON CONFLICT (list_id, manga_id) DO UPDATE SET title = excluded.title, cover = excluded.cover`)
.run(l.id, b.mangaId, String(b.title || '').slice(0, 300) || b.mangaId, cover.startsWith('/api/') ? cover : null, Date.now())
return json(res, 200, { lists: listsWith(user.id, b.mangaId) }), true
}
if ((m = /^\/api\/lists\/(\d+)\/items\/([^/]+)$/.exec(p)) && method === 'DELETE') {
const l = ownList(user.id, Number(m[1]))
const mangaId = decodeURIComponent(m[2])
db.prepare('DELETE FROM list_items WHERE list_id = ? AND manga_id = ?').run(l.id, mangaId)
return json(res, 200, { lists: listsWith(user.id, mangaId) }), true
}
if (p === '/api/prefetch' && method === 'GET') return json(res, 200, prefetchCfg(user.id)), true
if (p === '/api/prefetch' && method === 'POST') {
const b = await readBody(req)
const n = Math.min(10, Math.max(0, Number(b.n) | 0))
db.prepare('UPDATE users SET prefetch = ? WHERE id = ?').run(JSON.stringify({ n, hd: !!b.hd }), user.id)
return json(res, 200, prefetchCfg(user.id)), true
}
if (p === '/api/recommendations' && method === 'GET') return json(res, 200, await recommendations(user.id, q.get('refresh') === '1')), true
return false
}
return { route, onProgress, listsOf, listsWith }
}
+402 -31
View File
@@ -38,6 +38,11 @@ const ICONS = {
scissors: '<circle cx="6" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M20 4 8.1 15.9M14.5 14.5 20 20M8.1 8.1 12 12"/>',
image: '<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-5-5L5 21"/>',
queue: '<path d="M3 6h13M3 12h13M3 18h9"/><path d="m17 15 3 3-3 3"/><path d="M20 18h-6"/>',
library: '<path d="M4 4v16M9 4v16"/><path d="m14 4.5 4.5 15.2"/><path d="M3 20h18"/>',
bookmark: '<path d="M6 3h12v18l-6-4-6 4z"/>',
phone: '<rect x="6" y="2" width="12" height="20" rx="2.5"/><path d="M11 18h2"/>',
plus: '<path d="M12 5v14M5 12h14"/>',
pencil: '<path d="M4 20h4L19 9l-4-4L4 16z"/><path d="m13.5 6.5 4 4"/>',
sparkles: '<path d="M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8z"/><path d="M19 15l.8 2.2L22 18l-2.2.8L19 21l-.8-2.2L16 18l2.2-.8z"/>',
}
const icon = name => `<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${ICONS[name]}</svg>`
@@ -89,6 +94,13 @@ let cleanup = null
// ---------- Utilidades ----------
const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]))
const $ = (sel, root = document) => root.querySelector(sel)
// Portadas que no cargan: icono de la app (la CSP no permite onerror="…" en el HTML).
document.addEventListener('error', e => {
const img = e.target
if (img.tagName !== 'IMG' || !img.hasAttribute('data-fallback') || img.dataset.failed) return
img.dataset.failed = '1'
img.src = '/icon.svg'
}, true)
const store = {
get(k, d) { try { return JSON.parse(localStorage.getItem(k)) ?? d } catch { return d } },
set(k, v) { try { localStorage.setItem(k, JSON.stringify(v)) } catch {} },
@@ -121,6 +133,107 @@ function timeAgo(ts) {
const chapLabel = c => (c.num ? `Cap. ${c.num}` : 'Oneshot') + (c.title ? ` — ${c.title}` : '')
const readUrl = (mangaId, chapterId, page) => `#/read/${mangaId}/${chapterId}${page != null ? `?page=${page + 1}` : ''}`
// ---------- Sin conexión (capítulos guardados en este dispositivo) ----------
// Requiere service worker, que el navegador solo permite con HTTPS (o en localhost).
const OFFLINE_OK = 'serviceWorker' in navigator && window.isSecureContext && 'caches' in window
const OFF_CACHE = 'offline-v1'
if (OFFLINE_OK) navigator.serviceWorker.register('/sw.js').catch(() => {})
const offIdx = () => store.get('offlineChapters', {}) // chapterId -> { mangaId, mangaTitle, cover, lang, label, num, pages, bytes, at }
const offHas = id => !!offIdx()[id]
const offState = { running: null, queue: [], listeners: new Set() }
const offEmit = () => offState.listeners.forEach(f => { try { f() } catch {} })
// Capítulos guardados agrupados por manga.
function offByManga() {
const g = new Map()
for (const [chapterId, c] of Object.entries(offIdx())) {
const m = g.get(c.mangaId) || { mangaId: c.mangaId, title: c.mangaTitle, cover: c.cover, chapters: [], bytes: 0, at: 0 }
m.chapters.push({ chapterId, ...c }); m.bytes += c.bytes || 0; m.at = Math.max(m.at, c.at)
g.set(c.mangaId, m)
}
return [...g.values()].sort((a, b) => b.at - a.at)
}
// items: [{ mangaId, mangaTitle, cover, lang, chapterId, label, num }]
function offEnqueue(items) {
for (const it of items) if (!offHas(it.chapterId) && !offState.queue.some(q => q.chapterId === it.chapterId) && offState.running?.chapterId !== it.chapterId) offState.queue.push(it)
navigator.storage?.persist?.().catch(() => {})
offEmit()
offRun()
}
async function offRun() {
if (offState.running) return
while (offState.queue.length) {
const it = offState.queue.shift()
offState.running = { ...it, done: 0, total: 0 }
offEmit()
try { await offSaveChapter(it) } catch (err) { toast(`No se pudo guardar ${it.label}: ${err.message}`) }
}
offState.running = null
offEmit()
}
async function offSaveChapter(it) {
const cache = await caches.open(OFF_CACHE)
const hd = store.get('readerSettings', {}).hd !== false
const chapUrl = `/api/chapter/${it.chapterId}?saver=0&hd=${hd ? 1 : 0}`
const res = await fetch(chapUrl)
if (!res.ok) throw new Error(`Error ${res.status}`)
const data = await res.clone().json()
await cache.put(chapUrl, res)
const mUrl = `/api/manga/${it.mangaId}?lang=${encodeURIComponent(it.lang || '')}`
const mres = await fetch(mUrl).catch(() => null)
if (mres?.ok) await cache.put(mUrl, mres)
if (it.cover) { const c = await fetch(it.cover).catch(() => null); if (c?.ok) await cache.put(it.cover, c) }
offState.running.total = data.pages.length
let bytes = 0, i = 0
const worker = async () => {
while (i < data.pages.length) {
const u = data.pages[i++]
const r = await fetch(u)
if (!r.ok) throw new Error(`página ${r.status}`)
const blob = await r.blob()
bytes += blob.size
await cache.put(u, new Response(blob, { headers: { 'Content-Type': r.headers.get('Content-Type') || 'image/jpeg' } }))
offState.running.done++
offEmit()
}
}
await Promise.all([worker(), worker(), worker()])
const idx = offIdx()
idx[it.chapterId] = { mangaId: it.mangaId, mangaTitle: it.mangaTitle, cover: it.cover, lang: it.lang, label: it.label, num: it.num ?? null, pages: data.pages.length, bytes, at: Date.now() }
store.set('offlineChapters', idx)
}
async function offRemove(chapterIds) {
const cache = await caches.open(OFF_CACHE)
const idx = offIdx()
for (const id of chapterIds) {
const hit = await cache.match(`/api/chapter/${id}`, { ignoreSearch: true })
if (hit) {
const data = await hit.json().catch(() => ({ pages: [] }))
await Promise.all((data.pages || []).map(u => cache.delete(u)))
await cache.delete(`/api/chapter/${id}`, { ignoreSearch: true })
}
const mangaId = idx[id]?.mangaId
delete idx[id]
if (mangaId && !Object.values(idx).some(c => c.mangaId === mangaId)) await cache.delete(`/api/manga/${mangaId}`, { ignoreSearch: true })
}
store.set('offlineChapters', idx)
offEmit()
}
// Progreso leído sin conexión: se guarda aquí y se envía al volver la red.
function queueProgress(p) { const q = store.get('pendingProgress', {}); q[p.mangaId] = p; store.set('pendingProgress', q) }
async function flushProgress() {
const q = store.get('pendingProgress', {})
for (const [k, p] of Object.entries(q)) {
try { await api('/api/progress', { method: 'POST', body: p }); delete q[k] } catch { if (!navigator.onLine) break; delete q[k] } // con red y error: descartarlo
}
store.set('pendingProgress', q)
}
window.addEventListener('online', flushProgress)
setTimeout(flushProgress, 3000)
// Instalar como app (Chrome/Edge/Android ofrecen el aviso; se guarda para el botón de Ajustes).
let installPrompt = null
window.addEventListener('beforeinstallprompt', e => { e.preventDefault(); installPrompt = e })
// ---------- Router ----------
let prevHash = '', curHash = '' // para volver atrás sin pasar por el lector
async function router() {
@@ -142,7 +255,8 @@ async function router() {
if (parts[0] === 'login') return (location.hash = '#/')
if (parts[0] === 'manga') return await viewManga(parts[1])
if (parts[0] === 'read') return await viewReader(parts[1], parts[2], Number(params.get('page') || 0) - 1)
if (parts[0] === 'history') return await viewHistory()
if (parts[0] === 'history') return (location.hash = '#/library') // antiguo nombre
if (parts[0] === 'library') return await viewLibrary(params)
if (parts[0] === 'downloads') return (location.hash = '#/queues') // antiguo nombre
if (parts[0] === 'queues') return await viewQueues()
if (parts[0] === 'stats') return await viewStats()
@@ -235,6 +349,20 @@ function bindNews() {
})
}
const tagHref = (source, t) => `#/?source=${source}&tag=${encodeURIComponent(t.id)}&tagName=${encodeURIComponent(t.name)}`
// Recomendaciones según los géneros que más lees (las calcula el servidor, caché de 6 h).
async function loadRecs(refresh = false) {
if (!$('#recs')) return
const r = await api(`/api/recommendations${refresh ? '?refresh=1' : ''}`).catch(() => null)
// La portada pudo redibujarse mientras tanto: usar la sección actual.
const box = $('#recs')
if (!box) return
if (!r?.items?.length) { box.innerHTML = ''; return }
box.innerHTML = `<div class="row rhead"><h2>${icon('sparkles')} Recomendado para ti</h2>
<span class="muted small">Según lo que más lees: ${r.genres.map(g => `<a href="${tagHref(g.source || 'md', g)}">${esc(g.name)}</a>`).join(', ')}</span>
<button class="btn small" id="recref" title="Otras recomendaciones">${icon('refresh')}</button></div>
<div class="recrow">${r.items.map(mangaCard).join('')}</div>`
$('#recref', box).addEventListener('click', e => { e.currentTarget.disabled = true; loadRecs(true) })
}
async function viewHome(params) {
const q = params.get('q') || ''
const tag = params.get('tag') || ''
@@ -246,6 +374,7 @@ async function viewHome(params) {
const showCats = mode === 'cats' && !tag
app.innerHTML = `
<section id="cont"></section>
<section id="recs"></section>
<div class="tabs" id="sources">${Object.entries(SOURCES).map(([k, v]) => `<button data-s="${k}" class="${k === source ? 'on' : ''}">${v}</button>`).join('')}</div>
<form class="search"><input type="search" name="q" placeholder="Buscar ${tag ? `en «${esc(tagName)}»` : `en ${esc(SOURCES[source])}`}…" value="${esc(q)}"><button class="btn primary">${icon('search')}<span class="lbl">Buscar</span></button></form>
<label class="muted row" style="font-size:.88rem" ${source === 'md' ? '' : 'hidden'}><input type="checkbox" name="any" ${anyLang ? 'checked' : ''}> Incluir mangas sin traducción a ${esc(LANGS[me.lang] || me.lang)}</label>
@@ -272,6 +401,7 @@ async function viewHome(params) {
$('#cont').innerHTML = `<div id="news">${newsSection(fol)}</div>` + (hist = hist.filter(h => !h.finished), hist.length ? `<h2>Continuar leyendo</h2><div class="continue">${hist.slice(0, 12).map(continueCard).join('')}</div>` : '')
bindNews(fol)
}).catch(() => {})
loadRecs()
}
const res = $('#results')
@@ -311,7 +441,7 @@ async function viewManga(id, langOverride) {
if (cleanup) { cleanup(); cleanup = null }
app.innerHTML = '<div class="loading">Cargando…</div>'
const lang = langOverride || store.get(`lang:${id}`, me.lang)
const { manga, chapters, reads, progress, chapterLangs, offline, downloaded, download, upscale, following, clips: clipCount = 0, newKeys = [] } = await api(`/api/manga/${id}?lang=${encodeURIComponent(lang)}`)
const { manga, chapters, reads, progress, chapterLangs, offline, downloaded, download, upscale, following, clips: clipCount = 0, lists: inLists = [], newKeys = [] } = await api(`/api/manga/${id}?lang=${encodeURIComponent(lang)}`)
const dlSet = new Set(downloaded)
const newSet = new Set(newKeys.map(String))
let isFollowing = following
@@ -343,11 +473,12 @@ async function viewManga(id, langOverride) {
<p class="desc">${esc(manga.description)}</p>
<div class="actions">${mainBtn}
<button class="btn" id="follow"></button>
${offline ? '' : `<details class="menu" id="lmenu"><summary class="btn" id="lbtn" title="Añadir a tus listas">${icon('bookmark')} Listas</summary><div class="menu-list" id="lmlist"><div class="muted note">Cargando…</div></div></details>`}
${menuItems ? `<details class="menu"><summary class="btn" title="Más opciones" aria-label="Más opciones">${icon('dots')}</summary><div class="menu-list">${menuItems}</div></details>` : ''}
</div>
<div class="facts muted"><span>Fuente: ${esc(SOURCES[manga.source])}</span>${download ? `<span>Carpeta: mangas/${esc(download.folder)}</span>` : ''}${clipCount ? `<a href="#/clips?manga=${encodeURIComponent(id)}">${icon('scissors')} ${clipCount} recorte${clipCount > 1 ? 's' : ''}</a>` : ''}</div>
${offline ? '<p class="error">Sin conexión con la fuente: mostrando solo los capítulos descargados.</p>' : ''}
<div class="libpanel"><div class="psec" id="dl"></div><div class="psec" id="hd" hidden></div></div>
<div class="libpanel"><div class="psec" id="dl"></div><div class="psec" id="hd" hidden></div><div class="psec" id="off" hidden></div></div>
</div>
</div>
<div class="list-head">
@@ -406,6 +537,18 @@ async function viewManga(id, langOverride) {
},
},
}
if (OFFLINE_OK) KINDS.off = {
verb: 'Guardar', ic: 'phone', modes: { range: 'Rango', pick: 'Elegir' },
candidates: () => chapters.filter(c => !offHas(c.id) && !offState.queue.some(q => q.chapterId === c.id) && offState.running?.chapterId !== c.id),
rangeBase: () => chapters,
mark: c => (offHas(c.id) ? ' (en el dispositivo)' : ''),
summary: ids => plural(ids.length, 'capítulo'),
offTitle: () => 'Ya está en el dispositivo',
run: async ids => {
offEnqueue(ids.map(cid => { const c = chapters.find(x => x.id === cid); return { mangaId: id, mangaTitle: manga.title, cover: manga.cover, lang, chapterId: cid, label: chapLabel(c), num: c.num } }))
toast(`Guardando ${plural(ids.length, 'capítulo')} en este dispositivo`)
},
}
const selIds = () => {
if (!sel) return []
const K = KINDS[sel.kind]
@@ -456,6 +599,7 @@ async function viewManga(id, langOverride) {
if (wasPick !== (next?.mode === 'pick') || (wasPick && prevKind !== next?.kind)) renderList()
$('#dlopen')?.classList.toggle('on', sel?.kind === 'dl')
$('#hdopen')?.classList.toggle('on', sel?.kind === 'hd')
$('#offopen')?.classList.toggle('on', sel?.kind === 'off')
updateSummary()
}
function renderSelForm(kind) {
@@ -612,6 +756,89 @@ async function viewManga(id, langOverride) {
if (changed) refreshSel()
}
// --- En este dispositivo (sin conexión).
const renderOff = () => {
const box = $('#off')
if (!box || !OFFLINE_OK) return
const mine = Object.entries(offIdx()).filter(([, c]) => c.mangaId === id)
const bytes = mine.reduce((n, [, c]) => n + (c.bytes || 0), 0)
const run = offState.running?.mangaId === id ? offState.running : null
const queued = offState.queue.filter(q => q.mangaId === id).length
box.hidden = false
// Siguientes 3 sin guardar a partir de donde vas (o desde el principio).
const from = Math.max(0, chapters.findIndex(c => c.id === progress?.chapter_id))
const nextIds = chapters.slice(from).filter(c => !offHas(c.id)).slice(0, 3)
let body = ''
if (run || queued) {
const pct = run?.total ? (run.done / run.total) * 100 : 0
body = `<div class="bar dlbar"><i style="width:${pct.toFixed(1)}%"></i></div>
<div class="note muted">${run ? `${esc(run.label)} · pág. ${run.done}/${run.total || '?'}` : 'Esperando…'}${queued ? ` · ${queued} más` : ''}</div>`
} else if (!mine.length) body = '<div class="note muted">Guarda capítulos en este móvil o PC para leerlos sin internet (también los no descargados en el servidor).</div>'
const actions = [
nextIds.length && !run ? `<button class="btn small" id="offnext">${icon('phone')} Guardar ${nextIds.length === 1 ? 'el siguiente' : `los ${nextIds.length} siguientes`}</button>` : '',
KINDS.off.candidates().length ? `<button class="btn small ${sel?.kind === 'off' ? 'on' : ''}" id="offopen">${icon('phone')} Elegir capítulos…</button>` : '',
mine.length ? `<button class="btn small danger push" id="offdel">${icon('trash')} Borrar del dispositivo</button>` : '',
].join('')
if (!$('#offtop')) box.innerHTML = '<div id="offtop" class="psec-grid"></div><div id="offform" class="hdform" hidden></div>'
$('#offtop').innerHTML = secHead('phone', 'En este dispositivo', mine.length ? `${plural(mine.length, 'capítulo')} · ${fmtSize(bytes)}` : '') + body + `<div class="psec-actions">${actions}</div>`
$('#offnext')?.addEventListener('click', () => KINDS.off.run(nextIds.map(c => c.id)))
$('#offopen')?.addEventListener('click', () => setSel(sel?.kind === 'off' ? null : { kind: 'off', mode: 'range' }))
$('#offdel')?.addEventListener('click', async () => {
if (!confirm(`¿Borrar de este dispositivo los ${plural(mine.length, 'capítulo')} guardados de «${manga.title}» (${fmtSize(bytes)})?`)) return
await offRemove(mine.map(([cid]) => cid))
toast('Borrado del dispositivo')
})
}
let offRefreshAt = 0
const onOff = () => {
if (!$('#off')) return
renderOff()
// La lista y el selector solo cuando cambia qué está guardado (no en cada página descargada).
const n = Object.keys(offIdx()).length + offState.queue.length + (offState.running ? 1 : 0)
if (n !== offRefreshAt) { offRefreshAt = n; refreshSel() }
}
if (OFFLINE_OK) offState.listeners.add(onOff)
// --- Listas propias (menú "Listas").
let listSet = new Set(inLists)
const renderListsBtn = () => {
const b = $('#lbtn')
if (!b) return
b.classList.toggle('on', listSet.size > 0)
b.innerHTML = `${icon('bookmark')} ${listSet.size ? `En ${plural(listSet.size, 'lista')}` : 'Listas'}`
}
renderListsBtn()
async function renderListsMenu() {
const box = $('#lmlist')
if (!box) return
const lists = await api('/api/lists').catch(() => [])
box.innerHTML = lists.map(l => `<label class="check lcheck"><input type="checkbox" data-list="${l.id}" ${listSet.has(l.id) ? 'checked' : ''}> ${esc(l.name)}</label>`).join('')
+ `<button id="lnew">${icon('plus')} Nueva lista…</button><a href="#/library">${icon('library')} Ver mi librería</a>`
box.querySelectorAll('[data-list]').forEach(x => x.addEventListener('change', async () => {
const lid = Number(x.dataset.list)
try {
const r = x.checked
? await api(`/api/lists/${lid}/items`, { method: 'POST', body: { mangaId: id, title: manga.title, cover: manga.cover } })
: await api(`/api/lists/${lid}/items/${encodeURIComponent(id)}`, { method: 'DELETE' })
listSet = new Set(r.lists)
renderListsBtn()
} catch (err) { toast(err.message); x.checked = !x.checked }
}))
$('#lnew').addEventListener('click', async () => {
const name = prompt('Nombre de la nueva lista:')
if (!name?.trim()) return
try {
const l = await api('/api/lists', { method: 'POST', body: { name } })
const r = await api(`/api/lists/${l.id}/items`, { method: 'POST', body: { mangaId: id, title: manga.title, cover: manga.cover } })
listSet = new Set(r.lists)
renderListsBtn()
renderListsMenu()
toast(`Añadido a «${l.name}»`)
} catch (err) { toast(err.message) }
})
}
$('#lmenu')?.addEventListener('toggle', e => { if (e.target.open) renderListsMenu() })
const renderFollow = () => {
const b = $('#follow')
b.innerHTML = isFollowing ? `${icon('bell')} Siguiendo` : `${icon('bellOff')} Seguir`
@@ -635,10 +862,9 @@ async function viewManga(id, langOverride) {
location.hash = back ? prevHash : '#/'
})
// Cerrar el menú ⋯ al pulsar fuera.
const menu = $('.menu', app)
const closeMenu = e => { if (menu && !menu.contains(e.target)) menu.open = false }
const closeMenu = e => app.querySelectorAll('.menu[open]').forEach(menu => { if (!menu.contains(e.target)) menu.open = false })
document.addEventListener('click', closeMenu)
cleanup = () => { clearTimeout(dlTimer); clearTimeout(hdTimer); document.removeEventListener('click', closeMenu) }
cleanup = () => { clearTimeout(dlTimer); clearTimeout(hdTimer); document.removeEventListener('click', closeMenu); offState.listeners.delete(onOff) }
app.querySelectorAll('[data-lang]').forEach(a => a.addEventListener('click', e => { e.preventDefault(); store.set(`lang:${id}`, a.dataset.lang); viewManga(id, a.dataset.lang) }))
const ul = $('.chapters', app)
@@ -647,6 +873,7 @@ async function viewManga(id, langOverride) {
+ (dlSet.has(c.id) ? `<span title="Descargado">${icon('download')}</span> ` : wantedSet.has(c.id) ? `<span class="queued" title="En cola de descarga">${icon('download')}…</span> ` : '')
+ (hdSet.has(c.id) ? '<span class="hd" title="Reescalado a HD">HD</span> '
: hdJobs[c.id] ? `<span class="hd pending" title="${hdJobs[c.id] === 'error' ? 'Falló el reescalado' : 'En cola para HD'}">HD${hdJobs[c.id] === 'error' ? '!' : '…'}</span> ` : '')
+ (OFFLINE_OK && offHas(c.id) ? `<span class="dev" title="Guardado en este dispositivo">${icon('phone')}</span> ` : '')
+ (r ? (done ? 'Leído' : `pág. ${r.page + 1}/${r.total_pages}`) : esc(c.group))
function renderList() {
if (!ul) return
@@ -685,6 +912,7 @@ async function viewManga(id, langOverride) {
renderList()
renderDl(download)
renderHd(upscale)
renderOff()
$('#order')?.addEventListener('click', () => { desc = !desc; lastPick = null; store.set('chapterOrderDesc', desc); renderList() })
$('#lang')?.addEventListener('change', e => { store.set(`lang:${id}`, e.target.value); viewManga(id, e.target.value) })
}
@@ -730,9 +958,9 @@ async function viewReader(mangaId, chapterId, startPage) {
chapterTitle: chapter.title, lang: manga.source === 'lc' ? 'es' : chapter.lang || lang, page: lastShown(), totalPages: total,
isLast: idx >= 0 && !next, // al terminarlo, el manga sale de "Continuar leyendo"
})
const saveNow = () => { clearTimeout(saveTimer); saveTimer = null; api('/api/progress', { method: 'POST', body: payload() }).catch(() => {}) }
const saveNow = () => { clearTimeout(saveTimer); saveTimer = null; const p = payload(); api('/api/progress', { method: 'POST', body: p }).catch(() => queueProgress(p)) }
const scheduleSave = () => { clearTimeout(saveTimer); saveTimer = setTimeout(saveNow, 700) }
const beacon = () => { if (saveTimer) { clearTimeout(saveTimer); saveTimer = null; navigator.sendBeacon('/api/progress', JSON.stringify(payload())) } }
const beacon = () => { if (saveTimer) { clearTimeout(saveTimer); saveTimer = null; navigator.onLine ? navigator.sendBeacon('/api/progress', JSON.stringify(payload())) : queueProgress(payload()) } }
const onVis = () => document.visibilityState === 'hidden' && beacon()
document.addEventListener('visibilitychange', onVis)
window.addEventListener('pagehide', beacon)
@@ -758,6 +986,7 @@ async function viewReader(mangaId, chapterId, startPage) {
${manga.source === 'lc' || chap.local ? '' : '<label class="row muted" style="font-size:.85rem"><input type="checkbox" id="saver"> Ahorro datos</label>'}
${chap.hdAvailable ? '<label class="row muted" style="font-size:.85rem" title="Usar la versión reescalada de este capítulo"><input type="checkbox" id="hd"> Versión HD</label>'
: chap.local && info.upscale?.available ? `<button class="btn small" id="hdone" title="Reescala este capítulo con IA en segundo plano (unos minutos)">${icon('sparkles')} Reescalar a HD</button>` : ''}
${OFFLINE_OK ? `<button class="btn small" id="offone" ${offHas(chapterId) ? 'disabled' : ''} title="Guarda las páginas en este dispositivo para leerlas sin internet">${icon('phone')} ${offHas(chapterId) ? 'En el dispositivo' : 'Guardar sin conexión'}</button>` : ''}
<p class="muted">Modo, ajuste y dirección se recuerdan para este manga. ${icon('scroll')} en la barra de arriba cambia entre páginas y vertical. Zoom: pellizca o toca dos veces en el centro (Ctrl+rueda o + / − en PC).</p>
</div>
<div id="pages"></div>
@@ -1055,6 +1284,14 @@ async function viewReader(mangaId, chapterId, startPage) {
try { await api(`/api/upscale/${mangaId}`, { method: 'POST', body: { chapterId } }); toast('En cola: estará en HD en unos minutos') } catch (err) { toast(err.message); e.target.closest('button').disabled = false }
})
$('#chap').addEventListener('change', e => { location.hash = readUrl(mangaId, e.target.value) })
$('#offone')?.addEventListener('click', e => {
const b = e.target.closest('button')
b.disabled = true
b.innerHTML = `${icon('phone')} Guardando…`
const done = () => { if (offHas(chapterId) && b.isConnected) { b.innerHTML = `${icon('phone')} En el dispositivo`; offState.listeners.delete(done) } }
offState.listeners.add(done)
offEnqueue([{ mangaId, mangaTitle: manga.title, cover: manga.cover, lang, chapterId, label: chapLabel(chapter), num: chapter.num }])
})
slider.addEventListener('input', () => goTo(Number(slider.value) - 1))
// --- Recortes: se arrastra un rectángulo sobre la página; se recorta del original (no de la versión HD)
@@ -1331,30 +1568,117 @@ async function viewClips(params) {
cleanup = () => { clearTimeout(timer); document.removeEventListener('keydown', onKey); document.body.classList.remove('noscroll') }
}
// ---------- Historial ----------
async function viewHistory() {
// ---------- Librería ----------
// Pestañas: Leyendo, Siguiendo, Terminados, En el dispositivo y las listas propias.
const libCard = ({ href, cover, title, sub, pct, badge, del }) => `<a class="card hcard" href="${href}">
<img class="cover" loading="lazy" src="${esc(cover || '/icon.svg')}" alt="" data-fallback>
${badge ? `<span class="badge corner">${badge}</span>` : ''}
${del ? `<button class="hdel" ${del.attrs} title="${esc(del.title)}" aria-label="${esc(del.title)}">${icon(del.icon || 'trash')}</button>` : ''}
<div class="bar" ${pct == null ? 'style="visibility:hidden"' : ''}><i style="width:${pct || 0}%"></i></div>
<div class="t">${esc(title)}</div>
${sub ? `<div class="sub">${sub}</div>` : ''}
</a>`
async function viewLibrary(params) {
const tab = params.get('tab') || 'reading'
app.innerHTML = '<div class="loading">Cargando…</div>'
const hist = await api('/api/history')
app.innerHTML = `<h1>Historial de lectura</h1>
${hist.length ? '' : '<p class="muted">Aún no has leído nada.</p>'}
<div class="grid">${hist.map(h => {
const pct = h.total_pages ? Math.round(((h.page + 1) / h.total_pages) * 100) : 0
return `<a class="card hcard" href="#/manga/${h.manga_id}">
<img class="cover" loading="lazy" src="${esc(h.cover || '/icon.svg')}" alt="">
${h.finished ? '<span class="badge corner">Terminado</span>' : h.hasNew ? '<span class="badge corner">Caps. nuevos</span>' : ''}
<button class="hdel" data-del="${h.manga_id}" title="Borrar del historial" aria-label="Borrar del historial">${icon('trash')}</button>
<div class="bar" title="${pct}% del capítulo"><i style="width:${pct}%"></i></div>
<div class="t">${esc(h.manga_title)}</div>
<div class="sub">${h.chapter_num ? `Cap. ${esc(h.chapter_num)}` : 'Oneshot'} · ${timeAgo(h.updated_at)}</div>
</a>`
}).join('')}</div>`
app.querySelectorAll('[data-del]').forEach(b => b.addEventListener('click', async e => {
const [hist, fol, lists] = await Promise.all([api('/api/history').catch(() => null), api('/api/follows').catch(() => null), api('/api/lists').catch(() => [])])
if (!location.hash.startsWith('#/library')) return
const online = !!hist
const dev = OFFLINE_OK ? offByManga() : []
const reading = (hist || []).filter(h => !h.finished)
const finished = (hist || []).filter(h => h.finished)
const follows = fol?.follows || []
const tabs = [
['reading', 'Leyendo', 'book', reading.length],
['following', 'Siguiendo', 'bell', follows.length],
['finished', 'Terminados', 'check', finished.length],
...(OFFLINE_OK && (dev.length || tab === 'device') ? [['device', 'En el dispositivo', 'phone', dev.length]] : []),
...lists.map(l => [`list-${l.id}`, l.name, 'bookmark', l.count]),
]
const curList = tab.startsWith('list-') ? lists.find(l => `list-${l.id}` === tab) : null
app.innerHTML = `<h1>Librería</h1>
<div class="tabs libtabs">${tabs.map(([k, label, ic, n]) => `<button data-tab="${k}" class="${k === tab ? 'on' : ''}">${icon(ic)} ${esc(label)}${n ? ` <span class="muted">${n}</span>` : ''}</button>`).join('')}
${online ? `<button id="newlist" title="Crear una lista">${icon('plus')} Nueva lista</button>` : ''}</div>
${curList ? `<div class="row lhead"><span class="muted small">Lista propia · añade mangas desde su ficha con ${icon('bookmark')} Listas</span>
<button class="btn small" id="lren">${icon('pencil')} Renombrar</button><button class="btn small danger" id="ldel">${icon('trash')} Eliminar lista</button></div>` : ''}
<div id="libbody"><div class="loading">Cargando…</div></div>`
app.querySelectorAll('[data-tab]').forEach(b => b.addEventListener('click', () => { location.hash = `#/library?tab=${b.dataset.tab}` }))
const body = $('#libbody')
const grid = (cards, empty) => (cards.length ? `<div class="grid">${cards.join('')}</div>` : `<p class="muted">${empty}</p>`)
const histCard = h => libCard({
href: `#/manga/${h.manga_id}`, cover: h.cover, title: h.manga_title,
pct: h.total_pages ? Math.round(((h.page + 1) / h.total_pages) * 100) : 0,
badge: h.finished ? (h.hasNew ? 'Caps. nuevos' : '') : h.hasNew ? 'Caps. nuevos' : '',
sub: `${h.chapter_num ? `Cap. ${esc(h.chapter_num)}` : 'Oneshot'} · ${timeAgo(h.updated_at)}`,
del: { attrs: `data-delhist="${h.manga_id}"`, title: 'Borrar del historial' },
})
if (!online && tab !== 'device') {
body.innerHTML = `<p class="muted">Sin conexión con el servidor.${dev.length ? ' Puedes leer lo guardado en este dispositivo:' : ''}</p>`
+ (dev.length ? `<p><a class="btn" href="#/library?tab=device">${icon('phone')} En el dispositivo</a></p>` : '')
} else if (tab === 'reading') body.innerHTML = grid(reading.map(histCard), 'No estás leyendo nada ahora mismo.')
else if (tab === 'finished') body.innerHTML = grid(finished.map(histCard), 'Aún no has terminado ningún manga.')
else if (tab === 'following') {
body.innerHTML = grid(follows.map(f => libCard({
href: `#/manga/${f.manga_id}`, cover: f.cover, title: f.title, badge: f.newCount ? `${f.newCount} nuevo${f.newCount > 1 ? 's' : ''}` : '',
sub: f.last_new_at ? `Último capítulo nuevo ${timeAgo(f.last_new_at)}` : 'Sin novedades aún',
del: { attrs: `data-unfollow="${f.manga_id}"`, title: 'Dejar de seguir', icon: 'bellOff' },
})), 'No sigues ningún manga. Pulsa «Seguir» en su ficha para recibir avisos de capítulos nuevos.')
} else if (tab === 'device') {
const total = dev.reduce((n, m) => n + m.bytes, 0)
body.innerHTML = (dev.length ? `<p class="muted small">${plural(dev.reduce((n, m) => n + m.chapters.length, 0), 'capítulo')} · ${fmtSize(total)} en este dispositivo. Se leen sin internet.</p>` : '')
+ grid(dev.map(m => {
const last = m.chapters.sort((a, b) => (Number(a.num) || 0) - (Number(b.num) || 0))
return libCard({
href: `#/manga/${m.mangaId}`, cover: m.cover, title: m.title,
sub: `${plural(m.chapters.length, 'capítulo')} · ${fmtSize(m.bytes)}<br>${esc(last.map(c => c.num ?? '·').slice(0, 8).join(', '))}${last.length > 8 ? '…' : ''}`,
del: { attrs: `data-deldev="${m.mangaId}"`, title: 'Borrar del dispositivo' },
})
}), 'No hay nada guardado en este dispositivo. En la ficha de un manga, «En este dispositivo» guarda capítulos para leerlos sin internet.')
} else if (curList) {
const l = await api(`/api/lists/${curList.id}`).catch(err => (toast(err.message), { items: [] }))
body.innerHTML = grid(l.items.map(it => libCard({
href: `#/manga/${it.manga_id}`, cover: it.cover, title: it.title, sub: `Añadido ${timeAgo(it.added_at)}`,
del: { attrs: `data-unlist="${it.manga_id}"`, title: 'Quitar de la lista', icon: 'x' },
})), 'Esta lista está vacía. Añade mangas desde su ficha con el botón «Listas».')
} else body.innerHTML = '<p class="muted">Esa lista ya no existe.</p>'
const reload = () => viewLibrary(params)
body.addEventListener('click', async e => {
const b = e.target.closest('button.hdel')
if (!b) return
e.preventDefault()
e.stopPropagation()
if (!confirm('¿Borrar el historial de este manga?')) return
await api(`/api/history/${b.dataset.del}`, { method: 'DELETE' })
viewHistory()
}))
try {
if (b.dataset.delhist) {
if (!confirm('¿Borrar el historial de este manga?')) return
await api(`/api/history/${b.dataset.delhist}`, { method: 'DELETE' })
} else if (b.dataset.unfollow) {
await api(`/api/follow/${b.dataset.unfollow}`, { method: 'DELETE' })
toast('Ya no sigues este manga')
} else if (b.dataset.deldev) {
const m = dev.find(x => x.mangaId === b.dataset.deldev)
if (!confirm(`¿Borrar de este dispositivo los ${plural(m.chapters.length, 'capítulo')} de «${m.title}» (${fmtSize(m.bytes)})?`)) return
await offRemove(m.chapters.map(c => c.chapterId))
} else if (b.dataset.unlist) {
await api(`/api/lists/${curList.id}/items/${encodeURIComponent(b.dataset.unlist)}`, { method: 'DELETE' })
}
reload()
} catch (err) { toast(err.message) }
})
$('#newlist')?.addEventListener('click', async () => {
const name = prompt('Nombre de la nueva lista:')
if (!name?.trim()) return
try { const l = await api('/api/lists', { method: 'POST', body: { name } }); location.hash = `#/library?tab=list-${l.id}` } catch (err) { toast(err.message) }
})
$('#lren')?.addEventListener('click', async () => {
const name = prompt('Nuevo nombre de la lista:', curList.name)
if (!name?.trim()) return
try { await api(`/api/lists/${curList.id}`, { method: 'PATCH', body: { name } }); reload() } catch (err) { toast(err.message) }
})
$('#ldel')?.addEventListener('click', async () => {
if (!confirm(`¿Eliminar la lista «${curList.name}»? Los mangas no se tocan.`)) return
try { await api(`/api/lists/${curList.id}`, { method: 'DELETE' }); location.hash = '#/library' } catch (err) { toast(err.message) }
})
}
// ---------- Descargas ----------
@@ -1402,7 +1726,7 @@ async function viewQueues() {
// Tarjeta vertical (como en Historial): portada, barra de progreso, título, detalles y acciones.
const qcard = ({ key, id, cover, badge, pct, title, lines, actions = '', del = '' }) => `
<div class="card hcard qcard" data-key="${esc(key)}">
<a href="#/manga/${id}"><img class="cover" loading="lazy" src="${esc(cover || '/icon.svg')}" alt="" onerror="this.onerror=null;this.dataset.failed=1;this.src='/icon.svg'"></a>
<a href="#/manga/${id}"><img class="cover" loading="lazy" src="${esc(cover || '/icon.svg')}" alt="" data-fallback></a>
${badge ? `<span class="qpos corner">${badge}</span>` : ''}${del}
<div class="bar" ${pct == null ? 'style="visibility:hidden"' : ''}><i style="width:${(pct || 0).toFixed(1)}%"></i></div>
<a class="t" href="#/manga/${id}">${esc(title)}</a>
@@ -1600,8 +1924,22 @@ function viewSettings() {
<section><b>Cambiar contraseña</b>
<form id="pw" style="display:grid;gap:8px">
<input type="password" name="old" placeholder="Contraseña actual" autocomplete="current-password" required>
<input type="password" name="new" placeholder="Nueva contraseña" autocomplete="new-password" required minlength="6">
<input type="password" name="new" placeholder="Nueva contraseña" autocomplete="new-password" required minlength="8">
<button class="btn primary">${icon('key')} Guardar</button></form></section>
<section><b>${icon('download')} Descarga anticipada</b>
<p class="muted" style="margin:0;font-size:.88rem">Al pasar la mitad de un capítulo, el servidor descarga los siguientes en segundo plano para que abran al instante (y estén en la copia local).</p>
<div class="row" style="flex-wrap:wrap;gap:10px">
<select id="pfn">${[[0, 'Desactivada'], [1, 'El siguiente capítulo'], [2, '2 capítulos siguientes'], [3, '3 capítulos siguientes'], [5, '5 capítulos siguientes']].map(([v, l]) => `<option value="${v}">${l}</option>`).join('')}</select>
<label class="row muted" style="font-size:.88rem"><input type="checkbox" id="pfhd"> Reescalarlos también a HD</label>
</div></section>
<section><b>${icon('phone')} App y lectura sin conexión</b>
${OFFLINE_OK ? `<p class="muted" style="margin:0;font-size:.88rem">Capítulos guardados en este dispositivo: <b id="offsum">…</b>. Se guardan desde la ficha de cada manga («En este dispositivo») o desde el lector.</p>
<div class="row" style="flex-wrap:wrap;gap:8px"><a class="btn small" href="#/library?tab=device">${icon('library')} Ver lo guardado</a>
<button class="btn small danger" id="offclear">${icon('trash')} Borrar todo del dispositivo</button>
<button class="btn small" id="install" hidden>${icon('download')} Instalar como app</button></div>`
: `<p class="muted" style="margin:0;font-size:.88rem">Instalar la app y leer sin conexión necesita que la página se abra por <b>HTTPS</b> (el navegador no lo permite en <code>http://${esc(location.host)}</code>). Por ejemplo con <code>tailscale serve</code> o un proxy inverso con certificado.</p>`}
</section>
<section id="secsec"><b>${icon('key')} Seguridad: IPs bloqueadas</b><div id="bans" class="muted" style="font-size:.88rem">Cargando…</div></section>
<section><b>${icon('bell')} Avisos de capítulos nuevos</b>
<p class="muted" style="margin:0;font-size:.88rem">El servidor revisa cada pocas horas los mangas que sigues (los que empiezas a leer se siguen solos).
Si un manga tiene copia local, los capítulos nuevos se descargan automáticamente. Además puedes recibir una notificación:</p>
@@ -1635,6 +1973,39 @@ function viewSettings() {
viewSettings()
} catch (err) { toast(err.message) }
})
const renderBans = async () => {
const b = await api('/api/bans').catch(() => null)
const box = $('#bans')
if (!box || !b) return
box.innerHTML = `<p style="margin:0">Tras ${b.maxFails} intentos fallidos en ${b.windowMin} min (contraseña incorrecta o registro cerrado), la IP queda bloqueada ${b.banHours} h. La red local y Tailscale no se bloquean nunca.</p>
${b.bans.length ? `<div class="banlist">${b.bans.map(x => `<div class="row"><code>${esc(x.ip)}</code><span class="muted small" style="flex:1">hasta ${new Date(x.until).toLocaleString('es')} · ${esc(x.reason || '')}</span>
<button class="btn small" data-unban="${esc(x.ip)}">Desbloquear</button></div>`).join('')}</div>` : '<p style="margin:6px 0 0">Ninguna IP bloqueada.</p>'}
<p class="muted small" style="margin:6px 0 0">Tu IP vista por el servidor: <code>${esc(b.you.ip)}</code>${b.you.protected ? ' (red local, nunca se bloquea)' : ''}${b.trustedHeader ? ` · leída de <code>${esc(b.trustedHeader)}</code>` : ''}</p>
${b.fails.length ? `<p style="margin:6px 0 0">Intentos fallidos recientes: ${b.fails.map(f => `<code>${esc(f.ip)}</code> ×${f.n}`).join(', ')}</p>` : ''}`
box.querySelectorAll('[data-unban]').forEach(x => x.addEventListener('click', async () => {
try { await api(`/api/bans/${encodeURIComponent(x.dataset.unban)}`, { method: 'DELETE' }); toast('IP desbloqueada'); renderBans() } catch (err) { toast(err.message) }
}))
}
renderBans()
api('/api/prefetch').then(c => { if (!$('#pfn')) return; $('#pfn').value = String(c.n); $('#pfhd').checked = c.hd }).catch(() => {})
const savePf = () => api('/api/prefetch', { method: 'POST', body: { n: Number($('#pfn').value), hd: $('#pfhd').checked } }).then(() => toast('Descarga anticipada guardada')).catch(err => toast(err.message))
$('#pfn').addEventListener('change', savePf)
$('#pfhd').addEventListener('change', savePf)
if (OFFLINE_OK) {
const dev = offByManga()
$('#offsum').textContent = dev.length ? `${plural(dev.reduce((n, m) => n + m.chapters.length, 0), 'capítulo')} de ${plural(dev.length, 'manga')} · ${fmtSize(dev.reduce((n, m) => n + m.bytes, 0))}` : 'ninguno'
$('#offclear').hidden = !dev.length
$('#offclear').addEventListener('click', async () => {
if (!confirm('¿Borrar todos los capítulos guardados en este dispositivo?')) return
await caches.delete(OFF_CACHE)
store.set('offlineChapters', {})
toast('Borrado')
viewSettings()
})
const inst = $('#install')
inst.hidden = !installPrompt
inst.addEventListener('click', async () => { installPrompt?.prompt(); installPrompt = null; inst.hidden = true })
}
$('#ntest').addEventListener('click', async () => {
try { await api('/api/notify/test', { method: 'POST' }); toast('Notificación de prueba enviada') } catch (err) { toast(err.message) }
})
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

+7 -1
View File
@@ -6,6 +6,12 @@
<meta name="theme-color" content="#14121a">
<title>Manga Reader</title>
<link rel="icon" href="/icon.svg">
<link rel="manifest" href="/manifest.webmanifest">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Mangas">
<link rel="stylesheet" href="/style.css">
</head>
<body>
@@ -13,7 +19,7 @@
<a href="#/" class="brand"><img src="/icon.svg" alt="" width="26" height="26"> <span>Manga Reader</span></a>
<nav>
<a href="#/" data-icon="home">Inicio</a>
<a href="#/history" data-icon="history">Historial</a>
<a href="#/library" data-icon="library">Librería</a>
<a href="#/queues" data-icon="queue">Colas</a>
<a href="#/clips" data-icon="scissors">Recortes</a>
<a href="#/stats" data-icon="chart">Estadísticas</a>
+17
View File
@@ -0,0 +1,17 @@
{
"name": "Manga Reader",
"short_name": "Mangas",
"description": "Lector de manga autoalojado",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#14121a",
"theme_color": "#14121a",
"lang": "es",
"icons": [
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" },
{ "src": "/icon.svg", "sizes": "any", "type": "image/svg+xml" }
]
}
+20 -2
View File
@@ -56,7 +56,8 @@ input:focus, select:focus { border-color: var(--accent); }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 14px; }
.card { position: relative; display: block; border-radius: var(--radius); overflow: hidden; background: var(--surface); }
.card .cover { aspect-ratio: 2 / 3; width: 100%; object-fit: cover; background: var(--surface-2); }
.card .t { padding: 8px 10px 10px; font-size: .9rem; line-height: 1.3; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
/* Margen (no padding) para que el recorte de 2 líneas no deje asomar la 3.ª */
.card .t { padding: 0; margin: 8px 10px 10px; font-size: .9rem; line-height: 1.3; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.card .sub { padding: 0 10px 10px; font-size: .8rem; color: var(--muted); margin-top: -6px; }
.bar { height: 4px; background: var(--surface-2); }
.bar > i { display: block; height: 100%; background: var(--accent); }
@@ -306,7 +307,7 @@ body.noscroll { overflow: hidden; }
/* Historial: tarjetas verticales */
.hcard .bar { margin: 0; height: 5px; }
.hcard .t { padding-bottom: 2px; }
.hcard .t { margin-bottom: 4px; }
.hcard .sub { margin-top: 0; }
.hcard .corner { position: absolute; top: 8px; left: 8px; }
.hdel { position: absolute; top: 6px; right: 6px; display: grid; place-items: center; width: 32px; height: 32px; border-radius: 8px; border: 0;
@@ -328,3 +329,20 @@ body.noscroll { overflow: hidden; }
.qpos .ic { color: var(--accent); width: 15px; height: 15px; }
.small { font-size: .82rem; }
@media (max-width: 640px) { .qgrid { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); } }
/* Librería */
.libtabs button .muted { font-size: .78rem; }
.lhead { gap: 8px; flex-wrap: wrap; margin: -4px 0 12px; }
.lhead .small { flex: 1; min-width: 200px; }
.lcheck { padding: 8px 10px; border-radius: 8px; cursor: pointer; }
.lcheck:hover { background: var(--surface-2); }
.chapters .meta .dev .ic { color: var(--accent); }
/* Recomendaciones: fila desplazable */
.rhead { gap: 10px; flex-wrap: wrap; align-items: baseline; }
.rhead h2 { margin-bottom: 4px; }
.rhead > span.small { flex: 1; min-width: 180px; }
.rhead .small a { color: var(--accent); }
.recrow { display: grid; grid-auto-flow: column; grid-auto-columns: 140px; gap: 12px; overflow-x: auto; padding-bottom: 8px; scroll-snap-type: x mandatory; }
.recrow .card { scroll-snap-align: start; }
@media (max-width: 640px) { .recrow { grid-auto-columns: 115px; gap: 10px; } }
.banlist { display: grid; gap: 6px; margin-top: 8px; } .banlist .row { gap: 8px; flex-wrap: wrap; }
+48
View File
@@ -0,0 +1,48 @@
// Service worker: la app abre sin conexión (shell en caché) y los capítulos guardados "en este
// dispositivo" (caché OFFLINE, la llena app.js) se leen sin red.
const SHELL = 'shell-v1'
const OFFLINE = 'offline-v1'
const SHELL_FILES = ['/', '/app.js', '/style.css', '/icon.svg', '/manifest.webmanifest', '/icon-192.png', '/icon-512.png']
self.addEventListener('install', e => {
e.waitUntil(caches.open(SHELL).then(c => c.addAll(SHELL_FILES)).then(() => self.skipWaiting()))
})
self.addEventListener('activate', e => {
e.waitUntil(caches.keys()
.then(keys => Promise.all(keys.filter(k => k !== SHELL && k !== OFFLINE).map(k => caches.delete(k))))
.then(() => self.clients.claim()))
})
// Red primero (así siempre se ve la última versión) y la caché si no hay conexión.
async function networkFirst(req, cacheName, { store = true, ignoreSearch = false, fallback } = {}) {
try {
const res = await fetch(req)
if (store && res.ok) {
const c = await caches.open(cacheName)
if (store === true || (await c.match(req, { ignoreSearch }))) c.put(req, res.clone())
}
return res
} catch (err) {
const hit = await caches.match(req, { ignoreSearch, cacheName }) || (fallback && await caches.match(fallback))
if (hit) return hit
throw err
}
}
self.addEventListener('fetch', e => {
const req = e.request
if (req.method !== 'GET') return
const url = new URL(req.url)
if (url.origin !== location.origin) return
const p = url.pathname
if (req.mode === 'navigate') return e.respondWith(networkFirst(req, SHELL, { fallback: '/' }))
if (SHELL_FILES.includes(p) || p === '/sw.js') return e.respondWith(networkFirst(req, SHELL))
if (p === '/api/me') return e.respondWith(networkFirst(req, SHELL))
// Ficha y capítulo: solo se guardan si el usuario los guardó (store: 'existing' = refrescar si ya estaban).
if (p.startsWith('/api/manga/')) return e.respondWith(networkFirst(req, OFFLINE, { store: 'existing', ignoreSearch: true }))
if (p.startsWith('/api/chapter/')) return e.respondWith(networkFirst(req, OFFLINE, { store: false, ignoreSearch: true }))
// Imágenes: si están guardadas, sin red (más rápido y sin gastar datos).
if (p.startsWith('/api/local/') || p.startsWith('/api/img') || p.startsWith('/api/local-cover/')) {
e.respondWith(caches.open(OFFLINE).then(c => c.match(req)).then(hit => hit || fetch(req)))
}
})
+147
View File
@@ -0,0 +1,147 @@
// Seguridad para exponer el servidor a internet: IP real del cliente (detrás de un proxy de
// confianza), baneo de IP tras varios intentos fallidos, cabeceras de seguridad y protección SSRF
// del proxy de imágenes.
import net from 'node:net'
import dns from 'node:dns/promises'
const MAX_FAILS = Number(process.env.BAN_MAX_FAILS || 6) // intentos fallidos…
const FAIL_WINDOW = Number(process.env.BAN_WINDOW_MIN || 60) * 60e3 // …dentro de esta ventana
const BAN_MS = Number(process.env.BAN_HOURS || 24) * 3600e3 // duración del baneo
// Cabecera con la IP real que pone el proxy (p. ej. x-forwarded-for, cf-connecting-ip, x-real-ip).
// Solo se cree si la conexión viene de una IP privada (el proxy), nunca directamente de internet.
const REAL_IP_HEADER = (process.env.REAL_IP_HEADER || '').toLowerCase().trim()
const PRIVATE_V4 = [['10.0.0.0', 8], ['127.0.0.0', 8], ['169.254.0.0', 16], ['172.16.0.0', 12], ['192.168.0.0', 16], ['100.64.0.0', 10], ['0.0.0.0', 8]]
const v4num = ip => ip.split('.').reduce((n, x) => n * 256 + Number(x), 0)
export function isPrivateIp(ip) {
if (!ip) return true
ip = ip.replace(/^::ffff:/i, '')
if (net.isIPv4(ip)) {
const n = v4num(ip)
return PRIVATE_V4.some(([base, bits]) => Math.floor(n / 2 ** (32 - bits)) === Math.floor(v4num(base) / 2 ** (32 - bits)))
}
const l = ip.toLowerCase()
return l === '::1' || l === '::' || /^f[cd]/.test(l) || /^fe[89ab]/.test(l)
}
// IPs que nunca se banean: loopback, red local, Tailscale y lo que se añada en BAN_IGNORE.
const IGNORE = (process.env.BAN_IGNORE || '').split(',').map(s => s.trim()).filter(Boolean)
const neverBan = ip => isPrivateIp(ip) || IGNORE.includes(ip)
export function setupSecurity({ db }) {
db.exec(`
CREATE TABLE IF NOT EXISTS ip_bans (
ip TEXT PRIMARY KEY,
until INTEGER NOT NULL,
reason TEXT,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS ip_fails (
ip TEXT NOT NULL,
at INTEGER NOT NULL,
what TEXT
);
CREATE INDEX IF NOT EXISTS idx_ip_fails ON ip_fails(ip, at);
`)
const prune = () => {
db.prepare('DELETE FROM ip_bans WHERE until < ?').run(Date.now())
db.prepare('DELETE FROM ip_fails WHERE at < ?').run(Date.now() - Math.max(FAIL_WINDOW, 24 * 3600e3))
}
prune()
setInterval(prune, 3600e3).unref()
function clientIp(req) {
const direct = (req.socket.remoteAddress || '').replace(/^::ffff:/i, '')
if (REAL_IP_HEADER && isPrivateIp(direct)) {
// x-forwarded-for: "cliente, proxy1, proxy2". Cada proxy añade al final la IP que le habló, así
// que se recorre desde la derecha saltando los proxies propios (IPs privadas: VPS por Tailscale,
// Nginx Proxy Manager en Docker…). La primera pública es el cliente real; lo que haya más a la
// izquierda lo pudo escribir el propio cliente y no se cree.
const chain = String(req.headers[REAL_IP_HEADER] || '').split(',').map(s => s.trim().replace(/^::ffff:/i, '')).filter(s => net.isIP(s))
const ip = chain.findLast(s => !isPrivateIp(s)) ?? chain[0]
if (ip) return ip
}
return direct
}
// HTTPS real (directo o por el proxy de confianza): cookie Secure y HSTS.
const isHttps = req => !!req.socket.encrypted || (isPrivateIp(req.socket.remoteAddress) && String(req.headers['x-forwarded-proto'] || '').split(',')[0].trim() === 'https')
const banned = ip => db.prepare('SELECT until FROM ip_bans WHERE ip = ? AND until > ?').get(ip, Date.now())
// Intento fallido (contraseña incorrecta, registro cerrado…). Al llegar a MAX_FAILS, baneo.
function fail(ip, what) {
if (neverBan(ip)) return
const now = Date.now()
db.prepare('INSERT INTO ip_fails (ip, at, what) VALUES (?, ?, ?)').run(ip, now, what)
const n = db.prepare('SELECT COUNT(*) n FROM ip_fails WHERE ip = ? AND at > ?').get(ip, now - FAIL_WINDOW).n
if (n >= MAX_FAILS) {
db.prepare(`INSERT INTO ip_bans (ip, until, reason, created_at) VALUES (?, ?, ?, ?)
ON CONFLICT (ip) DO UPDATE SET until = excluded.until, reason = excluded.reason, created_at = excluded.created_at`)
.run(ip, now + BAN_MS, `${n} intentos fallidos (${what})`, now)
db.prepare('DELETE FROM ip_fails WHERE ip = ?').run(ip)
console.warn(`Seguridad: IP ${ip} baneada ${BAN_MS / 3600e3} h tras ${n} intentos fallidos (${what})`)
}
}
const success = ip => db.prepare('DELETE FROM ip_fails WHERE ip = ?').run(ip)
const remaining = ip => (neverBan(ip) ? null : Math.max(0, MAX_FAILS - db.prepare('SELECT COUNT(*) n FROM ip_fails WHERE ip = ? AND at > ?').get(ip, Date.now() - FAIL_WINDOW).n))
// Cabeceras para todas las respuestas.
function headers(req, res) {
res.setHeader('X-Content-Type-Options', 'nosniff')
res.setHeader('X-Frame-Options', 'DENY')
res.setHeader('Referrer-Policy', 'same-origin')
res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=(), payment=()')
res.setHeader('Cross-Origin-Opener-Policy', 'same-origin')
res.setHeader('Content-Security-Policy', [
"default-src 'self'", "script-src 'self'", "style-src 'self' 'unsafe-inline'", "img-src 'self' data: blob:",
"connect-src 'self'", "worker-src 'self'", "manifest-src 'self'", "font-src 'self'",
"object-src 'none'", "base-uri 'none'", "form-action 'self'", "frame-ancestors 'none'",
].join('; '))
if (isHttps(req)) res.setHeader('Strict-Transport-Security', 'max-age=15552000')
}
function route(p, method, req, res, json) {
if (p === '/api/bans' && method === 'GET') {
return json(res, 200, {
bans: db.prepare('SELECT ip, until, reason, created_at FROM ip_bans WHERE until > ? ORDER BY created_at DESC').all(Date.now()),
fails: db.prepare('SELECT ip, COUNT(*) n, MAX(at) last FROM ip_fails WHERE at > ? GROUP BY ip ORDER BY last DESC LIMIT 50').all(Date.now() - FAIL_WINDOW),
maxFails: MAX_FAILS, banHours: BAN_MS / 3600e3, windowMin: FAIL_WINDOW / 60e3, trustedHeader: REAL_IP_HEADER || null,
you: { ip: clientIp(req), protected: neverBan(clientIp(req)) },
}), true
}
const m = /^\/api\/bans\/([^/]+)$/.exec(p)
if (m && method === 'DELETE') {
const ip = decodeURIComponent(m[1])
db.prepare('DELETE FROM ip_bans WHERE ip = ?').run(ip)
db.prepare('DELETE FROM ip_fails WHERE ip = ?').run(ip)
return json(res, 200, { ok: true }), true
}
return false
}
return { clientIp, isHttps, banned, fail, success, remaining, headers, route }
}
// ---------- SSRF: el proxy de imágenes no debe alcanzar la red interna ----------
const dnsCache = new Map()
async function hostIsPublic(hostname) {
if (net.isIP(hostname)) return !isPrivateIp(hostname)
if (/^(localhost|.*\.local|.*\.internal|.*\.lan)$/i.test(hostname)) return false
const hit = dnsCache.get(hostname)
if (hit && hit.exp > Date.now()) return hit.ok
const addrs = await dns.lookup(hostname, { all: true }).catch(() => [])
const ok = addrs.length > 0 && addrs.every(a => !isPrivateIp(a.address))
dnsCache.set(hostname, { ok, exp: Date.now() + 5 * 60e3 })
if (dnsCache.size > 1000) dnsCache.delete(dnsCache.keys().next().value)
return ok
}
// fetch que solo va a hosts públicos y comprueba también cada redirección.
export async function safeFetch(url, opts = {}, redirects = 4) {
for (let i = 0; ; i++) {
const u = new URL(url)
if (!/^https?:$/.test(u.protocol) || !(await hostIsPublic(u.hostname))) throw new Error('Host no permitido')
const r = await fetch(u, { ...opts, redirect: 'manual' })
const loc = r.status >= 300 && r.status < 400 && r.headers.get('location')
if (!loc) return r
if (i >= redirects) throw new Error('Demasiadas redirecciones')
url = new URL(loc, u).toString()
}
}
+61 -21
View File
@@ -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, () => {
+14 -2
View File
@@ -41,6 +41,8 @@ export function setupUpscale({ db, MANGA_DIR, HttpError, json, readBody, MANGA_I
CREATE INDEX IF NOT EXISTS idx_upscale_manga ON upscale_jobs(manga_id);
`)
db.prepare("UPDATE upscale_jobs SET status = 'queued' WHERE status = 'running'").run() // reinicio a mitad
// Capítulos aún sin descargar que deben reescalarse en cuanto lleguen (descarga anticipada con HD).
db.exec('CREATE TABLE IF NOT EXISTS upscale_wanted (chapter_id TEXT PRIMARY KEY, created_at INTEGER NOT NULL)')
db.exec('CREATE TABLE IF NOT EXISTS kv (k TEXT PRIMARY KEY, v TEXT)')
// Segundos por página medidos (media móvil), para estimar cuánto tardará una cola.
let secPerPage = Number(db.prepare("SELECT v FROM kv WHERE k = 'hd_sec_per_page'").get()?.v) || 13
@@ -203,7 +205,8 @@ export function setupUpscale({ db, MANGA_DIR, HttpError, json, readBody, MANGA_I
// Llamado al terminar de descargar un capítulo: si el manga está marcado como HD, encolarlo.
function onChapterDownloaded(mangaId, chapterId) {
if (db.prepare('SELECT hd_auto FROM downloads WHERE manga_id = ?').get(mangaId)?.hd_auto) enqueue([chapterId])
const wanted = db.prepare('DELETE FROM upscale_wanted WHERE chapter_id = ?').run(chapterId).changes
if (wanted || db.prepare('SELECT hd_auto FROM downloads WHERE manga_id = ?').get(mangaId)?.hd_auto) enqueue([chapterId])
}
async function route(p, method, req, res) {
@@ -250,5 +253,14 @@ export function setupUpscale({ db, MANGA_DIR, HttpError, json, readBody, MANGA_I
if (!fs.existsSync(BIN)) console.log('HD: Real-ESRGAN no instalado, reescalado desactivado')
else if (!fs.existsSync('/dev/dri')) console.log('HD: sin /dev/dri en el contenedor, reescalado desactivado')
return { info, hdFileOf, onChapterDownloaded, cancel, route, pump, available, queue }
// Reescalar estos capítulos: los descargados ya, el resto al terminar su descarga.
function request(chapterIds) {
const have = db.prepare('SELECT 1 FROM downloaded_chapters WHERE chapter_id = ?')
const later = db.prepare('INSERT OR IGNORE INTO upscale_wanted (chapter_id, created_at) VALUES (?, ?)')
const now = []
for (const id of chapterIds) have.get(id) ? now.push(id) : later.run(id, Date.now())
if (now.length) enqueue(now)
}
return { info, hdFileOf, onChapterDownloaded, cancel, route, pump, available, queue, request }
}
+8
View File
@@ -19,6 +19,14 @@ services:
- PUBLIC_URL=${PUBLIC_URL:-http://localhost:3352}
# Registro de cuentas nuevas. Con la base vacía el primer usuario siempre puede registrarse.
- ALLOW_SIGNUP=${ALLOW_SIGNUP:-false}
# Baneo de IP: tras BAN_MAX_FAILS intentos fallidos en BAN_WINDOW_MIN minutos, BAN_HOURS horas.
- BAN_MAX_FAILS=${BAN_MAX_FAILS:-6}
- BAN_WINDOW_MIN=${BAN_WINDOW_MIN:-60}
- BAN_HOURS=${BAN_HOURS:-24}
- BAN_IGNORE=${BAN_IGNORE:-}
# Detrás de un proxy inverso (Caddy, Nginx, Cloudflare Tunnel…): cabecera con la IP real del
# cliente, p. ej. x-forwarded-for o cf-connecting-ip. Vacío = conexión directa.
- REAL_IP_HEADER=${REAL_IP_HEADER:-}
volumes:
# SQLite con usuarios, sesiones, historial, estadísticas y clave del proxy de imágenes.
- ./data:/app/data