feat: moderacion lolicon - cuarentena + decision desde Discord (webhook links aprobar/rechazar)
This commit is contained in:
@@ -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(
|
||||
`<!DOCTYPE html><html><body style="font-family:sans-serif;background:#09090b;color:#22c55e;display:flex;align-items:center;justify-content:center;height:100vh">
|
||||
<div style="text-align:center"><h1>✅ Aprobado y publicado</h1><p>${escapeHtml(post.title)}</p>
|
||||
<a href="https://worstscan.xyz/p/${post.slug}" style="color:#6366f1">Ver en la web →</a></div></body></html>`,
|
||||
{ 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(
|
||||
`<!DOCTYPE html><html><body style="font-family:sans-serif;background:#09090b;color:#ef4444;display:flex;align-items:center;justify-content:center;height:100vh">
|
||||
<div style="text-align:center"><h1>🗑️ Rechazado y eliminado</h1><p>El manga fue borrado definitivamente.</p></div></body></html>`,
|
||||
{ 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, ">").replace(/"/g, """)
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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<string, unknown>[]
|
||||
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[] {
|
||||
|
||||
@@ -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<boolean> {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
+49
-15
@@ -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<typeof setInterval> | 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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user