From 9d031d4ba363f25ef94083ea9d3a403492becba8 Mon Sep 17 00:00:00 2001 From: Nino Date: Fri, 30 Oct 2026 23:26:33 -0600 Subject: [PATCH] Manga Reader: lector web de manga con historial de lectura MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Basado en las fuentes de Tanko (MangaDex y LeerCapitulo), con usuarios con contraseña, progreso por página guardado en SQLite, lector por página o vertical, filtros de imagen y proxy de imágenes con URLs firmadas. Co-Authored-By: Claude Opus 5.5 --- .dockerignore | 3 + .gitignore | 5 + Dockerfile | 9 + README.md | 26 +++ app/public/app.js | 440 ++++++++++++++++++++++++++++++++++++ app/public/icon.svg | 1 + app/public/index.html | 24 ++ app/public/style.css | 121 ++++++++++ app/server.js | 510 ++++++++++++++++++++++++++++++++++++++++++ data/.keep | 0 docker-compose.yml | 32 +++ 11 files changed, 1171 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 app/public/app.js create mode 100644 app/public/icon.svg create mode 100644 app/public/index.html create mode 100644 app/public/style.css create mode 100644 app/server.js create mode 100644 data/.keep create mode 100644 docker-compose.yml diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..6174672 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,3 @@ +data +.git +.env diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ec51d9e --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +# Datos de ejecución: usuarios, sesiones, historial y clave HMAC del proxy de imágenes +data/* +!data/.keep +.env +node_modules/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..14a7a1d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,9 @@ +FROM node:24-alpine +ENV NODE_ENV=production NODE_NO_WARNINGS=1 TZ=America/El_Salvador +RUN apk add --no-cache tzdata +WORKDIR /app +COPY app/ ./ +USER node +EXPOSE 3352 +HEALTHCHECK --interval=60s --timeout=5s CMD wget -qO- http://127.0.0.1:3352/api/me >/dev/null || exit 1 +CMD ["node", "server.js"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..0e69862 --- /dev/null +++ b/README.md @@ -0,0 +1,26 @@ +# Manga Reader + +Lector web de manga autoalojado, con las dos fuentes de +[Tanko](https://github.com/Alexandro521/Tanko) (`src/servers/`): + +- **LeerCapitulo** (español, por defecto): mucho más catálogo. El scraper está reescrito para el + sitio actual y ya no necesita navegador headless (las páginas vienen en el HTML). +- **MangaDex** (multi-idioma): español = es-la + es combinados. Muchos títulos populares no tienen + capítulos por licencias; la ficha ofrece buscarlos en LeerCapitulo. +- Búsqueda, populares y actualizados recientemente. +- Lector por página (toques/flechas, izq→der o der→izq) o en vertical, modo ahorro de datos. +- Usuarios con contraseña; el **historial y la página exacta** se guardan en el servidor + (`data/manga.db`), así que puedes seguir desde cualquier dispositivo/sesión. +- Las imágenes pasan por el servidor (`/api/img`), solo con URLs firmadas por él (clave en `data/secret`). + +## Uso + +```bash +docker compose up -d --build +# http://192.168.1.141:3352 (o la IP de Tailscale) +``` + +Crea tu cuenta en la pantalla de inicio. Para cerrar el registro después: +`ALLOW_SIGNUP=false` en `.env` y `docker compose up -d`. + +Copia de seguridad: basta con `data/manga.db` (+ `-wal`/`-shm` si existen). diff --git a/app/public/app.js b/app/public/app.js new file mode 100644 index 0000000..d4a5a96 --- /dev/null +++ b/app/public/app.js @@ -0,0 +1,440 @@ +const app = document.getElementById('app') +const SOURCES = { lc: 'LeerCapitulo (español)', md: 'MangaDex (multi-idioma)' } +// Filtros de imagen del lector (CSS filter); el brillo se suma aparte. +const FILTERS = { + none: ['Sin filtro', ''], + sepia: ['Sepia', 'sepia(.65) contrast(.95)'], + paper: ['Papel cálido', 'sepia(.35) saturate(1.15) hue-rotate(-8deg) brightness(.95)'], + vintage: ['Vintage', 'sepia(.45) contrast(1.1) saturate(.75)'], + night: ['Noche (invertido)', 'invert(.88) hue-rotate(180deg) contrast(.95)'], + gray: ['Blanco y negro', 'grayscale(1)'], + contrast: ['Alto contraste', 'contrast(1.35) brightness(1.03)'], + vivid: ['Colores vivos', 'saturate(1.45) contrast(1.05)'], +} +const LANGS = { 'es-la': 'Español (Latam)', es: 'Español (España)', en: 'English', 'pt-br': 'Português (BR)', fr: 'Français', it: 'Italiano', ja: '日本語' } +let me = null +let cleanup = null + +// ---------- Utilidades ---------- +const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])) +const $ = (sel, root = document) => root.querySelector(sel) +const store = { + get(k, d) { try { return JSON.parse(localStorage.getItem(k)) ?? d } catch { return d } }, + set(k, v) { try { localStorage.setItem(k, JSON.stringify(v)) } catch {} }, +} +function toast(msg) { + const t = document.getElementById('toast') + t.textContent = msg + t.hidden = false + clearTimeout(toast.t) + toast.t = setTimeout(() => (t.hidden = true), 2500) +} +async function api(path, opts = {}) { + const res = await fetch(path, { + ...opts, + headers: opts.body ? { 'Content-Type': 'application/json' } : undefined, + body: opts.body ? JSON.stringify(opts.body) : undefined, + }) + const data = await res.json().catch(() => ({})) + if (res.status === 401 && !path.startsWith('/api/login')) { me = null; location.hash = '#/login'; throw new Error('No autenticado') } + if (!res.ok) throw new Error(data.error || `Error ${res.status}`) + return data +} +function timeAgo(ts) { + const s = (Date.now() - ts) / 1000 + if (s < 60) return 'hace un momento' + if (s < 3600) return `hace ${Math.floor(s / 60)} min` + if (s < 86400) return `hace ${Math.floor(s / 3600)} h` + return `hace ${Math.floor(s / 86400)} d` +} +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}` : ''}` + +// ---------- Router ---------- +async function router() { + if (cleanup) { cleanup(); cleanup = null } + document.body.classList.remove('reading') + const [pathPart, query] = location.hash.slice(1).split('?') + const parts = (pathPart || '/').split('/').filter(Boolean) + const params = new URLSearchParams(query || '') + document.querySelectorAll('#topbar nav a').forEach(a => a.classList.toggle('active', a.getAttribute('href') === `#/${parts[0] ?? ''}`)) + + if (!me) { + const r = await api('/api/me').catch(() => ({})) + me = r.user + me && (me.signup = r.signup) + if (!me) return viewAuth(r.signup) + } + try { + if (parts[0] === 'login') return (location.hash = '#/') + if (parts[0] === 'manga') return await viewManga(parts[1]) + if (parts[0] === 'read') return await viewReader(parts[1], parts[2], Number(params.get('page') || 0) - 1) + if (parts[0] === 'history') return await viewHistory() + if (parts[0] === 'settings') return viewSettings() + return await viewHome(params) + } catch (e) { + if (e.message !== 'No autenticado') app.innerHTML = `

${esc(e.message)}

` + } +} +window.addEventListener('hashchange', router) +router() + +// ---------- Login / registro ---------- +function viewAuth(signup) { + app.innerHTML = ` +
+

Manga Reader

+

Inicia sesión para leer y guardar tu progreso.

+
+ + +

+ + ${signup ? '' : ''} +
+
` + const form = $('form', app) + form.addEventListener('submit', async ev => { + ev.preventDefault() + const mode = ev.submitter?.value === 'register' ? 'register' : 'login' + const fd = new FormData(form) + try { + await api(`/api/${mode}`, { method: 'POST', body: { username: fd.get('username'), password: fd.get('password') } }) + me = null + location.hash = '#/' + router() + } catch (e) { $('.error', form).textContent = e.message } + }) +} + +// ---------- Inicio ---------- +function mangaCard(m) { + return ` + +
${esc(m.title)}
${m.sub ? `
${esc(m.sub)}
` : ''}
` +} +function continueCard(h) { + const pct = h.total_pages ? Math.round(((h.page + 1) / h.total_pages) * 100) : 0 + return ` + +
${esc(h.manga_title)} +
${h.chapter_num ? `Cap. ${esc(h.chapter_num)}` : 'Oneshot'} · pág. ${h.page + 1}/${h.total_pages || '?'}
+
${timeAgo(h.updated_at)}
+
` +} +async function viewHome(params) { + const q = params.get('q') || '' + const mode = params.get('mode') || 'popular' + const anyLang = params.get('any') === '1' + const source = SOURCES[params.get('source')] ? params.get('source') : store.get('source', 'lc') + store.set('source', source) + app.innerHTML = ` +
+
${Object.entries(SOURCES).map(([k, v]) => ``).join('')}
+ + + ${q ? `

Resultados para “${esc(q)}”

` : `
`} +
Cargando…
` + const form = $('form', app) + const go = (extra = {}) => { + const p = new URLSearchParams({ source, ...(form.q.value ? { q: form.q.value } : { mode }), ...($('[name=any]').checked ? { any: '1' } : {}), ...extra }) + location.hash = `#/?${p}` + } + form.addEventListener('submit', e => { e.preventDefault(); go() }) + $('[name=any]').addEventListener('change', () => go()) + app.querySelectorAll('#modes button').forEach(b => b.addEventListener('click', () => { location.hash = `#/?source=${source}&mode=${b.dataset.m}${anyLang ? '&any=1' : ''}` })) + app.querySelectorAll('#sources button').forEach(b => b.addEventListener('click', () => go({ source: b.dataset.s }))) + + api('/api/history').then(hist => { + if (!hist.length || q) return + $('#cont').innerHTML = `

Continuar leyendo

${hist.slice(0, 12).map(continueCard).join('')}
` + }).catch(() => {}) + + const qs = new URLSearchParams({ q, mode, source, ...(anyLang ? { anyLang: '1' } : {}) }) + const list = await api(`/api/search?${qs}`) + const res = $('#results') + res.className = list.length ? 'grid' : 'loading' + res.innerHTML = list.length ? list.map(mangaCard).join('') : 'Sin resultados.' +} + +// ---------- Ficha del manga ---------- +async function viewManga(id, langOverride) { + app.innerHTML = '
Cargando…
' + const lang = langOverride || store.get(`lang:${id}`, me.lang) + const { manga, chapters, reads, progress, chapterLangs } = await api(`/api/manga/${id}?lang=${encodeURIComponent(lang)}`) + const readMap = new Map(reads.map(r => [r.chapter_id, r])) + const isLc = manga.source === 'lc' + const langOpts = [...new Set([lang, me.lang, ...chapterLangs, ...manga.languages])].filter(Boolean) + const altSearch = `#/?source=lc&q=${encodeURIComponent(manga.title)}` + const first = chapters[0] + let mainBtn = '' + if (progress && chapters.some(c => c.id === progress.chapter_id)) { + mainBtn = `Continuar · ${progress.chapter_num ? `Cap. ${esc(progress.chapter_num)}` : 'Oneshot'}, pág. ${progress.page + 1}` + } else if (progress) { + mainBtn = `Continuar · Cap. ${esc(progress.chapter_num ?? '?')} (${esc(LANGS[progress.lang] || progress.lang || '')}), pág. ${progress.page + 1}` + } + if (first && !mainBtn) mainBtn = `Empezar a leer` + + app.innerHTML = ` +
+ +
+

${esc(manga.title)}

+
${esc(manga.authors.join(', '))}${manga.year ? ` · ${manga.year}` : ''}${manga.status ? ` · ${esc(manga.status)}` : ''}
+
${manga.tags.map(t => `${esc(t)}`).join('')}
+

${esc(manga.description)}

+
${mainBtn} + ${isLc ? '' : ` + Buscar en LeerCapitulo`} +
+
Fuente: ${esc(SOURCES[manga.source])}
+
+
+

Capítulos (${chapters.length}) + ${chapters.length > 1 ? '' : ''}

+ ${chapters.length ? '' : isLc ? '

Este manga no tiene capítulos.

' : `

No hay capítulos legibles en ${esc(LANGS[lang] || lang)} (MangaDex retira algunos por licencia). + Prueba a buscarlo en LeerCapitulo. + ${chapterLangs.length ? `Disponibles en: ${chapterLangs.map(l => `${esc(LANGS[l] || l)}`).join(' ')}` : 'No hay capítulos en ningún idioma.'}

`}` + 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) + let desc = store.get('chapterOrderDesc', true) + const renderList = () => { + if (!ul) return + const list = desc ? [...chapters].reverse() : chapters + ul.innerHTML = list.map(c => { + const r = readMap.get(c.id) + const done = r && r.total_pages && r.page >= r.total_pages - 1 + const cls = progress?.chapter_id === c.id ? 'current' : done ? 'read' : '' + const meta = r ? (done ? 'Leído' : `pág. ${r.page + 1}/${r.total_pages}`) : esc(c.group) + return `
  • ${esc(chapLabel(c))}${meta}
  • ` + }).join('') + } + renderList() + $('#order')?.addEventListener('click', () => { desc = !desc; store.set('chapterOrderDesc', desc); renderList() }) + $('#lang')?.addEventListener('change', e => { store.set(`lang:${id}`, e.target.value); viewManga(id, e.target.value) }) +} + +// ---------- Lector ---------- +async function viewReader(mangaId, chapterId, startPage) { + document.body.classList.add('reading') + app.innerHTML = '
    Cargando capítulo…
    ' + const lang = store.get(`lang:${mangaId}`, me.lang) + const settings = { mode: 'paged', fit: 'height', dir: 'ltr', saver: false, filter: 'none', brightness: 100, ...store.get('readerSettings', {}) } + const [info, chap] = await Promise.all([ + api(`/api/manga/${mangaId}?lang=${encodeURIComponent(lang)}`), + api(`/api/chapter/${chapterId}?saver=${settings.saver ? 1 : 0}`), + ]) + const { manga, chapters } = info + const idx = chapters.findIndex(c => c.id === chapterId) + const chapter = chapters[idx] || { id: chapterId, num: info.progress?.chapter_num, title: '' } + const prev = idx > 0 ? chapters[idx - 1] : null + const next = idx >= 0 && idx < chapters.length - 1 ? chapters[idx + 1] : null + const pages = chap.pages + const total = pages.length + let page = Math.min(Math.max(startPage >= 0 ? startPage : chap.savedPage || 0, 0), total - 1) + + // Guardado del progreso (con debounce y al salir de la página). + let saveTimer = null + const payload = () => ({ + mangaId, chapterId, mangaTitle: manga.title, cover: manga.cover, chapterNum: chapter.num ?? null, + chapterTitle: chapter.title, lang: manga.source === 'lc' ? 'es' : chapter.lang || lang, page, totalPages: total, + }) + const saveNow = () => { clearTimeout(saveTimer); saveTimer = null; api('/api/progress', { method: 'POST', body: payload() }).catch(() => {}) } + const scheduleSave = () => { clearTimeout(saveTimer); saveTimer = setTimeout(saveNow, 700) } + const beacon = () => { if (saveTimer) { clearTimeout(saveTimer); saveTimer = null; navigator.sendBeacon('/api/progress', JSON.stringify(payload())) } } + const onVis = () => document.visibilityState === 'hidden' && beacon() + document.addEventListener('visibilitychange', onVis) + window.addEventListener('pagehide', beacon) + saveNow() + + const chapOpts = chapters.map(c => ``).join('') + app.innerHTML = ` +
    +
    + ← +
    ${esc(manga.title)}
    + + + + + + + ${manga.source === 'lc' ? '' : ''} +
    +
    +
    + ${prev ? `« Cap. ant.` : ''} + + + ${next ? `Cap. sig. »` : ''} +
    +
    ` + const reader = $('.reader', app) + const box = $('#pages', app) + const slider = $('#slider', app) + $('#mode').value = settings.mode + $('#fit').value = settings.fit + $('#dir').value = settings.dir + $('#filter').value = FILTERS[settings.filter] ? settings.filter : 'none' + $('#bright').value = settings.brightness + const applyFilter = () => { + const f = [FILTERS[settings.filter]?.[1] || '', settings.brightness != 100 ? `brightness(${settings.brightness / 100})` : ''].join(' ').trim() + box.style.setProperty('--page-filter', f || 'none') + } + applyFilter() + if ($('#saver')) $('#saver').checked = settings.saver + const endBlock = `

    Fin del capítulo ${esc(chapter.num ?? '')}

    + ${next ? `Siguiente: ${esc(chapLabel(next))}` : '

    No hay más capítulos disponibles.

    '} + Volver a la ficha
    ` + + const setPageLabel = () => { $('#pnum').textContent = `${page + 1} / ${total}`; slider.value = page + 1 } + let observer = null + let atEnd = false + + function goTo(n, fromScroll = false) { + if (n >= total && settings.mode === 'paged') { atEnd = true; box.innerHTML = endBlock; reader.classList.remove('hide-ui'); return } + n = Math.min(Math.max(n, 0), total - 1) + const changed = n !== page || atEnd + atEnd = false + page = n + setPageLabel() + if (settings.mode === 'paged') { + const img = $('img', box) || Object.assign(document.createElement('img'), { alt: '' }) + if (!img.isConnected) { box.innerHTML = ''; box.append(img) } + img.src = pages[page] + window.scrollTo(0, 0) + for (let k = 1; k <= 3; k++) if (pages[page + k]) new Image().src = pages[page + k] + } else if (!fromScroll) { + box.querySelectorAll('img')[page]?.scrollIntoView() + } + if (changed) scheduleSave() + } + + function render() { + observer?.disconnect() + box.className = settings.mode === 'paged' ? `paged ${settings.fit === 'width' ? 'fit-width' : ''}` : 'vertical' + $('#fit').hidden = settings.mode !== 'paged' + $('#dir').hidden = settings.mode !== 'paged' + if (settings.mode === 'paged') { + box.innerHTML = '' + goTo(page) + return + } + box.innerHTML = pages.map((src, i) => ``).join('') + endBlock + box.querySelectorAll('img').forEach(img => img.addEventListener('load', () => img.setAttribute('data-loaded', ''), { once: true })) + const lazy = new IntersectionObserver(entries => entries.forEach(e => { + if (e.isIntersecting && e.target.dataset.src) { e.target.src = e.target.dataset.src; delete e.target.dataset.src } + }), { rootMargin: '2000px 0px' }) + // No seguir el scroll hasta haber saltado a la página guardada, para no pisarla con la 0. + let ready = false + observer = new IntersectionObserver(entries => { + if (!ready) return + for (const e of entries) if (e.isIntersecting) goTo(Number(e.target.dataset.i), true) + }, { rootMargin: '-45% 0px -45% 0px' }) + const imgs = [...box.querySelectorAll('img')] + imgs.forEach(img => { lazy.observe(img); observer.observe(img) }) + const lazyDisconnect = observer.disconnect.bind(observer) + observer.disconnect = () => { lazyDisconnect(); lazy.disconnect() } + const before = imgs.slice(0, page + 1).map(img => img.complete ? null : new Promise(r => { img.addEventListener('load', r, { once: true }); img.addEventListener('error', r, { once: true }) })) + Promise.race([Promise.all(before), new Promise(r => setTimeout(r, 8000))]).then(() => { + imgs[page]?.scrollIntoView() + lastY = window.scrollY + setTimeout(() => (ready = true), 300) + }) + setPageLabel() + } + + const saveSettings = () => store.set('readerSettings', settings) + $('#mode').addEventListener('change', e => { settings.mode = e.target.value; saveSettings(); render() }) + $('#fit').addEventListener('change', e => { settings.fit = e.target.value; saveSettings(); render() }) + $('#dir').addEventListener('change', e => { settings.dir = e.target.value; saveSettings() }) + $('#filter').addEventListener('change', e => { settings.filter = e.target.value; saveSettings(); applyFilter() }) + $('#bright').addEventListener('input', e => { settings.brightness = Number(e.target.value); saveSettings(); applyFilter() }) + $('#bright').addEventListener('dblclick', e => { e.target.value = settings.brightness = 100; saveSettings(); applyFilter() }) + $('#saver')?.addEventListener('change', e => { settings.saver = e.target.checked; saveSettings(); history.replaceState(null, '', readUrl(mangaId, chapterId, page)); router() }) + $('#chap').addEventListener('change', e => { location.hash = readUrl(mangaId, e.target.value) }) + slider.addEventListener('input', () => goTo(Number(slider.value) - 1)) + + // Toques: izquierda/derecha cambian de página (según dirección), centro muestra/oculta barras. + box.addEventListener('click', e => { + if (e.target.closest('a,button')) return + if (settings.mode !== 'paged') { reader.classList.toggle('hide-ui'); return } + const x = e.clientX / window.innerWidth + if (x > 0.35 && x < 0.65) { reader.classList.toggle('hide-ui'); return } + const forward = (x >= 0.65) === (settings.dir === 'ltr') + if (atEnd && !forward) { atEnd = false; box.innerHTML = ''; goTo(total - 1); return } + if (!atEnd) goTo(page + (forward ? 1 : -1)) + }) + const onKey = e => { + if (settings.mode !== 'paged' || e.target.matches('input,select')) return + const fwdKey = settings.dir === 'ltr' ? 'ArrowRight' : 'ArrowLeft' + const backKey = settings.dir === 'ltr' ? 'ArrowLeft' : 'ArrowRight' + if (e.key === fwdKey || e.key === ' ') { e.preventDefault(); if (!atEnd) goTo(page + 1) } + else if (e.key === backKey) { if (atEnd) { atEnd = false; box.innerHTML = ''; goTo(total - 1) } else goTo(page - 1) } + } + document.addEventListener('keydown', onKey) + let lastY = 0 + const onScroll = () => { + if (settings.mode !== 'vertical') return + const y = window.scrollY + if (Math.abs(y - lastY) > 30) { reader.classList.toggle('hide-ui', y > lastY); lastY = y } + } + window.addEventListener('scroll', onScroll, { passive: true }) + + render() + cleanup = () => { + if (saveTimer) saveNow() + observer?.disconnect() + document.removeEventListener('visibilitychange', onVis) + window.removeEventListener('pagehide', beacon) + document.removeEventListener('keydown', onKey) + window.removeEventListener('scroll', onScroll) + } +} + +// ---------- Historial ---------- +async function viewHistory() { + app.innerHTML = '
    Cargando…
    ' + const hist = await api('/api/history') + app.innerHTML = `

    Historial de lectura

    + ${hist.length ? '' : '

    Aún no has leído nada.

    '} +
    ${hist.map(h => ` +
    + ${continueCard(h).replace('class="cont"', 'style="display:flex;gap:12px;flex:1;min-width:0;align-items:center"')} +
    + Ficha + +
    +
    `).join('')}
    ` + app.querySelectorAll('[data-del]').forEach(b => b.addEventListener('click', async () => { + if (!confirm('¿Borrar el historial de este manga?')) return + await api(`/api/history/${b.dataset.del}`, { method: 'DELETE' }) + viewHistory() + })) +} + +// ---------- Ajustes ---------- +function viewSettings() { + app.innerHTML = `

    Ajustes

    +
    +
    Cuenta
    Sesión iniciada como ${esc(me.username)}
    +
    +
    Idioma preferido de los capítulos +
    +
    Cambiar contraseña +
    + + +
    +
    ` + $('#logout').addEventListener('click', async () => { await api('/api/logout', { method: 'POST' }); me = null; location.hash = '#/login'; router() }) + $('#lang').addEventListener('change', async e => { await api('/api/settings', { method: 'POST', body: { lang: e.target.value } }); me.lang = e.target.value; toast('Idioma guardado') }) + $('#pw').addEventListener('submit', async e => { + e.preventDefault() + try { + await api('/api/settings', { method: 'POST', body: { oldPassword: e.target.old.value, newPassword: e.target.new.value } }) + e.target.reset(); toast('Contraseña cambiada') + } catch (err) { toast(err.message) } + }) +} diff --git a/app/public/icon.svg b/app/public/icon.svg new file mode 100644 index 0000000..e450479 --- /dev/null +++ b/app/public/icon.svg @@ -0,0 +1 @@ + diff --git a/app/public/index.html b/app/public/index.html new file mode 100644 index 0000000..cd849a9 --- /dev/null +++ b/app/public/index.html @@ -0,0 +1,24 @@ + + + + + + +Manga Reader + + + + +
    + Manga Reader + +
    +
    + + + + diff --git a/app/public/style.css b/app/public/style.css new file mode 100644 index 0000000..82a5744 --- /dev/null +++ b/app/public/style.css @@ -0,0 +1,121 @@ +:root { + --bg: #14121a; + --surface: #1e1b26; + --surface-2: #2a2635; + --text: #ece8f4; + --muted: #9a93ab; + --accent: #bf78fa; + --accent-ink: #14121a; + --danger: #ff6b81; + --radius: 10px; + color-scheme: dark; +} +* { box-sizing: border-box; } +html, body { margin: 0; background: var(--bg); color: var(--text); font: 15px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; } +a { color: inherit; text-decoration: none; } +button, input, select { font: inherit; color: inherit; } +img { display: block; } + +#topbar { position: sticky; top: 0; z-index: 20; display: flex; align-items: center; justify-content: space-between; gap: 12px; + padding: 10px 16px; background: rgba(20, 18, 26, .92); backdrop-filter: blur(8px); border-bottom: 1px solid var(--surface-2); } +.brand { display: flex; align-items: center; gap: 8px; font-weight: 700; } +#topbar nav { display: flex; gap: 4px; } +#topbar nav a { padding: 6px 10px; border-radius: 8px; color: var(--muted); } +#topbar nav a.active, #topbar nav a:hover { color: var(--text); background: var(--surface-2); } +body.reading #topbar { display: none; } + +main { max-width: 1200px; margin: 0 auto; padding: 16px; } +body.reading main { max-width: none; padding: 0; } +h1 { font-size: 1.6rem; margin: 0 0 8px; } +h2 { font-size: 1.15rem; margin: 24px 0 12px; } +.muted { color: var(--muted); } +.row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; } + +.btn { display: inline-flex; align-items: center; justify-content: center; gap: 6px; padding: 9px 16px; border: 1px solid var(--surface-2); + border-radius: var(--radius); background: var(--surface); cursor: pointer; } +.btn:hover { background: var(--surface-2); } +.btn.primary { background: var(--accent); color: var(--accent-ink); border-color: var(--accent); font-weight: 600; } +.btn.primary:hover { filter: brightness(1.08); } +.btn.small { padding: 5px 10px; font-size: .88rem; } +.btn.danger { color: var(--danger); } +input[type=text], input[type=password], input[type=search], select { + padding: 9px 12px; border-radius: var(--radius); border: 1px solid var(--surface-2); background: var(--surface); outline: none; } +input:focus, select:focus { border-color: var(--accent); } + +.auth { max-width: 360px; margin: 10vh auto; background: var(--surface); padding: 24px; border-radius: 14px; } +.auth form { display: grid; gap: 12px; } +.error { color: var(--danger); min-height: 1.2em; margin: 0; } + +.search { display: flex; gap: 8px; margin-bottom: 8px; } +.search input { flex: 1; min-width: 0; } +.tabs { display: flex; gap: 6px; margin: 12px 0; flex-wrap: wrap; } +.tabs button { padding: 6px 12px; border-radius: 999px; border: 1px solid var(--surface-2); background: transparent; cursor: pointer; color: var(--muted); } +.tabs button.on { background: var(--surface-2); color: var(--text); } + +.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 14px; } +.card { position: relative; display: block; border-radius: var(--radius); overflow: hidden; background: var(--surface); } +.card .cover { aspect-ratio: 2 / 3; width: 100%; object-fit: cover; background: var(--surface-2); } +.card .t { padding: 8px 10px 10px; font-size: .9rem; line-height: 1.3; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } +.card .sub { padding: 0 10px 10px; font-size: .8rem; color: var(--muted); margin-top: -6px; } +.bar { height: 4px; background: var(--surface-2); } +.bar > i { display: block; height: 100%; background: var(--accent); } + +.continue { display: grid; grid-auto-flow: column; grid-auto-columns: minmax(260px, 320px); gap: 12px; overflow-x: auto; padding-bottom: 6px; } +.cont { display: flex; gap: 12px; padding: 10px; border-radius: var(--radius); background: var(--surface); align-items: center; } +.cont img { width: 56px; height: 84px; object-fit: cover; border-radius: 6px; background: var(--surface-2); flex: none; } +.cont .info { min-width: 0; flex: 1; } +.cont .info b { display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.cont .bar { margin-top: 6px; border-radius: 2px; } + +.detail { display: grid; grid-template-columns: 220px 1fr; gap: 24px; } +.detail .cover { width: 100%; border-radius: var(--radius); aspect-ratio: 2/3; object-fit: cover; background: var(--surface-2); } +.desc { white-space: pre-line; color: #cfc8dc; max-height: 12em; overflow: auto; } +.tags { display: flex; flex-wrap: wrap; gap: 6px; margin: 8px 0; } +.tag { font-size: .78rem; padding: 2px 8px; border-radius: 999px; background: var(--surface-2); color: var(--muted); } +.chapters { list-style: none; padding: 0; margin: 0; display: grid; gap: 6px; } +.chapters a { display: flex; justify-content: space-between; gap: 12px; padding: 10px 12px; border-radius: 8px; background: var(--surface); align-items: center; } +.chapters a:hover { background: var(--surface-2); } +.chapters a.read { opacity: .5; } +.chapters a.current { outline: 1px solid var(--accent); opacity: 1; } +.chapters .meta { font-size: .8rem; color: var(--muted); text-align: right; flex: none; } + +.history { display: grid; gap: 10px; } +.history .cont { justify-content: space-between; } + +.settings { max-width: 480px; display: grid; gap: 16px; } +.settings section { background: var(--surface); padding: 16px; border-radius: var(--radius); display: grid; gap: 10px; } + +.loading { padding: 40px; text-align: center; color: var(--muted); } + +/* ---- Lector ---- */ +.reader { min-height: 100vh; background: #000; position: relative; } +.rbar { position: fixed; left: 0; right: 0; z-index: 30; display: flex; align-items: center; gap: 8px; padding: 8px 12px; + background: rgba(10, 9, 14, .9); backdrop-filter: blur(6px); transition: transform .2s; flex-wrap: wrap; } +.rbar.top { top: 0; padding-top: max(8px, env(safe-area-inset-top)); } +.rbar.bottom { bottom: 0; justify-content: center; padding-bottom: max(8px, env(safe-area-inset-bottom)); } +.reader.hide-ui .rbar.top { transform: translateY(-110%); } +.reader.hide-ui .rbar.bottom { transform: translateY(110%); } +.rbar .ttl { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: .9rem; } +.rbar select { padding: 5px 8px; } +.rbar input[type=range] { flex: 1; max-width: 520px; accent-color: var(--accent); } + +#pages img { filter: var(--page-filter, none); transition: filter .2s; } +.paged { height: 100vh; height: 100dvh; display: flex; align-items: center; justify-content: center; user-select: none; } +.paged img { max-width: 100%; max-height: 100%; object-fit: contain; } +.paged.fit-width { height: auto; min-height: 100vh; align-items: flex-start; } +.paged.fit-width img { max-height: none; width: 100%; max-width: 900px; } +.vertical { display: flex; flex-direction: column; align-items: center; } +.vertical img { width: 100%; max-width: 900px; min-height: 300px; background: #111; } +.vertical img[data-loaded] { min-height: 0; } +.chapter-end { padding: 40px 16px 120px; text-align: center; display: grid; gap: 12px; justify-items: center; } + +#toast { position: fixed; bottom: 80px; left: 50%; transform: translateX(-50%); z-index: 50; background: var(--surface-2); + padding: 10px 16px; border-radius: var(--radius); box-shadow: 0 6px 20px rgba(0,0,0,.4); } + +@media (max-width: 640px) { + .detail { grid-template-columns: 1fr; } + .detail .cover { max-width: 180px; } + .grid { grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); gap: 10px; } + #topbar .brand span { display: none; } + #topbar nav a { padding: 6px 8px; } +} diff --git a/app/server.js b/app/server.js new file mode 100644 index 0000000..f73f5c8 --- /dev/null +++ b/app/server.js @@ -0,0 +1,510 @@ +// Manga Reader: lector web con las mismas fuentes que Tanko (MangaDex y LeerCapitulo) +// con usuarios y historial de lectura persistente en SQLite. +import http from 'node:http' +import fs from 'node:fs' +import path from 'node:path' +import crypto from 'node:crypto' +import { DatabaseSync } from 'node:sqlite' + +const PORT = Number(process.env.PORT || 3352) +const DATA_DIR = process.env.DATA_DIR || '/app/data' +const ALLOW_SIGNUP = (process.env.ALLOW_SIGNUP ?? 'true') !== 'false' +const PUBLIC_DIR = path.resolve(import.meta.dirname, 'public') +const MD_API = 'https://api.mangadex.org' +const LC_BASE = 'https://www.leercapitulo.co' +const BROWSER_UA = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0 Safari/537.36' +const UA = 'manga-reader-selfhosted/1.0' +const SESSION_DAYS = 120 + +// ---------- Base de datos ---------- +fs.mkdirSync(DATA_DIR, { recursive: true }) +const db = new DatabaseSync(path.join(DATA_DIR, 'manga.db')) +const SECRET_FILE = path.join(DATA_DIR, 'secret') +if (!fs.existsSync(SECRET_FILE)) fs.writeFileSync(SECRET_FILE, crypto.randomBytes(32).toString('hex'), { mode: 0o600 }) +const SECRET = fs.readFileSync(SECRET_FILE, 'utf8').trim() +db.exec(` +PRAGMA journal_mode = WAL; +PRAGMA foreign_keys = ON; +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL COLLATE NOCASE, + pass_hash TEXT NOT NULL, + lang TEXT NOT NULL DEFAULT 'es-la', + created_at INTEGER NOT NULL +); +CREATE TABLE IF NOT EXISTS sessions ( + token TEXT PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + expires_at INTEGER NOT NULL +); +CREATE TABLE IF NOT EXISTS progress ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + manga_id TEXT NOT NULL, + manga_title TEXT NOT NULL, + cover TEXT, + chapter_id TEXT NOT NULL, + chapter_num TEXT, + chapter_title TEXT, + lang TEXT, + page INTEGER NOT NULL DEFAULT 0, + total_pages INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL, + PRIMARY KEY (user_id, manga_id) +); +CREATE TABLE IF NOT EXISTS chapter_reads ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + chapter_id TEXT NOT NULL, + manga_id TEXT NOT NULL, + page INTEGER NOT NULL DEFAULT 0, + total_pages INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL, + PRIMARY KEY (user_id, chapter_id) +); +CREATE INDEX IF NOT EXISTS idx_reads_manga ON chapter_reads(user_id, manga_id); +`) +db.prepare('DELETE FROM sessions WHERE expires_at < ?').run(Date.now()) + +// ---------- Contraseñas y sesiones ---------- +function hashPassword(pw) { + const salt = crypto.randomBytes(16) + const hash = crypto.scryptSync(pw, salt, 64) + return `${salt.toString('hex')}:${hash.toString('hex')}` +} +function verifyPassword(pw, stored) { + const [saltHex, hashHex] = stored.split(':') + const hash = crypto.scryptSync(pw, Buffer.from(saltHex, 'hex'), 64) + return crypto.timingSafeEqual(hash, Buffer.from(hashHex, 'hex')) +} +function createSession(userId) { + const token = crypto.randomBytes(32).toString('base64url') + db.prepare('INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)') + .run(token, userId, Date.now() + SESSION_DAYS * 86400e3) + return token +} +function sessionCookie(token, maxAge) { + return `sid=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAge}` +} +function getUser(req) { + const m = /(?:^|;\s*)sid=([^;]+)/.exec(req.headers.cookie || '') + if (!m) return null + return db.prepare(`SELECT u.id, u.username, u.lang FROM sessions s JOIN users u ON u.id = s.user_id + WHERE s.token = ? AND s.expires_at > ?`).get(m[1], Date.now()) ?? null +} + +// ---------- Cliente MangaDex (adaptado de Tanko src/servers/mangadex.ts) ---------- +const cache = new Map() +async function cached(key, ttl, fn) { + const hit = cache.get(key) + if (hit && hit.exp > Date.now()) return hit.data + const data = await fn() + cache.set(key, { data, exp: Date.now() + ttl }) + if (cache.size > 500) cache.delete(cache.keys().next().value) + return data +} + +// Las imágenes pasan por /api/img firmadas con HMAC: el proxy solo descarga URLs generadas por este servidor. +const sign = u => crypto.createHmac('sha256', SECRET).update(u).digest('base64url').slice(0, 22) +const imgUrl = u => `/api/img?u=${encodeURIComponent(u)}&s=${sign(u)}` + +async function md(pathname, params = {}, ttl = 5 * 60e3) { + const url = new URL(MD_API + pathname) + for (const [k, v] of Object.entries(params)) { + if (Array.isArray(v)) v.forEach(x => url.searchParams.append(k, x)) + else if (v !== undefined && v !== '') url.searchParams.set(k, v) + } + return cached(url.toString(), ttl, async () => { + const res = await fetch(url, { headers: { 'User-Agent': UA } }) + if (!res.ok) throw new HttpError(502, `MangaDex respondió ${res.status}`) + return res.json() + }) +} + +const pick = (obj, langs) => { + if (!obj) return '' + for (const l of langs) if (obj[l]) return obj[l] + return Object.values(obj)[0] ?? '' +} +function mangaSummary(m, lang) { + const a = m.attributes + const langs = [lang, 'es', 'es-la', 'en', 'ja-ro'] + let title = a.title.en ?? Object.values(a.title)[0] + const alt = (a.altTitles || []).find(t => t[lang] || t.es || t['es-la']) + if (alt) title = pick(alt, [lang, 'es-la', 'es']) + const coverRel = m.relationships?.find(r => r.type === 'cover_art') + const cover = coverRel?.attributes?.fileName + ? imgUrl(`https://uploads.mangadex.org/covers/${m.id}/${coverRel.attributes.fileName}.256.jpg`) + : null + return { + id: m.id, + source: 'md', + title, + description: pick(a.description, langs), + status: a.status, + year: a.year, + tags: (a.tags || []).map(t => pick(t.attributes.name, ['es', 'en'])).slice(0, 8), + authors: (m.relationships || []).filter(r => r.type === 'author' && r.attributes).map(r => r.attributes.name), + languages: a.availableTranslatedLanguages || [], + cover, + } +} +const RATINGS = ['safe', 'suggestive'] + +async function searchManga(q, lang, mode) { + const params = { + limit: 30, + 'includes[]': ['cover_art'], + 'contentRating[]': RATINGS, + 'availableTranslatedLanguage[]': lang ? [lang] : undefined, + } + if (q) { params.title = q; params['order[relevance]'] = 'desc' } + else if (mode === 'latest') params['order[latestUploadedChapter]'] = 'desc' + else params['order[followedCount]'] = 'desc' + const data = await md('/manga', params) + return data.data.map(m => mangaSummary(m, lang)) +} + +async function getManga(id, lang) { + const data = await md(`/manga/${id}`, { 'includes[]': ['cover_art', 'author'] }, 30 * 60e3) + return mangaSummary(data.data, lang) +} + +// Lista de capítulos en un idioma, un registro por número de capítulo (como Tanko). +// El español junta es-la y es, prefiriendo la variante elegida. +async function getChapters(mangaId, lang) { + const langs = lang === 'es-la' ? ['es-la', 'es'] : lang === 'es' ? ['es', 'es-la'] : [lang] + const all = [] + for (let offset = 0; ; offset += 500) { + const data = await md(`/manga/${mangaId}/feed`, { + limit: 500, offset, + 'translatedLanguage[]': langs, + 'order[volume]': 'asc', 'order[chapter]': 'asc', + 'contentRating[]': [...RATINGS, 'erotica'], + includeExternalUrl: 0, includeEmptyPages: 0, includeFuturePublishAt: 0, + 'includes[]': ['scanlation_group'], + }) + all.push(...data.data) + if (offset + 500 >= data.total || data.data.length === 0) break + } + const byNum = new Map() + for (const c of all) { + const num = c.attributes.chapter ?? `oneshot-${c.id}` + const prev = byNum.get(num) + if (prev && langs.indexOf(prev.lang) <= langs.indexOf(c.attributes.translatedLanguage)) continue + byNum.set(num, { + id: c.id, + num: c.attributes.chapter, + volume: c.attributes.volume, + title: c.attributes.title || '', + pages: c.attributes.pages, + group: c.relationships.find(r => r.type === 'scanlation_group')?.attributes?.name || '', + publishAt: c.attributes.publishAt, + lang: c.attributes.translatedLanguage, + }) + } + const out = [...byNum.values()] + out.sort((a, b) => (parseFloat(a.num) || 0) - (parseFloat(b.num) || 0)) + return out +} + +// Idiomas con capítulos legibles de verdad (availableTranslatedLanguages incluye capítulos retirados o externos). +async function getChapterLangs(mangaId) { + const data = await md(`/manga/${mangaId}/feed`, { + limit: 500, includeExternalUrl: 0, includeEmptyPages: 0, includeFuturePublishAt: 0, + 'contentRating[]': [...RATINGS, 'erotica'], + }, 30 * 60e3) + const count = {} + for (const c of data.data) count[c.attributes.translatedLanguage] = (count[c.attributes.translatedLanguage] || 0) + 1 + return Object.entries(count).sort((a, b) => b[1] - a[1]).map(([l]) => l) +} + +async function getPages(chapterId, saver) { + // Las URLs de at-home caducan a los ~15 min: caché corta. + const data = await md(`/at-home/server/${chapterId}`, {}, 5 * 60e3) + const { baseUrl, chapter } = data + const files = saver ? chapter.dataSaver : chapter.data + const kind = saver ? 'data-saver' : 'data' + return files.map(f => imgUrl(`${baseUrl}/${kind}/${chapter.hash}/${f}`)) +} + +// ---------- Cliente LeerCapitulo (Tanko src/servers/leerCapitulo.ts, adaptado al sitio actual) ---------- +// IDs: manga "lc~~", capítulo "lc~~~". Ya no hace falta navegador: +// las páginas del capítulo vienen en el HTML como . +const decode = s => String(s ?? '').replace(/<[^>]+>/g, '').replace(/&(#x?[0-9a-f]+|amp|lt|gt|quot|apos|nbsp|lsaquo|rsaquo);/gi, (_, e) => { + const named = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', lsaquo: '‹', rsaquo: '›' }[e.toLowerCase()] + if (named) return named + return String.fromCodePoint(e[1].toLowerCase() === 'x' ? parseInt(e.slice(2), 16) : parseInt(e.slice(1), 10)) +}).trim() +async function lc(pathname, ttl = 10 * 60e3, asJson = false) { + return cached(`lc:${pathname}`, ttl, async () => { + const res = await fetch(LC_BASE + pathname, { headers: { 'User-Agent': BROWSER_UA, 'Accept-Language': 'es' } }) + if (!res.ok) throw new HttpError(res.status === 404 ? 404 : 502, `LeerCapitulo respondió ${res.status}`) + return asJson ? res.json() : res.text() + }) +} +const lcCover = src => (src ? imgUrl(new URL(src, LC_BASE).toString()) : null) +const lcId = uri => { + const m = /\/manga\/([a-z0-9]+)\/([a-z0-9-]+)\//.exec(uri || '') + return m ? `lc~${m[1]}~${m[2]}` : null +} +function lcParseCards(html) { + const re = /class="lc-(?:slide|side-item|release|card)(?:-cover)?" href="(\/manga\/[a-z0-9]+\/[a-z0-9-]+\/)"[^>]*>\s*]*>([^<]+)/g + const out = [] + const seen = new Set() + for (const m of html.matchAll(re)) { + const id = lcId(m[1]) + if (!id || seen.has(id)) continue + seen.add(id) + out.push({ id, source: 'lc', title: decode(m[3]), cover: lcCover(m[2]) }) + } + return out +} +async function lcSearch(q, mode) { + if (q) { + const list = await lc(`/search/?q=${encodeURIComponent(q)}`, 10 * 60e3, true) + return list.map(e => ({ id: lcId(e.uri), source: 'lc', title: decode(e.name), cover: lcCover(e.cover_uri), sub: e.last_chapter_name })) + .filter(e => e.id) + } + const html = await lc('/') + const section = (from, to) => { + const i = html.indexOf(from) + if (i < 0) return '' + const j = html.indexOf(to, i + from.length) + return html.slice(i, j < 0 ? undefined : j) + } + if (mode === 'latest') return lcParseCards(section('

    Ultimos capitulos', 'Tendencias', '')), ...lcParseCards(section('>Populares

    ', 'lc-panel-title'))] + return popular.filter((m, i) => popular.findIndex(x => x.id === m.id) === i) +} +async function lcManga(id) { + const [, mid, slug] = id.split('~') + const html = await lc(`/manga/${mid}/${slug}/`) + const fact = k => decode(new RegExp(`${k}\\s*<(?:a|span)[^>]*>([^<]*)<`).exec(html)?.[1]) + const sinopsis = /id="sinopsis"[\s\S]*?]*>([\s\S]*?)<\/p>/.exec(html)?.[1] + const chapters = [] + const seen = new Set() + const re = new RegExp(`href="/leer/${mid}/${slug}/([0-9a-z.-]+)/">\\s*([^<]*)[\\s\\S]*?([^<]*)`, 'g') + for (const m of html.matchAll(re)) { + if (seen.has(m[1])) continue + seen.add(m[1]) + const title = decode(m[2]).replace(/^Cap[ií]tulo\s+[0-9.]+\s*[:-]?\s*/i, '') + chapters.push({ id: `${id}~${m[1]}`, num: m[1], volume: null, title, pages: null, group: m[3], publishAt: m[3] }) + } + chapters.sort((a, b) => (parseFloat(a.num) || 0) - (parseFloat(b.num) || 0)) + const manga = { + id, + source: 'lc', + title: decode(/]*>([^<]+)/.exec(html)?.[1]) || slug, + description: decode(sinopsis?.replace(//g, '\n')), + status: fact('Estado'), + year: null, + tags: [...html.matchAll(/class="badge [^"]*" href="\/manga\/\?(?:genre|theme)=[^"]+">([^<]+) decode(m[1])).slice(0, 10), + authors: fact('Autor') ? [fact('Autor')] : [], + languages: ['es'], + cover: lcCover(/class="lc-cover-lg">\s* imgUrl(decode(m[1]))) + if (!pages.length) throw new HttpError(502, 'No se encontraron páginas en LeerCapitulo') + return pages +} + +// Proxy de imágenes: MangaDex bloquea el hotlinking de portadas y así el navegador no depende de CDNs ajenos. +async function proxyImage(req, res, u, s) { + let url + try { url = new URL(u) } catch { throw new HttpError(400, 'URL inválida') } + const expected = sign(u) + if (typeof s !== 'string' || s.length !== expected.length || !crypto.timingSafeEqual(Buffer.from(s), Buffer.from(expected))) throw new HttpError(403, 'Firma inválida') + if (!/^https?:$/.test(url.protocol) || /^(localhost|\d+\.\d+\.\d+\.\d+|\[.*\])$/.test(url.hostname)) throw new HttpError(403, 'Host no permitido') + const isMd = /(^|\.)mangadex\.(org|network)$/.test(url.hostname) + const r = await fetch(url, { headers: { 'User-Agent': isMd ? UA : BROWSER_UA, ...(isMd ? {} : { Referer: LC_BASE + '/' }) } }) + if (!r.ok || !r.body) throw new HttpError(502, `Imagen ${r.status}`) + res.writeHead(200, { + 'Content-Type': r.headers.get('content-type') || 'image/jpeg', + 'Cache-Control': 'private, max-age=604800, immutable', + }) + for await (const chunk of r.body) res.write(chunk) + res.end() +} + +// ---------- HTTP ---------- +class HttpError extends Error { + constructor(status, message) { super(message); this.status = status } +} +function json(res, status, body, headers = {}) { + res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store', ...headers }) + res.end(JSON.stringify(body)) +} +async function readBody(req) { + let size = 0 + const chunks = [] + for await (const c of req) { + size += c.length + if (size > 64 * 1024) throw new HttpError(413, 'Cuerpo demasiado grande') + chunks.push(c) + } + try { return JSON.parse(Buffer.concat(chunks).toString() || '{}') } catch { throw new HttpError(400, 'JSON inválido') } +} +const MANGA_ID = /^([0-9a-f-]{36}|lc~[a-z0-9]+~[a-z0-9-]+)$/ +const CHAPTER_ID = /^([0-9a-f-]{36}|lc~[a-z0-9]+~[a-z0-9-]+~[0-9a-z.-]+)$/ +const LANG = /^[a-z]{2}(-[a-z]{2})?$/ +const requireUser = req => { + const u = getUser(req) + if (!u) throw new HttpError(401, 'No autenticado') + return u +} + +const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.svg': 'image/svg+xml', '.webmanifest': 'application/manifest+json' } +function serveStatic(res, pathname) { + let file = path.join(PUBLIC_DIR, path.normalize(pathname).replace(/^(\.\.[/\\])+/, '')) + if (!file.startsWith(PUBLIC_DIR) || !fs.existsSync(file) || fs.statSync(file).isDirectory()) file = path.join(PUBLIC_DIR, 'index.html') + res.writeHead(200, { 'Content-Type': MIME[path.extname(file)] || 'application/octet-stream', 'Cache-Control': 'no-cache' }) + fs.createReadStream(file).pipe(res) +} + +const loginAttempts = new Map() +function rateLimit(ip) { + const now = Date.now() + const list = (loginAttempts.get(ip) || []).filter(t => now - t < 10 * 60e3) + if (list.length >= 10) throw new HttpError(429, 'Demasiados intentos, espera unos minutos') + list.push(now) + loginAttempts.set(ip, list) +} + +async function route(req, res) { + const url = new URL(req.url, 'http://x') + const p = url.pathname + const q = url.searchParams + const method = req.method + + if (!p.startsWith('/api/')) return serveStatic(res, p) + + // --- Auth --- + if (p === '/api/me' && method === 'GET') { + const userCount = db.prepare('SELECT COUNT(*) n FROM users').get().n + return json(res, 200, { user: getUser(req), signup: ALLOW_SIGNUP || userCount === 0 }) + } + if ((p === '/api/login' || p === '/api/register') && method === 'POST') { + rateLimit(req.socket.remoteAddress) + const { username, password } = await readBody(req) + if (typeof username !== 'string' || typeof password !== 'string') throw new HttpError(400, 'Datos incompletos') + let user + if (p === '/api/register') { + const userCount = db.prepare('SELECT COUNT(*) n FROM users').get().n + if (!ALLOW_SIGNUP && userCount > 0) throw new HttpError(403, 'El registro está deshabilitado') + if (!/^[\w.-]{3,32}$/.test(username)) throw new HttpError(400, 'Usuario: 3-32 caracteres (letras, números, . _ -)') + if (password.length < 6) throw new HttpError(400, 'La contraseña debe tener al menos 6 caracteres') + if (db.prepare('SELECT 1 FROM users WHERE username = ?').get(username)) throw new HttpError(409, 'Ese usuario ya existe') + const r = db.prepare('INSERT INTO users (username, pass_hash, created_at) VALUES (?, ?, ?)') + .run(username, hashPassword(password), Date.now()) + user = { id: Number(r.lastInsertRowid) } + } else { + const row = db.prepare('SELECT id, pass_hash FROM users WHERE username = ?').get(username) + if (!row || !verifyPassword(password, row.pass_hash)) throw new HttpError(401, 'Usuario o contraseña incorrectos') + user = row + } + const token = createSession(user.id) + return json(res, 200, { ok: true }, { 'Set-Cookie': sessionCookie(token, SESSION_DAYS * 86400) }) + } + if (p === '/api/logout' && method === 'POST') { + const m = /(?:^|;\s*)sid=([^;]+)/.exec(req.headers.cookie || '') + if (m) db.prepare('DELETE FROM sessions WHERE token = ?').run(m[1]) + return json(res, 200, { ok: true }, { 'Set-Cookie': sessionCookie('', 0) }) + } + + // Todo lo demás requiere sesión. + const user = requireUser(req) + + if (p === '/api/settings' && method === 'POST') { + const body = await readBody(req) + if (body.lang !== undefined) { + if (!LANG.test(body.lang)) throw new HttpError(400, 'Idioma inválido') + db.prepare('UPDATE users SET lang = ? WHERE id = ?').run(body.lang, user.id) + } + if (body.newPassword !== undefined) { + const row = db.prepare('SELECT pass_hash FROM users WHERE id = ?').get(user.id) + if (!verifyPassword(String(body.oldPassword || ''), row.pass_hash)) throw new HttpError(401, 'Contraseña actual incorrecta') + if (String(body.newPassword).length < 6) throw new HttpError(400, 'La contraseña debe tener al menos 6 caracteres') + db.prepare('UPDATE users SET pass_hash = ? WHERE id = ?').run(hashPassword(String(body.newPassword)), user.id) + } + return json(res, 200, { ok: true }) + } + + if (p === '/api/img' && method === 'GET') return proxyImage(req, res, q.get('u') || '', q.get('s')) + + const lang = LANG.test(q.get('lang') || '') ? q.get('lang') : user.lang + + if (p === '/api/search' && method === 'GET') { + if (q.get('source') === 'lc') return json(res, 200, await lcSearch((q.get('q') || '').slice(0, 100), q.get('mode'))) + const anyLang = q.get('anyLang') === '1' + return json(res, 200, await searchManga((q.get('q') || '').slice(0, 100), anyLang ? null : lang, q.get('mode'))) + } + + let m + if ((m = /^\/api\/manga\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'GET') { + const { manga, chapters } = m[1].startsWith('lc~') + ? await lcManga(m[1]) + : { manga: await getManga(m[1], lang), chapters: await getChapters(m[1], lang) } + const reads = db.prepare('SELECT chapter_id, page, total_pages FROM chapter_reads WHERE user_id = ? AND manga_id = ?').all(user.id, m[1]) + const progress = db.prepare('SELECT * FROM progress WHERE user_id = ? AND manga_id = ?').get(user.id, m[1]) ?? null + const chapterLangs = chapters.length || manga.source === 'lc' ? [] : await getChapterLangs(m[1]) + return json(res, 200, { manga, chapters, lang, reads, progress, chapterLangs }) + } + if ((m = /^\/api\/chapter\/([^/]+)$/.exec(p)) && CHAPTER_ID.test(m[1]) && method === 'GET') { + const pages = m[1].startsWith('lc~') ? await lcPages(m[1]) : await getPages(m[1], q.get('saver') === '1') + const read = db.prepare('SELECT page FROM chapter_reads WHERE user_id = ? AND chapter_id = ?').get(user.id, m[1]) + return json(res, 200, { pages, savedPage: read?.page ?? 0 }) + } + + // --- Historial --- + if (p === '/api/history' && method === 'GET') { + return json(res, 200, db.prepare('SELECT * FROM progress WHERE user_id = ? ORDER BY updated_at DESC LIMIT 200').all(user.id)) + } + if (p === '/api/progress' && method === 'POST') { + const b = await readBody(req) + if (!MANGA_ID.test(b.mangaId || '') || !CHAPTER_ID.test(b.chapterId || '') || !b.chapterId.startsWith(b.mangaId.startsWith('lc~') ? b.mangaId + '~' : '')) throw new HttpError(400, 'IDs inválidos') + const page = Math.max(0, Number(b.page) | 0) + const total = Math.max(0, Number(b.totalPages) | 0) + const now = Date.now() + db.prepare(`INSERT INTO progress (user_id, manga_id, manga_title, cover, chapter_id, chapter_num, chapter_title, lang, page, total_pages, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (user_id, manga_id) DO UPDATE SET manga_title = excluded.manga_title, cover = excluded.cover, + chapter_id = excluded.chapter_id, chapter_num = excluded.chapter_num, chapter_title = excluded.chapter_title, + lang = excluded.lang, page = excluded.page, total_pages = excluded.total_pages, updated_at = excluded.updated_at`) + .run(user.id, b.mangaId, String(b.mangaTitle || '').slice(0, 300), b.cover ? String(b.cover).slice(0, 1000) : null, + b.chapterId, b.chapterNum != null ? String(b.chapterNum) : null, String(b.chapterTitle || '').slice(0, 300), + LANG.test(b.lang || '') ? b.lang : null, page, total, now) + db.prepare(`INSERT INTO chapter_reads (user_id, chapter_id, manga_id, page, total_pages, updated_at) VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT (user_id, chapter_id) DO UPDATE SET page = excluded.page, total_pages = excluded.total_pages, updated_at = excluded.updated_at`) + .run(user.id, b.chapterId, b.mangaId, page, total, now) + return json(res, 200, { ok: true }) + } + if ((m = /^\/api\/history\/([^/]+)$/.exec(p)) && MANGA_ID.test(m[1]) && method === 'DELETE') { + db.prepare('DELETE FROM progress WHERE user_id = ? AND manga_id = ?').run(user.id, m[1]) + db.prepare('DELETE FROM chapter_reads WHERE user_id = ? AND manga_id = ?').run(user.id, m[1]) + return json(res, 200, { ok: true }) + } + + throw new HttpError(404, 'No encontrado') +} + +// Migración: portadas guardadas antes de firmar las URLs del proxy. +for (const row of db.prepare("SELECT user_id, manga_id, cover FROM progress WHERE cover LIKE '/api/img?u=%' AND cover NOT LIKE '%&s=%'").all()) { + const u = new URLSearchParams(row.cover.slice('/api/img?'.length)).get('u') + if (u) db.prepare('UPDATE progress SET cover = ? WHERE user_id = ? AND manga_id = ?').run(imgUrl(u), row.user_id, row.manga_id) +} + +http.createServer(async (req, res) => { + try { + await route(req, res) + } catch (err) { + const status = err instanceof HttpError ? err.status : 500 + if (status >= 500) console.error(req.method, req.url, err) + if (!res.headersSent) json(res, status, { error: err.message || 'Error' }) + else res.end() + } +}).listen(PORT, () => console.log(`Manga Reader escuchando en :${PORT} (registro ${ALLOW_SIGNUP ? 'abierto' : 'cerrado'})`)) diff --git a/data/.keep b/data/.keep new file mode 100644 index 0000000..e69de29 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..23faef8 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,32 @@ +services: + manga-reader: + build: . + container_name: manga-reader + restart: unless-stopped + environment: + - TZ=America/El_Salvador + - PORT=3352 + - DATA_DIR=/app/data + # Pon "false" cuando ya hayas creado las cuentas que necesites. + # (Si no hay ningún usuario, el primero siempre puede registrarse.) + - ALLOW_SIGNUP=${ALLOW_SIGNUP:-true} + volumes: + # SQLite con usuarios, sesiones e historial de lectura. + - ./data:/app/data + ports: + # Local del servidor, LAN y Tailscale. + - "127.0.0.1:3352:3352" + - "192.168.1.141:3352:3352" + - "${IP_TAILSCALE:-100.125.134.51}:3352:3352" + read_only: true + tmpfs: + - /tmp + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3"