383 lines
26 KiB
Markdown
383 lines
26 KiB
Markdown
# worst-scan-web — Portal Web del Fansub (Next.js 16)
|
||
|
||
Portal web público + panel admin del fansub de manga **worst-scan** (escaneado y traducción automática JP/EN/KR/CN → ES). Este repo es SOLO el frontend web (Next.js). El pipeline de traducción (descarga, OCR, LLM, inpainting, render, MOBI, bot de Telegram) vive en un repo aparte y expone una API REST FastAPI en `:8080` (`API_BASE_URL`), que esta web consume vía proxy.
|
||
|
||
**Dominio de producción:** `manga.cbcren.online` (vía Caddy con auto-HTTPS)
|
||
**Doc de arquitectura original:** ver `agy.md` (AGY = Arquitectura, Galerías, Yapa)
|
||
|
||
---
|
||
|
||
## Stack
|
||
|
||
| Capa | Tecnología |
|
||
|------|-----------|
|
||
| Framework | Next.js **16.2.11** (App Router, Turbopack, `output: standalone`) |
|
||
| UI | React **19.2.4**, Tailwind CSS **4**, iconos lucide-react |
|
||
| Datos | SQLite vía `better-sqlite3` ^13 (modo WAL), SWR ^2.4.2 en cliente |
|
||
| Auth | JWT HS256 vía `jose` ^6 (cookie `session`, expiración 7d) |
|
||
| Utilidades | JSZip ^3 (parseo CBZ en cliente), TypeScript 5 |
|
||
| Deploy | Docker (multi-stage Node 22-alpine) o systemd + Node directo; Caddy como reverse proxy |
|
||
|
||
---
|
||
|
||
## Arquitectura General
|
||
|
||
```
|
||
┌─────────────────────────────────────┐
|
||
│ Caddy (manga.cbcren.online) │
|
||
│ auto-HTTPS + TLS │
|
||
└──────────────┬──────────────────────┘
|
||
│
|
||
┌──────────────▼──────────────────────┐
|
||
│ worst-scan-web (Next.js) │
|
||
│ puerto 3000 (container) │
|
||
│ │
|
||
│ ┌─────────────────────────────┐ │
|
||
│ │ Sitio PÚBLICO (server-side)│ │
|
||
│ │ / /p/[slug] /tag/[tag] │ │
|
||
│ │ → SQLite directo (posts) │ │
|
||
│ └─────────────────────────────┘ │
|
||
│ ┌─────────────────────────────┐ │
|
||
│ │ Panel PRIVADO (client-side)│ │
|
||
│ │ /feed /queue /search │ │
|
||
│ │ /submit /admin/posts │ │
|
||
│ │ → SWR → /api/proxy/* │ │
|
||
│ └─────────────────────────────┘ │
|
||
│ ┌─────────────────────────────┐ │
|
||
│ │ API propia: /api/auth, │ │
|
||
│ │ /api/posts, /api/cover, │ │
|
||
│ │ /api/cron, /api/setup, │ │
|
||
│ │ /api/webhooks/manga, │ │
|
||
│ │ /api/proxy/[...path] │ │
|
||
│ └─────────────────────────────┘ │
|
||
└──────────────┬──────────────────────┘
|
||
│ API_BASE_URL (proxy)
|
||
┌──────────────▼──────────────────────┐
|
||
│ Pipeline REST (FastAPI :8080) │
|
||
│ /api/v1/health|status|galleries │
|
||
│ /summary|artifacts|cover|pages │
|
||
└─────────────────────────────────────┘
|
||
```
|
||
|
||
---
|
||
|
||
## Estructura de Archivos
|
||
|
||
```
|
||
src/
|
||
├── lib/
|
||
│ ├── db.ts # SQLite: tabla posts + capa de datos completa
|
||
│ ├── auth.ts # JWT HS256: createSession / verifySession
|
||
│ ├── api.ts # Cliente del pipeline REST (API_BASE_URL)
|
||
│ ├── types.ts # Interfaces (GallerySummary, etc.)
|
||
│ ├── cbz.ts # loadPagesFromCbz (JSZip) — cliente
|
||
│ ├── cover-cache.ts # cacheCover / getCoverPath (disco, por gid)
|
||
│ ├── poller.ts # startPoller / pollOnce — sync pipeline→posts
|
||
│ ├── settings.ts # initSettings / get / saveSettings (JSON)
|
||
│ ├── slug.ts # slugify / cleanTitle
|
||
│ └── utils.ts # formatSize, extractTag, cn, etc.
|
||
├── middleware.ts # Auth por prefijo de path (ver §Auth)
|
||
├── instrumentation.ts # register() → initSettings + startPoller (1 vez)
|
||
├── app/
|
||
│ ├── layout.tsx # Root: <html lang="es">, metadata, Inter
|
||
│ ├── globals.css # Tema oscuro + @utility Tailwind v4
|
||
│ ├── (public)/ # GRUPO PÚBLICO — server-side, sin login
|
||
│ │ ├── layout.tsx # Header sticky + nav "Buscar"
|
||
│ │ ├── page.tsx # / grid de posts publicados
|
||
│ │ ├── p/[slug]/page.tsx # detalle de post (fansub)
|
||
│ │ └── tag/[tag]/page.tsx # posts por tag
|
||
│ ├── (app)/ # GRUPO PRIVADO — client-side, login requerido
|
||
│ │ ├── layout.tsx # Sidebar desktop + bottom-nav móvil
|
||
│ │ ├── feed/page.tsx # /feed galleries del pipeline
|
||
│ │ ├── gallery/[gid]/page.tsx # detalle gallery
|
||
│ │ ├── gallery/[gid]/read/page.tsx # lector (paged/webtoon)
|
||
│ │ ├── queue/page.tsx # /queue estado pipeline
|
||
│ │ ├── search/page.tsx # /search nhentai/e-hentai
|
||
│ │ ├── submit/page.tsx # /submit encolar URLs
|
||
│ │ └── admin/posts/page.tsx + [id]/page.tsx # CRUD posts
|
||
│ ├── login/page.tsx # /login (password)
|
||
│ ├── setup/page.tsx # /setup primer arranque (4 campos)
|
||
│ └── api/ # Route handlers (ver §API)
|
||
├── components/ # cover-image, gallery-card, gallery-grid,
|
||
│ │ # post-card, tag-badge, filter-bar,
|
||
│ │ # search-form, submit-form, empty-state,
|
||
│ │ # layout/sidebar
|
||
├── hooks/ # use-galleries, use-queue, use-status
|
||
data/ # worst-scan.db (SQLite), covers/, (runtime)
|
||
deploy/ # Caddyfile, setup.sh, worst-scan-web.service
|
||
scripts/ # start.js, setup.sh
|
||
```
|
||
|
||
---
|
||
|
||
## Base de Datos (`src/lib/db.ts`)
|
||
|
||
SQLite vía `better-sqlite3`. Ruta: `process.env.DB_PATH` o `data/worst-scan.db`. WAL + `foreign_keys = ON`.
|
||
|
||
**Tabla `posts`:**
|
||
|
||
| Columna | Tipo | Notas |
|
||
|---------|------|-------|
|
||
| `id` | INTEGER | PK AUTOINCREMENT |
|
||
| `gid` | TEXT | NOT NULL, UNIQUE — id de la gallery del pipeline |
|
||
| `title` | TEXT | NOT NULL |
|
||
| `title_jpn` | TEXT | nullable |
|
||
| `artist` | TEXT | nullable |
|
||
| `parody` | TEXT | nullable |
|
||
| `tags` | TEXT | JSON serializado (`string[]`) |
|
||
| `num_pages` | INTEGER | DEFAULT 0 |
|
||
| `source` | TEXT | `nhentai` / `ehentai` |
|
||
| `cover_url` | TEXT | nullable |
|
||
| `url` | TEXT | nullable |
|
||
| `summary` | TEXT | nullable |
|
||
| `slug` | TEXT | UNIQUE NOT NULL — URL amigable `/p/{slug}` |
|
||
| `published` | INTEGER | DEFAULT 0 |
|
||
| `created_at` | TEXT | `datetime('now')` |
|
||
| `updated_at` | TEXT | `datetime('now')` |
|
||
| `published_at` | TEXT | nullable |
|
||
|
||
**Índices:** `idx_posts_gid`, `idx_posts_slug` (redundantes con UNIQUE), `idx_posts_published`, `idx_posts_created(created_at DESC)`.
|
||
|
||
**Funciones de la capa de datos:** `getDb()`, `migrate()`, `rowToPost()`, `getAllPosts(publishedOnly)`, `getPostById()`, `getPostByGid()`, `getPostBySlug()`, `createPost()`, `updatePost()`, `publishPost()`, `deletePost()`, `getPostsByTag()` (usa `json_each`), `getPostCount()`, `getGidsNotInPosts()` (**stub — devuelve `[]`**).
|
||
|
||
> ⚠️ **`createPost` publica por defecto** (`published = input.published ?? 1`) — todo post nuevo sale publicado.
|
||
> ⚠️ **`updatePost` interpola los nombres de columna del body sin whitelist** → riesgo de SQL injection (ver §Bugs).
|
||
|
||
---
|
||
|
||
## Autenticación (`src/lib/auth.ts` + `src/middleware.ts`)
|
||
|
||
- **Cookie:** `session` (httpOnly, `secure` en production, sameSite lax, 7d).
|
||
- **Token:** JWT HS256, payload `{ authenticated: true, timestamp }`, expiración `7d`.
|
||
- **Secret:** `process.env.JWT_SECRET || process.env.WEB_PASSWORD || "worst-scan-web-dev-secret"` ⚠️ fallback predecible.
|
||
- **Login:** `POST /api/auth/login` con `{ password }` → compara con `WEB_PASSWORD`. **Si no hay `WEB_PASSWORD` configurada, emite sesión sin verificar nada.**
|
||
- **Logout:** `POST /api/auth/logout` borra la cookie (el JWT no se revoca, solo expira).
|
||
- **Middleware** (`src/middleware.ts`): paths en `publicPaths` → allow; páginas `/`, `/p/`, `/tag/` → allow; **sin `WEB_PASSWORD` → TODO abierto**; si no, exige cookie válida → redirect `/login`.
|
||
|
||
**`publicPaths`:** `/login`, `/api/auth/login`, `/api/auth/logout`, `/api/health`, `/api/proxy/health`, `/api/posts`, `/api/cover`, `/api/cron`, `/api/setup`, `/setup`, `/_next`, `/favicon.ico`, `/fonts`.
|
||
|
||
> ⚠️ **BUG:** la coincidencia es por **prefijo** (`pathname.startsWith(p)`): `/api/posts` también destapa `/api/posts/[id]` y `/api/posts/[id]/publish`; `/api/cron` destapa `/api/cron/poll`. Consecuencia: crear/editar/borrar/publicar posts y disparar el cron **nunca requieren auth**.
|
||
|
||
---
|
||
|
||
## API Propia (route handlers)
|
||
|
||
| Ruta | Métodos | Auth | Función |
|
||
|------|---------|------|---------|
|
||
| `/api/auth/login` | POST | pública | Valida password → setea cookie `session` |
|
||
| `/api/auth/logout` | POST | pública | Borra cookie |
|
||
| `/api/health` | GET | pública | `{status:"ok", uptime}` — healthcheck |
|
||
| `/api/cover/[gid]` | GET | pública | Sirve portada cacheada (`Cache-Control: public, max-age=86400`); si falta, self-cachea desde `/api/proxy/galleries/{gid}/cover`; 404 si no hay |
|
||
| `/api/cron/poll` | GET | pública (bug) | Invoca `pollOnce()` → `{ok, newPosts}` |
|
||
| `/api/posts` | GET/POST | pública (bug) | GET: lista posts (`?published=1` filtra); POST: crea (409 si el gid existe) |
|
||
| `/api/posts/[id]` | GET/PATCH/DELETE | pública (bug) | GET detalle; PATCH `updatePost` (⚠️ SQLi); DELETE borra + cover |
|
||
| `/api/posts/[id]/publish` | POST | pública (bug) | `{publish: bool}` → `publishPost` |
|
||
| `/api/proxy/[...path]` | GET/POST/PUT/DELETE/PATCH | protegida* | Proxy inverso al pipeline (`API_BASE_URL` + `/` + subpath) |
|
||
| `/api/setup` | GET/POST | pública | GET: `{configured}`; POST: guarda allowlist `["API_BASE_URL","API_KEY","WEB_PASSWORD","WEBHOOK_SECRET"]` en `data/settings.json` ⚠️ sin auth |
|
||
| `/api/webhooks/manga` | POST | firma HMAC* | Crea/actualiza post desde el pipeline + cachea cover |
|
||
|
||
\* cuando `WEB_PASSWORD`/`WEBHOOK_SECRET` están configurados; si no, abiertos.
|
||
|
||
---
|
||
|
||
## Proxy (`src/app/api/proxy/[...path]/route.ts`)
|
||
|
||
- Target: `API_BASE = process.env.API_BASE_URL || "http://127.0.0.1:8080/api/v1"`.
|
||
- Autentica upstream con `X-API-Key: process.env.API_KEY` si está set.
|
||
- Mapeo: `/api/proxy/galleries/123?x=1` → `{API_BASE}/galleries/123?x=1` (query string se pasa tal cual).
|
||
- Reenvía body (blob) solo en métodos no-GET/HEAD; copia `Content-Type` de vuelta; descarta `content-encoding/content-length/transfer-encoding`.
|
||
- Errores upstream → `502 {error:{code:"proxy_error", message}}`.
|
||
- `GET /api/proxy/health` público (allowlist); el resto protegido por sesión (si `WEB_PASSWORD` set).
|
||
|
||
---
|
||
|
||
## Poller (`src/lib/poller.ts` + `src/instrumentation.ts`)
|
||
|
||
- `POLL_INTERVAL_MS = 60_000` — corre cada 1 minuto (más una vez al arranque).
|
||
- Arranque: `instrumentation.ts` → `register()` (solo `NEXT_RUNTIME === "nodejs"`) llama `initSettings()` + `startPoller()`.
|
||
- **`pollOnce()`:**
|
||
1. Lista `api.galleries.list("completed", 1, 100)` + `api.galleries.translated()` del pipeline.
|
||
2. Por cada gid: `api.galleries.summary(gid)`.
|
||
3. `cleanTitle()` + `slugify(cleanT, gid)`; sintetiza summary (`synopsis` o `generateSummary()`).
|
||
4. **Moderación:** `moderateTags()` (ver §Moderación) — tags prohibidos → cuarentena (`published: 0`) + `notifyQuarantine()` a Discord de moderación.
|
||
5. **Traducción de título:** `translateTitle()` → `title_es` (vía proxy free-ide local, deepseek-v4-flash-free con fallback big-pickle). Solo si no existe `title_es`.
|
||
6. **Auto-publica:** no existe → `createPost({...})`; existe → `updatePost`. Si pasó a tener tags dudosos → `publishPost(id, false)` (cuarentena).
|
||
7. `cacheCover(gid, "http://127.0.0.1:{PORT||3000}/api/proxy/galleries/{gid}/cover")`.
|
||
8. Posts limpios nuevos → `notifyNewPost()` (Discord de novedades).
|
||
|
||
## Traducción de títulos (`src/lib/translate.ts`)
|
||
|
||
- Traduce títulos de manga al español latinoamericano usando el **proxy free-ide local** (`FREE_IDE_PROXY_URL`, default `127.0.0.1:6446/v1`, key `FREE_IDE_PROXY_KEY`).
|
||
- Modelos: `deepseek-v4-flash-free` (primario) → `big-pickle` (fallback). `max_tokens: 200` para que el razonamiento no consuma toda la respuesta.
|
||
- Heurística `isProbablySpanish()`: no traduce lo que ya parece español (evita re-traducir nombres propios).
|
||
- `displayTitle(post)` (`src/lib/title.ts`): devuelve `title_es || title` — usarlo en TODA la UI pública (cards, home hero, página de post, RSS).
|
||
- Migración masiva de existentes: `node scripts/translate_existing.js` (con `LIMIT=n` para probar un lote).
|
||
|
||
## Moderación de contenido (`src/lib/moderation.ts`)
|
||
|
||
- **Capa 1 — tags explícitos:** `lolicon`, `shotacon`, `loli`, `shota`, `child`, `children`, `kids`, `minor` (regex de palabra completa — `hololive` NO cae).
|
||
- **Capa 2 — contexto:** combinaciones de tags que sugieren menores sin tag explícito (`small breasts + crying + defloration`, etc.).
|
||
- Posts dudosos → cuarentena (`published=0`) + aviso a Discord de moderación (`DISCORD_MODERATION_WEBHOOK_URL`) con links ✅ Aprobar / 🗑️ Rechazar.
|
||
- Decisión: `GET /api/review/[id]?action=approve|reject&token=DISCORD_MOD_TOKEN` (protegido por token, no sesión).
|
||
- Auditoría manual: `node scripts/audit_lolicon.js`.
|
||
|
||
> ⚠️ **BLOQUEANTE CONOCIDO:** el endpoint `/galleries` del pipeline solo lista las galleries del UrlQueue activo (~6), no las traducidas en `output/artifacts/` (~38 invisibles). El poller no puede descubrirlas. Solución: que `/galleries` escanee `output/artifacts/` o use `/galleries/translated`.
|
||
|
||
> ⚠️ **BUG self-fetch:** `cacheCover` fetchea `/api/proxy/...` (protegido por middleware); sin cookie de servidor, con `WEB_PASSWORD` set el fetch recibe redirect 307 a `/login` y **guarda HTML de login como portada**.
|
||
|
||
---
|
||
|
||
## Webhook (`src/app/api/webhooks/manga/route.ts`)
|
||
|
||
- **Solo POST.** Firma HMAC-SHA256 del raw body; header `x-signature-256` o `X-Signature-256`. Secret: `WEBHOOK_SECRET || FANSUB_WEBHOOK_SECRET`. **Sin secret configurado → verificación omitida, endpoint abierto.**
|
||
- **Payload:** `gid` (req), `title` (req), `tags[]`, `num_pages|pages`, `synopsis`, `url`, `source`, `title_jpn`, `artist`, `parody`, `cover_url`.
|
||
- **Acción:** no existe → `createPost({..., published: 1})`; existe → `updatePost`. Luego `cacheCover(...)` (mismo bug de self-fetch).
|
||
- Respuesta `{success, gid, post}`; 401 firma mala; 400 sin gid/title.
|
||
|
||
---
|
||
|
||
## Caché de Portadas (`src/lib/cover-cache.ts`)
|
||
|
||
- Storage: `process.env.COVERS_DIR || data/covers/`. Archivo por gid: `{gid}.{ext}` (sin hash).
|
||
- `cacheCover(gid, proxyUrl)`: fetch con timeout 15s → buffer a disco; falla silenciosa (`null`).
|
||
- `getCoverPath(gid)`: `readdirSync().filter(f => f.startsWith(gid + "."))`. `getCoverContentType`, `deleteCover`.
|
||
|
||
---
|
||
|
||
## CBZ (`src/lib/cbz.ts`) — solo cliente
|
||
|
||
- `loadPagesFromCbz(url, signal?)` → `{pages, pageCount}`: fetch → JSZip → filtra imágenes (`.jpg/.jpeg/.png/.webp/.gif/.bmp`) → ordena por primer número en el nombre → `URL.createObjectURL` por página.
|
||
- `revokePageUrls(urls)` libera los object URLs.
|
||
- No hay lectura de CBZ en servidor ni endpoints de páginas reales (plan: `/api/pages/[gid]`).
|
||
|
||
---
|
||
|
||
## Frontend — Grupos y Páginas
|
||
|
||
### `(public)` — server-side, sin login (lee SQLite directo)
|
||
- `/` — grid de posts publicados (`getAllPosts(true)`), `PostCard`, `grid-cols-2 sm:3 md:4 xl:5`.
|
||
- `/p/[slug]` — detalle de post: portada `/api/cover/{gid}`, título + `title_jpn`, badge "Antología/Tomo" si `isAnthology` (tags anthology/compilation/tankoubon o `num_pages >= 100`), `TagBadge` (artist/parody → `/tag/...`), sinopsis solo si `isRealSynopsis` (no empieza con `**`/`Fuente:` y >50 chars), "Más publicaciones" (4 compact). Botones: Leer Manga (`/gallery/[gid]/read` — **privado**), Ver en nhentai/e-hentai, Archivos (desktop).
|
||
- `/tag/[tag]` — posts por tag (`getPostsByTag`), `decodeURIComponent(tag)` ⚠️ sin try/catch.
|
||
|
||
### `(app)` — client-side, login requerido (SWR → `/api/proxy/*`)
|
||
- `/feed` — galleries del pipeline (`useGalleries(status, 1)` → SWR `/api/proxy/galleries?page=1&per_page=50&status=...`, refresh 30s/10s), `FilterBar` + `GalleryGrid` + skeletons.
|
||
- `/gallery/[gid]` — detalle: SWR `/summary` + `/artifacts`, `CoverImage`, tags, badge ESP, lista de archivos colapsable, botones Leer/Original/Eliminar.
|
||
- `/gallery/[gid]/read` — **lector**: modos `paged` (zoom 0.5–3, flechas, slider, tap zones, swipe >40px, precarga 2 págs) y `webtoon` (scroll continuo, lazy desde 5ª); controles auto-ocultos 3.5s; fullscreen (`f`); keyboard `←→`, `+/-/0`; cascada de fallback para páginas: `/pages` → `/summary` (num_pages) → `/artifacts` → HEAD `/cover` → error. Root `fixed inset-0 z-50 bg-[#09090b] text-white touch-none`.
|
||
- `/queue` — estado pipeline: SWR `/api/proxy/queue?status=...&per_page=100` + `/api/proxy/status` (refresh 10s), `SlotGauge`, `StatBox`, botón Retry.
|
||
- `/search` — `SearchForm`: POST `/api/proxy/search` `{query, source}` → grid con estado `blocked`; "Encolar" / "Encolar todo (N)" → POST `/api/proxy/galleries`.
|
||
- `/submit` — `SubmitForm`: textarea URLs → POST `/api/proxy/galleries` por línea.
|
||
- `/admin/posts` — CRUD posts: toggle publicar (POST `/api/posts/{id}/publish`), eliminar (confirm), links a `/p/{slug}` y `/gallery/{gid}`; editor `[id]`: PATCH `{title, summary, slug}`.
|
||
- Layout: `Sidebar` desktop fija (`w-56`, 5 links + Salir) + bottom-nav móvil (`h-14`, sin Salir); logo apunta a `/` (público).
|
||
|
||
### Standalone
|
||
- `/login` — password → POST `/api/auth/login` → `/feed`.
|
||
- `/setup` — primer arranque: GET `/api/setup` → si `configured` va a `/feed`; si no, 4 campos (`API_BASE_URL`, `API_KEY`, `WEB_PASSWORD` min 6, `WEBHOOK_SECRET`) → POST `/api/setup`.
|
||
|
||
### Hooks
|
||
`useGalleries(status, page)` (usado), `useGallery` (muerto), `useQueue`/`useQueueStats` (muertos), `useStatus`/`useSystemStatus` (muerto). QueuePage y GalleryDetailPage reimplementan SWR inline con fetcher duplicado.
|
||
|
||
---
|
||
|
||
## Estilos (Tailwind CSS 4)
|
||
|
||
- `@import "tailwindcss" source("../../src")` en `globals.css`. Sin CSS modules.
|
||
- **Tema oscuro exclusivo** (`html { color-scheme: dark }`): `--background:#09090b`, `--surface:#18181b`, `--border:#27272a`, `--foreground:#fafafa`, `--muted:#a1a1aa`, `--accent:#6366f1` (indigo), `--success:#22c55e`, `--warning:#eab308`, `--error:#ef4444` (+ variantes `-subtle`), `--radius:.5rem`.
|
||
- `@utility` custom: `card`, `card-hover`, `tag-pill`, `btn`, `btn-primary`, `btn-ghost`, `btn-danger`, `input`, `skeleton` (+ shimmer).
|
||
- Fuente **Inter** (`@font-face` woff2 de fonts.gstatic.com, fallback system-ui). Iconos lucide-react. Scrollbars finos.
|
||
- Hardcodeos menores: lector `bg-[#09090b]` fijo; badge ESP emerald fijo.
|
||
|
||
---
|
||
|
||
## Variables de Entorno (`.env` / `.env.example`)
|
||
|
||
| Variable | Default | Propósito |
|
||
|----------|---------|-----------|
|
||
| `API_BASE_URL` | `http://host.docker.internal:8080/api/v1` | Base URL del pipeline REST (bare-metal: `127.0.0.1`) |
|
||
| `API_KEY` | *(vacío)* | API key del pipeline (vacío = sin auth upstream) |
|
||
| `WEB_PASSWORD` | *(vacío)* | Password del panel admin (vacío = todo abierto) |
|
||
| `WEBHOOK_SECRET` | *(vacío)* | Validación HMAC del webhook |
|
||
| `DB_PATH` | `data/worst-scan.db` | Ruta SQLite |
|
||
| `COVERS_DIR` | `data/covers` | Directorio de portadas |
|
||
| `JWT_SECRET` | — | (usado por auth.ts si existe; si no, cae a `WEB_PASSWORD`) |
|
||
| `PORT` | 3000 | Puerto del server (Dockerfile/systemd) |
|
||
| `HOSTNAME` | 0.0.0.0 | Bind (Docker/systemd) |
|
||
|
||
---
|
||
|
||
## Despliegue
|
||
|
||
**Dos estrategias documentadas, MUTUAMENTE EXCLUYENTES:**
|
||
|
||
### A) Docker (agy.md — la real en producción)
|
||
- Multi-stage Node 22-alpine: `deps` (python3/make/g++ para better-sqlite3 + `npm ci`) → `builder` (`npm run build`) → `runner` (usuario `nextjs` uid 1001, `EXPOSE 3000`, `ENV PORT=3000 HOSTNAME=0.0.0.0 DB_PATH=/app/data/worst-scan.db COVERS_DIR=/app/data/covers`, `VOLUME /app/data`, HEALTHCHECK `wget /api/health`).
|
||
- Compose: `expose: 3000` (sin mapeo), `env_file: .env`, `extra_hosts: host.docker.internal:host-gateway`, red `caddy` (external), volumen `data:/app/data`, `restart: unless-stopped`.
|
||
- Caddy en contenedor `caddy-ingress` con auto-HTTPS para `manga.cbcren.online`; recarga: `docker exec caddy-ingress caddy reload --config /etc/caddy/Caddyfile`. Caddyfile canónico del host: `/home/ren/immich/Caddyfile`.
|
||
|
||
### B) systemd + Node directo (`install.sh` / `deploy/setup.sh`)
|
||
- Instala Node 22 + Caddy, `npm run build`, corre `.next/standalone/server.js` bajo systemd (`Type=exec`, `Restart=always`, `HOSTNAME=127.0.0.1`, `PORT=3000`).
|
||
- ⚠️ `install.sh` **desinstala Docker** a propósito — no conviven con la estrategia A.
|
||
- ⚠️ `deploy/setup.sh` escribe `WorkingDirectory=$APP_DIR/standalone` (falta `.next/`).
|
||
- ⚠️ **`deploy/setup.sh:22` contiene la contraseña de sudo hardcodeada en texto plano** — CRÍTICO, parametrizar.
|
||
|
||
### Scripts
|
||
- `npm start` → `scripts/start.js`: sincroniza `.next/static` → standalone, `NODE_ENV=production`, spawn `node server.js` con `PORT`/`HOSTNAME`.
|
||
- `install.sh`: swap 1GB si <512MB → git/curl → **Docker cleanup** → Node 22 (nodesource) → mirror npm si China → Caddy (cloudsmith) → clone `gitea.cbcren.online/renato97/worst-scan-web` (reset --hard) → `.env` (nunca sobreescribe) → build → systemd unit → bloque Caddy `:80` → health wait → resumen con `http://IP/setup`.
|
||
|
||
---
|
||
|
||
## Bugs Conocidos & Deuda Técnica
|
||
|
||
### 🔴 Seguridad
|
||
1. **SQL injection en `updatePost`** (`db.ts:148-170`) — nombres de columna del body interpolados sin whitelist; permite sobrescribir `published`/`published_at` y columnas arbitrarias.
|
||
2. **Middleware por prefijo** (`middleware.ts:23`) — `/api/posts*` y `/api/cron*` públicos en la práctica (PATCH/DELETE/publish/cron sin auth).
|
||
3. **Sin `WEB_PASSWORD` → app 100% abierta**, incluidas rutas admin.
|
||
4. **`/api/setup` sin auth** — cualquiera configura la app y fija secrets (guardados en texto plano en `data/settings.json`).
|
||
5. **Webhook sin `WEBHOOK_SECRET` → abierto** (crear/modificar posts arbitrarios).
|
||
6. **JWT fallback predecible** (`worst-scan-web-dev-secret`); si se usa `WEB_PASSWORD` como clave, quien la conozca forja tokens.
|
||
7. **Password de sudo hardcodeada** en `deploy/setup.sh:22`.
|
||
8. Logout no revoca el JWT (solo expira); sin refresh/rotación.
|
||
|
||
### 🐛 Funcionales
|
||
9. **~38 galerías invisibles** — `/galleries` del pipeline solo lista las del UrlQueue (~6); el poller no descubre las traducidas. Bloqueante para la web.
|
||
10. **Cover self-cache rota con auth** — fetch de servidor sin cookie recibe 307 → guarda HTML de login como portada.
|
||
11. **`createPost` publica todo por defecto** — no hay flujo de borrador real.
|
||
12. `getGidsNotInPosts()` es un **stub** (`return []`).
|
||
13. `ALTER TABLE posts ADD COLUMN url` en `migrate()` es **código muerto** (columna ya existe → siempre lanza y se traga).
|
||
14. **Enlaces rotos público→privado**: "Buscar" (`/search`) y "Leer Manga" (`/gallery/[gid]/read`) llevan a rutas con login.
|
||
15. **Bug de conteo en `SubmitForm`** — `"X de Y ok"` inflado (cuenta todo menos `"?"`); criterio de fila inconsistente con el contador.
|
||
16. **Hooks muertos**: `useGallery`, `useQueue`, `useQueueStats`, `useStatus` — sin uso; fetcher SWR duplicado en 6 archivos.
|
||
17. **Overlap bottom-nav móvil** — el layout `(app)` no compensa la nav fija (`p-0` sin `pb-14`).
|
||
18. **Logo del sidebar → `/`** (saca al usuario del panel al sitio público); **sin logout en móvil**.
|
||
19. **`decodeURIComponent(tag)` sin try/catch** → 500 con `%` literal en el tag.
|
||
20. **Sin paginación** — `per_page=50/100` fijos, sin UI de página pese a que la API devuelve `meta`.
|
||
21. Lector: zoom solo desktop; tap zones inertes en webtoon; `touch-none`+`touch-pan-y` pueden trabar scroll; título `g/{gid}` si falla summary.
|
||
22. `window.location.href = "/feed"` tras eliminar (recarga completa); errores silenciosos en editor de posts, retry y search.
|
||
|
||
### 🟡 Docs / Infra
|
||
23. `README.md` es boilerplate de create-next-app; `AGENTS.md` (viejo) documentaba el pipeline Python que no está en este repo — esta versión lo reemplaza.
|
||
24. Docker vs systemd contradictorios (`install.sh` desinstala Docker).
|
||
25. `.env.example` con paths Docker (`/app/data`, `host.docker.internal`) que en bare-metal hay que sobreescribir.
|
||
26. CORS `*` en la API del pipeline (lockear en producción).
|
||
27. `dominios-disponibles.md` desactualizado (`manga.cbcren.online` figura libre).
|
||
|
||
---
|
||
|
||
## Roadmap / Plan Pendiente (de `agy.md`, 7 pasos)
|
||
|
||
1. Columnas `pages_count`, `mobi_stored`, `cbz_path` en `posts`.
|
||
2. Nuevo `src/lib/gallery-store.ts` (downloadAndExtractCbz con JSZip, downloadMobi, getPagePath…).
|
||
3. Rewrite del poller (que descubra galleries traducidas).
|
||
4. Endpoints `/api/pages/*` y `/api/download/*/mobi`.
|
||
5. Mover lector a grupo `(reader)/` (fuera del layout admin).
|
||
6. 6 UI fixes (enlaces públicos, paginación, hooks, bottom-nav…).
|
||
7. Cover fallback a `pages/001.jpg`.
|
||
|
||
---
|
||
|
||
## What NOT to Add
|
||
|
||
- No CI/CD pipelines, no pre-commit hooks, no linter/formatter configs nuevos (seguir el espíritu del repo).
|
||
- No escribir raw SQL fuera de `db.ts`.
|
||
- No exponer secrets en texto plano (ni en scripts ni en `settings.json`).
|
||
- No nuevos entry points sin discusión previa.
|