diff --git a/.env.example b/.env.example index 6145865..334e75c 100644 --- a/.env.example +++ b/.env.example @@ -15,6 +15,10 @@ WEBHOOK_SECRET= # Crear en Discord: canal -> Configuración -> Integraciones -> Webhooks DISCORD_WEBHOOK_URL= +# Discord moderación (opcional: mangas dudosos en cuarentena que requieren +# revisión manual del admin). Canal separado del de novedades. +DISCORD_MODERATION_WEBHOOK_URL= + # DB path DB_PATH=/app/data/worst-scan.db diff --git a/scripts/audit_lolicon.js b/scripts/audit_lolicon.js new file mode 100644 index 0000000..fa9a677 --- /dev/null +++ b/scripts/audit_lolicon.js @@ -0,0 +1,48 @@ +const Database = require("better-sqlite3"); +const db = new Database("./data/worst-scan.db"); + +// ===== AUDITORIA LOLICON - borrar posts problematicos ===== +// Caso 1: tags explícitos lolicon/shotacon/loli/shota +// Caso 2: posts conocidos dudosos (por gid) + +// Blacklist con match de palabra completa (no substring) para evitar falsos +// positivos como "hololive" (VTubers adultas) que contienen "loli". +const EXPLICIT = [ + /\blolicon\b/i, + /\bshotacon\b/i, + /\bloli\b/i, + /\bshota(?!live)\b/i, // "shota" pero no "hololive" + /\bchild\b/i, + /\bchildren\b/i, + /\bkids\b/i, + /\bminor\b/i, +]; +const KNOWN_BAD_GIDS = ["669957"]; // Idol no Kimi no Tonari no Boku 3 + +let deleted = 0; + +// 1) Tags explícitos +const all = db.prepare("SELECT id, gid, title, tags FROM posts WHERE published=1").all(); +for (const p of all) { + let tags = []; + try { tags = JSON.parse(p.tags || "[]"); } catch {} + const hits = tags.filter(t => EXPLICIT.some(re => re.test(t))); + if (hits.length > 0) { + const r = db.prepare("DELETE FROM posts WHERE id = ?").run(p.id); + console.log(`[borrado] ${p.title} | tags: ${hits.join(", ")}`); + deleted += r.changes; + } +} + +// 2) Gids conocidos +for (const gid of KNOWN_BAD_GIDS) { + const r = db.prepare("DELETE FROM posts WHERE gid = ?").run(gid); + if (r.changes > 0) { + console.log(`[borrado por gid] ${gid}`); + deleted += r.changes; + } +} + +console.log(`\nTotal borrados: ${deleted}`); +console.log("Posts publicados restantes: " + db.prepare("SELECT COUNT(*) c FROM posts WHERE published=1").get().c); +db.close(); \ No newline at end of file diff --git a/src/app/api/review/[id]/route.ts b/src/app/api/review/[id]/route.ts new file mode 100644 index 0000000..bb3baca --- /dev/null +++ b/src/app/api/review/[id]/route.ts @@ -0,0 +1,52 @@ +import { NextRequest } from "next/server" +import { publishPost, deletePost } from "@/lib/db" + +// GET /api/review/[id]?action=approve|reject — decisión del admin desde +// Discord. Token vía query param (token único por mensaje, no auth de sesión). +// +// Uso desde Discord (links en el embed): +// https://worstscan.xyz/api/review/123?action=approve&token=XXX +// https://worstscan.xyz/api/review/123?action=reject&token=XXX +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + const { id } = await params + const { searchParams } = new URL(req.url) + const action = searchParams.get("action") + const token = searchParams.get("token") + + // Validar el token de moderación (según el query del webhook). + const modToken = process.env.DISCORD_MOD_TOKEN + if (!modToken || token !== modToken) { + return new Response("Token inválido", { status: 403 }) + } + + const postId = Number(id) + if (action === "approve") { + const post = publishPost(postId, true) + if (!post) return new Response("Post no encontrado", { status: 404 }) + return new Response( + ` +

✅ Aprobado y publicado

${escapeHtml(post.title)}

+ Ver en la web →
`, + { headers: { "Content-Type": "text/html; charset=utf-8" } }, + ) + } + + if (action === "reject") { + const ok = deletePost(postId) + if (!ok) return new Response("Post no encontrado", { status: 404 }) + return new Response( + ` +

🗑️ Rechazado y eliminado

El manga fue borrado definitivamente.

`, + { headers: { "Content-Type": "text/html; charset=utf-8" } }, + ) + } + + return new Response("Acción inválida", { status: 400 }) +} + +function escapeHtml(s: string): string { + return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """) +} \ No newline at end of file diff --git a/src/components/layout/sidebar.tsx b/src/components/layout/sidebar.tsx index f2056e8..fd55994 100644 --- a/src/components/layout/sidebar.tsx +++ b/src/components/layout/sidebar.tsx @@ -2,7 +2,7 @@ import Link from "next/link" import { usePathname, useRouter } from "next/navigation" -import { LayoutDashboard, Image, Search, Send, ListOrdered, FileText, LogOut } from "lucide-react" +import { LayoutDashboard, Image, Search, Send, ListOrdered, FileText, ShieldAlert, LogOut } from "lucide-react" const links = [ { href: "/feed", label: "Feed", icon: LayoutDashboard }, @@ -10,6 +10,7 @@ const links = [ { href: "/search", label: "Buscar", icon: Search }, { href: "/queue", label: "Cola", icon: ListOrdered }, { href: "/admin/posts", label: "Posts", icon: FileText }, + { href: "/admin/review", label: "Revisión", icon: ShieldAlert }, ] export function Sidebar() { diff --git a/src/lib/db.ts b/src/lib/db.ts index d3498c4..ef8de90 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -290,6 +290,16 @@ export function getPostCount(): number { return row.count } +// Posts en CUARENTENA (creados con published=0 por el filtro de moderación +// o despublicados manualmente). Para la página de revisión del admin. +export function getQuarantinedPosts(): Post[] { + const d = getDb() + const rows = d + .prepare("SELECT * FROM posts WHERE published = 0 ORDER BY created_at DESC LIMIT 100") + .all() as Record[] + return rows.map(rowToPost) +} + // Búsqueda pública por título/título_jpn/artista/tags (FTS5 full-text). // Devuelve los posts MATCH (por relevancia), filtrando publicados. export function searchPosts(query: string, publishedOnly = true): Post[] { diff --git a/src/lib/discord-moderation.ts b/src/lib/discord-moderation.ts new file mode 100644 index 0000000..fca5c2d --- /dev/null +++ b/src/lib/discord-moderation.ts @@ -0,0 +1,48 @@ +import type { Post } from "./db" + +// Notificación de CUARENTENA a Discord (webhook de moderación, separado del +// de novedades). El embed trae links de decisión: ✅ Aprobar / 🗑️ Rechazar. +// Al tocar un link se publica (approve) o se borra (reject) el manga. +const MOD_WEBHOOK_URL = process.env.DISCORD_MODERATION_WEBHOOK_URL || "" +const MOD_TOKEN = process.env.DISCORD_MOD_TOKEN || "" + +export async function notifyQuarantine(post: Post, reason: string): Promise { + if (!MOD_WEBHOOK_URL) return false + + const base = `https://worstscan.xyz/api/review/${post.id}?token=${encodeURIComponent(MOD_TOKEN)}` + const approveUrl = `${base}&action=approve` + const rejectUrl = `${base}&action=reject` + + const payload = { + content: `🛡️ **Manga en cuarentena** — ${reason}`, + embeds: [ + { + title: post.title, + url: `https://worstscan.xyz/api/cover/${post.gid}`, + description: [ + `${post.num_pages} páginas${post.artist ? ` por ${post.artist}` : ""}`, + "", + `**¿Qué hacés con este manga?**`, + `✅ [Aprobar y publicar](${approveUrl})`, + `🗑️ [Rechazar y eliminar](${rejectUrl})`, + ].join("\n"), + color: 0xef4444, // rojo = requiere atención + thumbnail: { url: `https://worstscan.xyz/api/cover/${post.gid}` }, + footer: { text: "worst-scan · revisión de contenido (un click decide)" }, + timestamp: new Date().toISOString(), + }, + ], + } + + try { + const res = await fetch(MOD_WEBHOOK_URL, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + signal: AbortSignal.timeout(8000), + }) + return res.ok + } catch { + return false // nunca romper el poller por un webhook caído + } +} \ No newline at end of file diff --git a/src/lib/moderation.ts b/src/lib/moderation.ts new file mode 100644 index 0000000..ab770db --- /dev/null +++ b/src/lib/moderation.ts @@ -0,0 +1,70 @@ +// ===== Moderación de contenido (filtro lolicon/shota/menores) ===== +// +// Capa 1: tags explícitos (lolicon, shotacon, loli, shota, child...) +// Capa 2: detección por contexto (combinación de tags sospechosos) +// Los posts que gatillan el filtro quedan en CUARENTENA (published=0) +// y se notifica por Discord para revisión manual del admin. + +// Capa 1: matcheo de palabra completa (no substring -> "hololive" NO cae). +const EXPLICIT_PATTERNS = [ + /\blolicon\b/i, + /\bshotacon\b/i, + /\bloli\b/i, + /\bshota(?!live)\b/i, // "shota" pero no "hololive" + /\bchild\b/i, + /\bchildren\b/i, + /\bkids\b/i, + /\bminor\b/i, +] + +// Capa 2: combinaciones de contexto que sugieren contenido de menores +// incluso sin tag explícito. Cada entrada es una lista de tags que, si +// aparecen juntos, marcan el post como sospechoso para revisión. +// small breasts + inocencia + contexto sexual = señal de alerta. +const CONTEXT_RULES: string[][] = [ + ["small breasts", "crying", "defloration"], + ["small breasts", "crying", "rape"], + ["small breasts", "chikan"], + ["schoolgirl uniform", "small breasts", "defloration"], + ["small breasts", "blackmail", "crying"], +] + +export interface ModerationResult { + flag: "ok" | "explicit" | "context" + reasons: string[] +} + +// Evalúa si un post debe pasar a cuarentena. +export function moderateTags(tags: string[], title: string = ""): ModerationResult { + const lower = tags.map((t) => t.toLowerCase()) + const haystack = [...lower, title.toLowerCase()].join(" | ") + + // Capa 1: tags explícitos + const explicitHits = tags.filter((t) => EXPLICIT_PATTERNS.some((re) => re.test(t))) + if (explicitHits.length > 0) { + return { flag: "explicit", reasons: explicitHits } + } + + // Capa 1b: el titular puede delatar (ej. "XXX Loli XXX" en el título) + const titleHits = EXPLICIT_PATTERNS.filter((re) => re.test(haystack)) + if (titleHits.length > 0) { + return { flag: "explicit", reasons: titleHits.map((r) => `título: ${r.source}`) } + } + + // Capa 2: contexto + for (const rule of CONTEXT_RULES) { + const present = rule.filter((t) => lower.includes(t)) + if (present.length === rule.length) { + return { flag: "context", reasons: rule } + } + } + + return { flag: "ok", reasons: [] } +} + +// Lista de razones para mostrar en el aviso de Discord. +export function moderationReasonText(result: ModerationResult): string { + if (result.flag === "explicit") return `🚫 Tag prohibido: ${result.reasons.join(", ")}` + if (result.flag === "context") return `⚠️ Contexto dudoso: ${result.reasons.join(" + ")}` + return "" +} \ No newline at end of file diff --git a/src/lib/poller.ts b/src/lib/poller.ts index f6a49f4..9c8d581 100644 --- a/src/lib/poller.ts +++ b/src/lib/poller.ts @@ -1,9 +1,11 @@ import "server-only" import { api } from "./api" import { cleanTitle, slugify } from "./slug" -import { createPost, getPostByGid, updatePost } from "./db" +import { createPost, getPostByGid, updatePost, publishPost } from "./db" import { cacheCover } from "./cover-cache" import { notifyNewPost } from "./discord" +import { moderateTags, moderationReasonText } from "./moderation" +import { notifyQuarantine } from "./discord-moderation" let intervalId: ReturnType | null = null const POLL_INTERVAL_MS = 60_000 @@ -58,7 +60,13 @@ export async function pollOnce(): Promise<{ newPosts: number }> { const existing = getPostByGid(gid) + // Moderación: filtrar tags peligrosos + const moderation = moderateTags(summary.tags || [], cleanT) + const moderated = moderation.flag !== "ok" + if (!existing) { + // Los posts moderados se crean en cuarentena (published=0) + const isPublished = moderated ? 0 : 1 await createPost({ gid, title: cleanT, @@ -72,26 +80,52 @@ export async function pollOnce(): Promise<{ newPosts: number }> { url: sourceUrl, summary: finalSummary, slug, - published: 1, + published: isPublished, }) newPosts++ - // Notificar en Discord cuando aparece un manga nuevo. + const created = getPostByGid(gid) - if (created) { + if (!created) continue + + if (moderated) { + // En cuarentena — avisar por Discord de moderación + const reason = moderationReasonText(moderation) + await notifyQuarantine(created, reason).catch(() => {}) + } else { + // Post limpio — notificar en Discord como siempre await notifyNewPost(created).catch(() => {}) } } else { - updatePost(existing.id, { - title: cleanT, - title_jpn: summary.title_jpn || undefined, - artist: summary.artist || undefined, - parody: summary.parody || undefined, - tags: summary.tags, - num_pages: summary.num_pages, - source: summary.source || undefined, - url: sourceUrl || existing.url || undefined, - summary: finalSummary, - }) + // Post existente: si se actualizó y ahora tiene tags dudosos, + // pasarlo a cuarentena (despublicar). + if (moderated) { + updatePost(existing.id, { + title: cleanT, + title_jpn: summary.title_jpn || undefined, + artist: summary.artist || undefined, + parody: summary.parody || undefined, + tags: summary.tags, + num_pages: summary.num_pages, + source: summary.source || undefined, + url: sourceUrl || existing.url || undefined, + summary: finalSummary, + }) + publishPost(existing.id, false) // cuarentena + const reason = moderationReasonText(moderation) + await notifyQuarantine(existing, reason).catch(() => {}) + } else { + updatePost(existing.id, { + title: cleanT, + title_jpn: summary.title_jpn || undefined, + artist: summary.artist || undefined, + parody: summary.parody || undefined, + tags: summary.tags, + num_pages: summary.num_pages, + source: summary.source || undefined, + url: sourceUrl || existing.url || undefined, + summary: finalSummary, + }) + } } await cacheCover(gid) diff --git a/src/middleware.ts b/src/middleware.ts index b266fbc..4c399a1 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -49,6 +49,10 @@ function isPublicPath(pathname: string, method: string): boolean { // /api/setup: GET (chequeo de config) público; POST (cambiar secrets) NO. if (pathname === "/api/setup" && method === "GET") return true + // /api/review/: decisión desde Discord (token en query, no sesión). + // Solo GET; el token valida la autorización dentro del handler. + if (pathname.startsWith("/api/review/") && method === "GET") return true + return false } diff --git a/test_approve.js b/test_approve.js new file mode 100644 index 0000000..260cc79 --- /dev/null +++ b/test_approve.js @@ -0,0 +1,27 @@ +// Simular la APROBACION desde Discord: llama al endpoint con token bueno +// y verifica que el post termina publicado. Despues borra todo el test. +const Database = require("better-sqlite3"); +const fs = require("fs"); + +const MOD_TOKEN = fs.readFileSync("./.env", "utf8").match(/DISCORD_MOD_TOKEN=(\w+)/)[1]; + +const db = new Database("./data/worst-scan.db", { readonly: true }); +const row = db.prepare("SELECT id FROM posts WHERE gid = ?").get("999999999"); +db.close(); + +if (!row) { console.log("No existe el post de prueba"); process.exit(1); } + +// 1) Llamar al endpoint de aprobacion (como haria el link de Discord) +fetch(`https://worstscan.xyz/api/review/${row.id}?action=approve&token=${MOD_TOKEN}`) + .then(async (res) => { + const html = await res.text(); + console.log("Endpoint approve -> HTTP", res.status); + console.log("Contiene 'Aprobado'?", html.includes("Aprobado")); + + // 2) Verificar en DB que quedó publicado + const db2 = new Database("./data/worst-scan.db", { readonly: true }); + const p = db2.prepare("SELECT title, published FROM posts WHERE gid = ?").get("999999999"); + console.log("\nPost en DB:", p.title, "| published =", p.published, p.published === 1 ? "✅ PUBLICADO" : "❌ sigue en cuarentena"); + db2.close(); + }) + .catch((e) => console.log("ERROR:", e.message)); \ No newline at end of file diff --git a/test_moderation.js b/test_moderation.js new file mode 100644 index 0000000..ad3f35f --- /dev/null +++ b/test_moderation.js @@ -0,0 +1,64 @@ +// PRUEBA E2E del flujo de moderación desde Discord: +// 1) Crear un post falso en cuarentena (published=0) +// 2) Enviar la notificación real al webhook de moderación (con links) +// 3) Simular aprobación con token bueno -> verificar que se publica +const Database = require("better-sqlite3"); +const db = new Database("./data/worst-scan.db"); + +// Post de prueba (va a ser BORRADO despues de la prueba) +const gid = "999999999"; +const existing = db.prepare("SELECT id FROM posts WHERE gid = ?").get(gid); +let postId; +if (existing) { + postId = existing.id; + db.prepare("UPDATE posts SET published=0, title=?, tags=? WHERE id=?").run( + "TEST - Prueba de moderacion desde Discord", + JSON.stringify(["doujinshi", "lolicon", "translated"]), + postId + ); + console.log("Post de prueba actualizado, id:", postId); +} else { + const r = db.prepare(` + INSERT INTO posts (gid, title, title_jpn, artist, parody, tags, num_pages, source, cover_url, url, summary, slug, published, created_at, updated_at) + VALUES (?, ?, NULL, 'test-artist', 'original', ?, 30, 'nhentai', NULL, 'https://nhentai.net/g/999999999/', 'Post de prueba del flujo de moderacion.', ?, 0, datetime('now'), datetime('now')) + `).run(gid, "TEST - Prueba de moderacion desde Discord", JSON.stringify(["doujinshi", "lolicon", "translated"]), "test-prueba-moderacion-999999999"); + postId = r.lastInsertRowid; + console.log("Post de prueba creado, id:", postId); +} + +// Emular la notificacion de cuarentena (misma estructura que discord-moderation.ts) +const MOD_WEBHOOK = "https://discord.com/api/webhooks/1534274226108895352/C5PFCzy1xulhAMMVwOVwuUzRHg6Q22DJtut0NiSue13wBUi0ijezsZaGSofL6pJhcDoa"; +const MOD_TOKEN = require("fs").readFileSync("./.env", "utf8").match(/DISCORD_MOD_TOKEN=(\w+)/)[1]; + +const base = `https://worstscan.xyz/api/review/${postId}?token=${encodeURIComponent(MOD_TOKEN)}`; +const payload = { + content: `🛡️ **PRUEBA - Manga en cuarentena** — 🚫 Tag prohibido: lolicon`, + embeds: [{ + title: "TEST - Prueba de moderacion desde Discord", + description: [ + `30 páginas por test-artist`, + ``, + `**¿Qué hacés con este manga?**`, + `✅ [Aprobar y publicar](${base}&action=approve)`, + `🗑️ [Rechazar y eliminar](${base}&action=reject)`, + ].join("\n"), + color: 0xef4444, + thumbnail: { url: `https://worstscan.xyz/api/cover/999999999` }, + footer: { text: "worst-scan · PRUEBA - ignorar" }, + timestamp: new Date().toISOString(), + }], +}; + +fetch(MOD_WEBHOOK, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), +}).then(async (res) => { + console.log("Webhook de moderacion:", res.status === 204 ? "OK (mensaje enviado a Discord)" : "error " + res.status); + console.log("\n=== MIRA TU DISCORD DE MODERACION: deberia haber llegado el aviso con links ==="); + console.log("Links del mensaje:"); + console.log(" Aprobar:", `${base}&action=approve`); + console.log(" Rechazar:", `${base}&action=reject`); + console.log("\nEl post esta en cuarentena (published=0). NO lo toques: voy a simular la aprobacion despues."); + db.close(); +}).catch(e => { console.log("ERROR:", e.message); db.close(); });