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:
@@ -22,3 +22,14 @@ RENDER_GID=992
|
|||||||
|
|
||||||
TZ=America/El_Salvador
|
TZ=America/El_Salvador
|
||||||
CHECK_INTERVAL_HOURS=3
|
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
|
||||||
|
|||||||
@@ -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"),
|
(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
|
recortes y portadas pendientes, y debajo la biblioteca descargada con su tamaño; todo en tarjetas
|
||||||
verticales con portada, como el historial.
|
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
|
- **Estadísticas**: rachas, capítulos y páginas por semana, calendario de actividad, géneros
|
||||||
favoritos y lo más leído.
|
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`).
|
- 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
|
El primer usuario puede registrarse siempre; después el registro queda cerrado salvo
|
||||||
`ALLOW_SIGNUP=true` en `.env`.
|
`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é guardar / mover a otro servidor
|
||||||
|
|
||||||
| Qué | Dónde | Contenido |
|
| Qué | Dónde | Contenido |
|
||||||
|
|||||||
+2
-2
@@ -32,7 +32,7 @@ const GENRE_ES = {
|
|||||||
// Etiquetas de formato que no son géneros.
|
// 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',
|
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'])
|
'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, '')
|
const k = String(raw).trim().toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '')
|
||||||
if (NOT_GENRE.has(k)) return null
|
if (NOT_GENRE.has(k)) return null
|
||||||
return GENRE_ES[k] || String(raw).trim()
|
return GENRE_ES[k] || String(raw).trim()
|
||||||
@@ -374,5 +374,5 @@ export function setupFeatures({ db, fetchMangaFull, enqueueDownload, downloadInf
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
return { route, onProgress, mangaExtras, unfollow, checkFollows }
|
return { route, onProgress, mangaExtras, unfollow, checkFollows, stats }
|
||||||
}
|
}
|
||||||
|
|||||||
+166
@@ -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 }
|
||||||
|
}
|
||||||
+401
-30
@@ -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"/>',
|
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"/>',
|
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"/>',
|
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"/>',
|
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>`
|
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 ----------
|
// ---------- Utilidades ----------
|
||||||
const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]))
|
const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]))
|
||||||
const $ = (sel, root = document) => root.querySelector(sel)
|
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 = {
|
const store = {
|
||||||
get(k, d) { try { return JSON.parse(localStorage.getItem(k)) ?? d } catch { return d } },
|
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 {} },
|
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 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}` : ''}`
|
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 ----------
|
// ---------- Router ----------
|
||||||
let prevHash = '', curHash = '' // para volver atrás sin pasar por el lector
|
let prevHash = '', curHash = '' // para volver atrás sin pasar por el lector
|
||||||
async function router() {
|
async function router() {
|
||||||
@@ -142,7 +255,8 @@ async function router() {
|
|||||||
if (parts[0] === 'login') return (location.hash = '#/')
|
if (parts[0] === 'login') return (location.hash = '#/')
|
||||||
if (parts[0] === 'manga') return await viewManga(parts[1])
|
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] === '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] === 'downloads') return (location.hash = '#/queues') // antiguo nombre
|
||||||
if (parts[0] === 'queues') return await viewQueues()
|
if (parts[0] === 'queues') return await viewQueues()
|
||||||
if (parts[0] === 'stats') return await viewStats()
|
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)}`
|
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) {
|
async function viewHome(params) {
|
||||||
const q = params.get('q') || ''
|
const q = params.get('q') || ''
|
||||||
const tag = params.get('tag') || ''
|
const tag = params.get('tag') || ''
|
||||||
@@ -246,6 +374,7 @@ async function viewHome(params) {
|
|||||||
const showCats = mode === 'cats' && !tag
|
const showCats = mode === 'cats' && !tag
|
||||||
app.innerHTML = `
|
app.innerHTML = `
|
||||||
<section id="cont"></section>
|
<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>
|
<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>
|
<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>
|
<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>` : '')
|
$('#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)
|
bindNews(fol)
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
|
loadRecs()
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = $('#results')
|
const res = $('#results')
|
||||||
@@ -311,7 +441,7 @@ async function viewManga(id, langOverride) {
|
|||||||
if (cleanup) { cleanup(); cleanup = null }
|
if (cleanup) { cleanup(); cleanup = null }
|
||||||
app.innerHTML = '<div class="loading">Cargando…</div>'
|
app.innerHTML = '<div class="loading">Cargando…</div>'
|
||||||
const lang = langOverride || store.get(`lang:${id}`, me.lang)
|
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 dlSet = new Set(downloaded)
|
||||||
const newSet = new Set(newKeys.map(String))
|
const newSet = new Set(newKeys.map(String))
|
||||||
let isFollowing = following
|
let isFollowing = following
|
||||||
@@ -343,11 +473,12 @@ async function viewManga(id, langOverride) {
|
|||||||
<p class="desc">${esc(manga.description)}</p>
|
<p class="desc">${esc(manga.description)}</p>
|
||||||
<div class="actions">${mainBtn}
|
<div class="actions">${mainBtn}
|
||||||
<button class="btn" id="follow"></button>
|
<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>` : ''}
|
${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>
|
||||||
<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>
|
<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>' : ''}
|
${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>
|
</div>
|
||||||
<div class="list-head">
|
<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 = () => {
|
const selIds = () => {
|
||||||
if (!sel) return []
|
if (!sel) return []
|
||||||
const K = KINDS[sel.kind]
|
const K = KINDS[sel.kind]
|
||||||
@@ -456,6 +599,7 @@ async function viewManga(id, langOverride) {
|
|||||||
if (wasPick !== (next?.mode === 'pick') || (wasPick && prevKind !== next?.kind)) renderList()
|
if (wasPick !== (next?.mode === 'pick') || (wasPick && prevKind !== next?.kind)) renderList()
|
||||||
$('#dlopen')?.classList.toggle('on', sel?.kind === 'dl')
|
$('#dlopen')?.classList.toggle('on', sel?.kind === 'dl')
|
||||||
$('#hdopen')?.classList.toggle('on', sel?.kind === 'hd')
|
$('#hdopen')?.classList.toggle('on', sel?.kind === 'hd')
|
||||||
|
$('#offopen')?.classList.toggle('on', sel?.kind === 'off')
|
||||||
updateSummary()
|
updateSummary()
|
||||||
}
|
}
|
||||||
function renderSelForm(kind) {
|
function renderSelForm(kind) {
|
||||||
@@ -612,6 +756,89 @@ async function viewManga(id, langOverride) {
|
|||||||
if (changed) refreshSel()
|
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 renderFollow = () => {
|
||||||
const b = $('#follow')
|
const b = $('#follow')
|
||||||
b.innerHTML = isFollowing ? `${icon('bell')} Siguiendo` : `${icon('bellOff')} Seguir`
|
b.innerHTML = isFollowing ? `${icon('bell')} Siguiendo` : `${icon('bellOff')} Seguir`
|
||||||
@@ -635,10 +862,9 @@ async function viewManga(id, langOverride) {
|
|||||||
location.hash = back ? prevHash : '#/'
|
location.hash = back ? prevHash : '#/'
|
||||||
})
|
})
|
||||||
// Cerrar el menú ⋯ al pulsar fuera.
|
// Cerrar el menú ⋯ al pulsar fuera.
|
||||||
const menu = $('.menu', app)
|
const closeMenu = e => app.querySelectorAll('.menu[open]').forEach(menu => { if (!menu.contains(e.target)) menu.open = false })
|
||||||
const closeMenu = e => { if (menu && !menu.contains(e.target)) menu.open = false }
|
|
||||||
document.addEventListener('click', closeMenu)
|
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) }))
|
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)
|
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> ` : '')
|
+ (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> '
|
+ (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> ` : '')
|
: 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))
|
+ (r ? (done ? 'Leído' : `pág. ${r.page + 1}/${r.total_pages}`) : esc(c.group))
|
||||||
function renderList() {
|
function renderList() {
|
||||||
if (!ul) return
|
if (!ul) return
|
||||||
@@ -685,6 +912,7 @@ async function viewManga(id, langOverride) {
|
|||||||
renderList()
|
renderList()
|
||||||
renderDl(download)
|
renderDl(download)
|
||||||
renderHd(upscale)
|
renderHd(upscale)
|
||||||
|
renderOff()
|
||||||
$('#order')?.addEventListener('click', () => { desc = !desc; lastPick = null; store.set('chapterOrderDesc', desc); renderList() })
|
$('#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) })
|
$('#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,
|
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"
|
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 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()
|
const onVis = () => document.visibilityState === 'hidden' && beacon()
|
||||||
document.addEventListener('visibilitychange', onVis)
|
document.addEventListener('visibilitychange', onVis)
|
||||||
window.addEventListener('pagehide', beacon)
|
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>'}
|
${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.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>` : ''}
|
: 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>
|
<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>
|
||||||
<div id="pages"></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 }
|
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) })
|
$('#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))
|
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)
|
// --- 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') }
|
cleanup = () => { clearTimeout(timer); document.removeEventListener('keydown', onKey); document.body.classList.remove('noscroll') }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- Historial ----------
|
// ---------- Librería ----------
|
||||||
async function viewHistory() {
|
// Pestañas: Leyendo, Siguiendo, Terminados, En el dispositivo y las listas propias.
|
||||||
app.innerHTML = '<div class="loading">Cargando…</div>'
|
const libCard = ({ href, cover, title, sub, pct, badge, del }) => `<a class="card hcard" href="${href}">
|
||||||
const hist = await api('/api/history')
|
<img class="cover" loading="lazy" src="${esc(cover || '/icon.svg')}" alt="" data-fallback>
|
||||||
app.innerHTML = `<h1>Historial de lectura</h1>
|
${badge ? `<span class="badge corner">${badge}</span>` : ''}
|
||||||
${hist.length ? '' : '<p class="muted">Aún no has leído nada.</p>'}
|
${del ? `<button class="hdel" ${del.attrs} title="${esc(del.title)}" aria-label="${esc(del.title)}">${icon(del.icon || 'trash')}</button>` : ''}
|
||||||
<div class="grid">${hist.map(h => {
|
<div class="bar" ${pct == null ? 'style="visibility:hidden"' : ''}><i style="width:${pct || 0}%"></i></div>
|
||||||
const pct = h.total_pages ? Math.round(((h.page + 1) / h.total_pages) * 100) : 0
|
<div class="t">${esc(title)}</div>
|
||||||
return `<a class="card hcard" href="#/manga/${h.manga_id}">
|
${sub ? `<div class="sub">${sub}</div>` : ''}
|
||||||
<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>`
|
</a>`
|
||||||
}).join('')}</div>`
|
async function viewLibrary(params) {
|
||||||
app.querySelectorAll('[data-del]').forEach(b => b.addEventListener('click', async e => {
|
const tab = params.get('tab') || 'reading'
|
||||||
|
app.innerHTML = '<div class="loading">Cargando…</div>'
|
||||||
|
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.preventDefault()
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
|
try {
|
||||||
|
if (b.dataset.delhist) {
|
||||||
if (!confirm('¿Borrar el historial de este manga?')) return
|
if (!confirm('¿Borrar el historial de este manga?')) return
|
||||||
await api(`/api/history/${b.dataset.del}`, { method: 'DELETE' })
|
await api(`/api/history/${b.dataset.delhist}`, { method: 'DELETE' })
|
||||||
viewHistory()
|
} 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 ----------
|
// ---------- Descargas ----------
|
||||||
@@ -1402,7 +1726,7 @@ async function viewQueues() {
|
|||||||
// Tarjeta vertical (como en Historial): portada, barra de progreso, título, detalles y acciones.
|
// 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 = '' }) => `
|
const qcard = ({ key, id, cover, badge, pct, title, lines, actions = '', del = '' }) => `
|
||||||
<div class="card hcard qcard" data-key="${esc(key)}">
|
<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}
|
${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>
|
<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>
|
<a class="t" href="#/manga/${id}">${esc(title)}</a>
|
||||||
@@ -1600,8 +1924,22 @@ function viewSettings() {
|
|||||||
<section><b>Cambiar contraseña</b>
|
<section><b>Cambiar contraseña</b>
|
||||||
<form id="pw" style="display:grid;gap:8px">
|
<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="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>
|
<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>
|
<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).
|
<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>
|
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()
|
viewSettings()
|
||||||
} catch (err) { toast(err.message) }
|
} 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 () => {
|
$('#ntest').addEventListener('click', async () => {
|
||||||
try { await api('/api/notify/test', { method: 'POST' }); toast('Notificación de prueba enviada') } catch (err) { toast(err.message) }
|
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 |
@@ -6,6 +6,12 @@
|
|||||||
<meta name="theme-color" content="#14121a">
|
<meta name="theme-color" content="#14121a">
|
||||||
<title>Manga Reader</title>
|
<title>Manga Reader</title>
|
||||||
<link rel="icon" href="/icon.svg">
|
<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">
|
<link rel="stylesheet" href="/style.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -13,7 +19,7 @@
|
|||||||
<a href="#/" class="brand"><img src="/icon.svg" alt="" width="26" height="26"> <span>Manga Reader</span></a>
|
<a href="#/" class="brand"><img src="/icon.svg" alt="" width="26" height="26"> <span>Manga Reader</span></a>
|
||||||
<nav>
|
<nav>
|
||||||
<a href="#/" data-icon="home">Inicio</a>
|
<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="#/queues" data-icon="queue">Colas</a>
|
||||||
<a href="#/clips" data-icon="scissors">Recortes</a>
|
<a href="#/clips" data-icon="scissors">Recortes</a>
|
||||||
<a href="#/stats" data-icon="chart">Estadísticas</a>
|
<a href="#/stats" data-icon="chart">Estadísticas</a>
|
||||||
|
|||||||
@@ -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
@@ -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; }
|
.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 { 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 .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; }
|
.card .sub { padding: 0 10px 10px; font-size: .8rem; color: var(--muted); margin-top: -6px; }
|
||||||
.bar { height: 4px; background: var(--surface-2); }
|
.bar { height: 4px; background: var(--surface-2); }
|
||||||
.bar > i { display: block; height: 100%; background: var(--accent); }
|
.bar > i { display: block; height: 100%; background: var(--accent); }
|
||||||
@@ -306,7 +307,7 @@ body.noscroll { overflow: hidden; }
|
|||||||
|
|
||||||
/* Historial: tarjetas verticales */
|
/* Historial: tarjetas verticales */
|
||||||
.hcard .bar { margin: 0; height: 5px; }
|
.hcard .bar { margin: 0; height: 5px; }
|
||||||
.hcard .t { padding-bottom: 2px; }
|
.hcard .t { margin-bottom: 4px; }
|
||||||
.hcard .sub { margin-top: 0; }
|
.hcard .sub { margin-top: 0; }
|
||||||
.hcard .corner { position: absolute; top: 8px; left: 8px; }
|
.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;
|
.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; }
|
.qpos .ic { color: var(--accent); width: 15px; height: 15px; }
|
||||||
.small { font-size: .82rem; }
|
.small { font-size: .82rem; }
|
||||||
@media (max-width: 640px) { .qgrid { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); } }
|
@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; }
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -10,11 +10,13 @@ import { setupFeatures } from './features.js'
|
|||||||
import { setupUpscale } from './upscale.js'
|
import { setupUpscale } from './upscale.js'
|
||||||
import { setupClips } from './clips.js'
|
import { setupClips } from './clips.js'
|
||||||
import { setupCovers } from './covers.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 PORT = Number(process.env.PORT || 3352)
|
||||||
const DATA_DIR = process.env.DATA_DIR || '/app/data'
|
const DATA_DIR = process.env.DATA_DIR || '/app/data'
|
||||||
const MANGA_DIR = process.env.MANGA_DIR || '/mangas'
|
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 PUBLIC_DIR = path.resolve(import.meta.dirname, 'public')
|
||||||
const MD_API = 'https://api.mangadex.org'
|
const MD_API = 'https://api.mangadex.org'
|
||||||
const LC_BASE = 'https://www.leercapitulo.co'
|
const LC_BASE = 'https://www.leercapitulo.co'
|
||||||
@@ -137,9 +139,11 @@ function createSession(userId) {
|
|||||||
.run(token, userId, Date.now() + SESSION_DAYS * 86400e3)
|
.run(token, userId, Date.now() + SESSION_DAYS * 86400e3)
|
||||||
return token
|
return token
|
||||||
}
|
}
|
||||||
function sessionCookie(token, maxAge) {
|
function sessionCookie(token, maxAge, secure = false) {
|
||||||
return `sid=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAge}`
|
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) {
|
function getUser(req) {
|
||||||
const m = /(?:^|;\s*)sid=([^;]+)/.exec(req.headers.cookie || '')
|
const m = /(?:^|;\s*)sid=([^;]+)/.exec(req.headers.cookie || '')
|
||||||
if (!m) return null
|
if (!m) return null
|
||||||
@@ -152,7 +156,15 @@ const cache = new Map()
|
|||||||
async function cached(key, ttl, fn) {
|
async function cached(key, ttl, fn) {
|
||||||
const hit = cache.get(key)
|
const hit = cache.get(key)
|
||||||
if (hit && hit.exp > Date.now()) return hit.data
|
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 })
|
cache.set(key, { data, exp: Date.now() + ttl })
|
||||||
if (cache.size > 500) cache.delete(cache.keys().next().value)
|
if (cache.size > 500) cache.delete(cache.keys().next().value)
|
||||||
return data
|
return data
|
||||||
@@ -308,10 +320,18 @@ const decode = s => String(s ?? '').replace(/<[^>]+>/g, '').replace(/&(#x?[0-9a-
|
|||||||
}).trim()
|
}).trim()
|
||||||
async function lc(pathname, ttl = 10 * 60e3, asJson = false) {
|
async function lc(pathname, ttl = 10 * 60e3, asJson = false) {
|
||||||
return cached(`lc:${pathname}`, ttl, async () => {
|
return cached(`lc:${pathname}`, ttl, async () => {
|
||||||
const res = await fetch(LC_BASE + pathname, { headers: { 'User-Agent': BROWSER_UA, 'Accept-Language': 'es' } })
|
// Cloudflare de LeerCapitulo da 52x/503 a ratos cuando su servidor no responde: hasta 3 intentos.
|
||||||
.catch(() => { throw new HttpError(502, 'No se pudo conectar con LeerCapitulo') })
|
for (let i = 0; ; i++) {
|
||||||
if (!res.ok) throw new HttpError(res.status === 404 ? 404 : 502, `LeerCapitulo respondió ${res.status}`)
|
const res = await fetch(LC_BASE + pathname, { headers: { 'User-Agent': BROWSER_UA, 'Accept-Language': 'es' }, signal: AbortSignal.timeout(30e3) })
|
||||||
return asJson ? res.json() : res.text()
|
.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)
|
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) {
|
function fetchImage(url, signal) {
|
||||||
const isMd = /(^|\.)mangadex\.(org|network)$/.test(new URL(url).hostname)
|
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 ----------
|
// ---------- Descargas a MANGA_DIR/<Título>/Capitulo NNNN.cbz ----------
|
||||||
@@ -694,7 +714,7 @@ const requireUser = req => {
|
|||||||
return u
|
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) {
|
function serveStatic(res, pathname) {
|
||||||
let file = path.join(PUBLIC_DIR, path.normalize(pathname).replace(/^(\.\.[/\\])+/, ''))
|
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')
|
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) {
|
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 url = new URL(req.url, 'http://x')
|
||||||
const p = url.pathname
|
const p = url.pathname
|
||||||
const q = url.searchParams
|
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 })
|
return json(res, 200, { user: getUser(req), signup: ALLOW_SIGNUP || userCount === 0 })
|
||||||
}
|
}
|
||||||
if ((p === '/api/login' || p === '/api/register') && method === 'POST') {
|
if ((p === '/api/login' || p === '/api/register') && method === 'POST') {
|
||||||
rateLimit(req.socket.remoteAddress)
|
rateLimit(ip)
|
||||||
const { username, password } = await readBody(req)
|
const { username, password } = await readBody(req)
|
||||||
if (typeof username !== 'string' || typeof password !== 'string') throw new HttpError(400, 'Datos incompletos')
|
if (typeof username !== 'string' || typeof password !== 'string') throw new HttpError(400, 'Datos incompletos')
|
||||||
let user
|
let user
|
||||||
if (p === '/api/register') {
|
if (p === '/api/register') {
|
||||||
const userCount = db.prepare('SELECT COUNT(*) n FROM users').get().n
|
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 (!/^[\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')
|
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 (?, ?, ?)')
|
const r = db.prepare('INSERT INTO users (username, pass_hash, created_at) VALUES (?, ?, ?)')
|
||||||
.run(username, hashPassword(password), Date.now())
|
.run(username, hashPassword(password), Date.now())
|
||||||
user = { id: Number(r.lastInsertRowid) }
|
user = { id: Number(r.lastInsertRowid) }
|
||||||
} else {
|
} else {
|
||||||
const row = db.prepare('SELECT id, pass_hash FROM users WHERE username = ?').get(username)
|
const row = db.prepare('SELECT id, pass_hash FROM users WHERE username = ?').get(username.slice(0, 64))
|
||||||
if (!row || !verifyPassword(password, row.pass_hash)) throw new HttpError(401, 'Usuario o contraseña incorrectos')
|
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
|
user = row
|
||||||
}
|
}
|
||||||
|
security.success(ip)
|
||||||
const token = createSession(user.id)
|
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') {
|
if (p === '/api/logout' && method === 'POST') {
|
||||||
const m = /(?:^|;\s*)sid=([^;]+)/.exec(req.headers.cookie || '')
|
const m = /(?:^|;\s*)sid=([^;]+)/.exec(req.headers.cookie || '')
|
||||||
if (m) db.prepare('DELETE FROM sessions WHERE token = ?').run(m[1])
|
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.
|
// Todo lo demás requiere sesión.
|
||||||
const user = requireUser(req)
|
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 features.route(p, method, q, req, res, user)) !== false) return
|
||||||
if ((await upscale.route(p, method, req, res)) !== 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 (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') {
|
if (p === '/api/settings' && method === 'POST') {
|
||||||
const body = await readBody(req)
|
const body = await readBody(req)
|
||||||
@@ -766,9 +799,12 @@ async function route(req, res) {
|
|||||||
}
|
}
|
||||||
if (body.newPassword !== undefined) {
|
if (body.newPassword !== undefined) {
|
||||||
const row = db.prepare('SELECT pass_hash FROM users WHERE id = ?').get(user.id)
|
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 (!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 < 6) throw new HttpError(400, 'La contraseña debe tener al menos 6 caracteres')
|
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)
|
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 })
|
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 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 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(() => [])
|
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') {
|
if ((m = /^\/api\/chapter\/([^/]+)$/.exec(p)) && CHAPTER_ID.test(m[1]) && method === 'GET') {
|
||||||
const hdAvailable = !!upscale.hdFileOf(m[1])
|
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`)
|
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)
|
.run(user.id, b.chapterId, b.mangaId, page, total, now)
|
||||||
features.onProgress(user.id, b, page, total, prevPage)
|
features.onProgress(user.id, b, page, total, prevPage)
|
||||||
|
library.onProgress(user.id, b, page, total)
|
||||||
return json(res, 200, { ok: true })
|
return json(res, 200, { ok: true })
|
||||||
}
|
}
|
||||||
if ((m = /^\/api\/history\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'DELETE') {
|
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')
|
throw new HttpError(404, 'No encontrado')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const security = setupSecurity({ db })
|
||||||
const features = setupFeatures({ db, fetchMangaFull, enqueueDownload, downloadInfo, HttpError, json, readBody, MANGA_ID })
|
const features = setupFeatures({ db, fetchMangaFull, enqueueDownload, downloadInfo, HttpError, json, readBody, MANGA_ID })
|
||||||
const upscale = setupUpscale({ db, MANGA_DIR, HttpError, json, readBody, MANGA_ID })
|
const upscale = setupUpscale({ db, MANGA_DIR, HttpError, json, readBody, MANGA_ID })
|
||||||
const covers = setupCovers({ DATA_DIR, upscale })
|
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 })
|
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.
|
// 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) {
|
} catch (err) {
|
||||||
const status = err instanceof HttpError ? err.status : 500
|
const status = err instanceof HttpError ? err.status : 500
|
||||||
if (status >= 500) console.error(req.method, req.url, err)
|
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()
|
else res.end()
|
||||||
}
|
}
|
||||||
}).listen(PORT, () => {
|
}).listen(PORT, () => {
|
||||||
|
|||||||
+14
-2
@@ -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);
|
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
|
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)')
|
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.
|
// 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
|
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.
|
// Llamado al terminar de descargar un capítulo: si el manga está marcado como HD, encolarlo.
|
||||||
function onChapterDownloaded(mangaId, chapterId) {
|
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) {
|
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')
|
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')
|
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 }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,14 @@ services:
|
|||||||
- PUBLIC_URL=${PUBLIC_URL:-http://localhost:3352}
|
- PUBLIC_URL=${PUBLIC_URL:-http://localhost:3352}
|
||||||
# Registro de cuentas nuevas. Con la base vacía el primer usuario siempre puede registrarse.
|
# Registro de cuentas nuevas. Con la base vacía el primer usuario siempre puede registrarse.
|
||||||
- ALLOW_SIGNUP=${ALLOW_SIGNUP:-false}
|
- 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:
|
volumes:
|
||||||
# SQLite con usuarios, sesiones, historial, estadísticas y clave del proxy de imágenes.
|
# SQLite con usuarios, sesiones, historial, estadísticas y clave del proxy de imágenes.
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
|
|||||||
Reference in New Issue
Block a user