# 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: , 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. **Auto-publica todo:** no existe → `createPost({..., published: 1})`; existe → `updatePost`. 5. `cacheCover(gid, "http://127.0.0.1:{PORT||3000}/api/proxy/galleries/{gid}/cover")`. > ⚠️ **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.