Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions nodyx-core/src/migrations/131_octoguard_hold.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
-- OctoGuard 2, cran « Retenir » (CDC E4h, lot 2, D17).
-- Un contenu retenu n'est PAS écrit dans channel_messages / posts / threads :
-- il attend ici, invisible de tous sauf de son auteur et de l'équipe, et n'est
-- publié pour de vrai qu'à l'approbation. Aucune lecture existante (historique,
-- recherche, mentions, aperçus) n'a donc besoin d'être filtrée.

ALTER TABLE octoguard_automod_rules DROP CONSTRAINT IF EXISTS octoguard_automod_rules_action_check;
ALTER TABLE octoguard_automod_rules ADD CONSTRAINT octoguard_automod_rules_action_check
CHECK (action IN ('delete','warn','mute','ban_temp','notify_only','report_only','hold'));

CREATE TABLE IF NOT EXISTS octoguard_held (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- chat : un message ; post : une réponse ; thread : un nouveau fil ; post_edit : une modification
kind VARCHAR(16) NOT NULL CHECK (kind IN ('chat','post','thread','post_edit')),
author_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
channel_id UUID REFERENCES channels(id) ON DELETE CASCADE,
thread_id UUID REFERENCES threads(id) ON DELETE CASCADE,
post_id UUID REFERENCES posts(id) ON DELETE CASCADE,
category_id UUID REFERENCES categories(id) ON DELETE CASCADE,
-- Ce qu'il faut publier à l'approbation (contenu déjà nettoyé, titre, étiquettes, réponse à…)
payload JSONB NOT NULL,
rule_id UUID REFERENCES octoguard_automod_rules(id) ON DELETE SET NULL,
rule_name TEXT NOT NULL,
excerpt TEXT NOT NULL DEFAULT '',
status VARCHAR(10) NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','approved','refused','expired')),
decided_by UUID REFERENCES users(id) ON DELETE SET NULL,
decided_at TIMESTAMPTZ,
-- Identifiant du contenu publié à l'approbation (message, post ou fil)
published_id UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX IF NOT EXISTS idx_octoguard_held_pending ON octoguard_held (created_at) WHERE status = 'pending';
CREATE INDEX IF NOT EXISTS idx_octoguard_held_author ON octoguard_held (author_id, status);
11 changes: 8 additions & 3 deletions nodyx-core/src/routes/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,12 +143,17 @@ export default async function adminRoutes(app: FastifyInstance) {
}, async (request, reply) => {
const access = request.access!
// Dossiers en attente dans la file de signalements : le compteur du menu
// (dossiers ouverts + contenus retenus à relire, cran « Retenir »)
let open_reports: number | null = null
let held_pending: number | null = null
if (access.permissions.includes('moderate_members') || access.permissions.includes('manage_octoguard')) {
const { rows } = await db.query<{ n: number }>(`SELECT COUNT(DISTINCT (target_type, target_id))::int AS n FROM reports WHERE status = 'open'`)
open_reports = rows[0]?.n ?? 0
const { rows } = await db.query<{ n: number; h: number }>(
`SELECT (SELECT COUNT(DISTINCT (target_type, target_id)) FROM reports WHERE status = 'open')::int AS n,
(SELECT COUNT(*) FROM octoguard_held WHERE status = 'pending')::int AS h`)
held_pending = rows[0]?.h ?? 0
open_reports = (rows[0]?.n ?? 0) + held_pending
}
return reply.send({ role: access.role, permissions: access.permissions, open_reports })
return reply.send({ role: access.role, permissions: access.permissions, open_reports, held_pending })
})

// ── Dashboard stats ────────────────────────────────────────────────────────
Expand Down
35 changes: 30 additions & 5 deletions nodyx-core/src/routes/forums.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,9 @@ import { checkHtmlContent } from '../services/contentFilter'
import { io } from '../socket/io'
import { can, getAccess, communityOfThread, outranks, rankOf } from '../services/permissions'
import { isUserMuted } from '../services/octoguard'
import { moderateText, blockedBody } from '../services/octoguard/moderate'
import { moderateText, textForModeration, blockedBody, heldBody } from '../services/octoguard/moderate'
import { holdContent, holdRefusal } from '../services/octoguard/held'
import { fileAutoReport } from '../services/octoguard/autoReport'
import { toPlainText } from '../services/reports'

// Un titre de fil est du texte brut, jamais du HTML : checkHtmlContent() ne fait
// que SCANNER (mots interdits) sans jamais retirer de balises. Sans ce nettoyage,
Expand Down Expand Up @@ -274,9 +274,25 @@ app.get('/threads', {
}

// OctoGuard (forum) : examiné AVANT de créer quoi que ce soit
const ogThread = await moderateText(request.user!.userId, 'forum', [title, toPlainText(content)])
const ogThread = await moderateText(request.user!.userId, 'forum', [title, textForModeration(content)])
if (ogThread.blocked) return reply.code(422).send(blockedBody(ogThread))

// Cran « retenir » : le fil n'est PAS créé. Le contenu, nettoyé comme pour
// une publication, attend la relecture de l'équipe (CDC E4h, lot 2).
if (ogThread.hold) {
const rehostHeld = await rehostExternalImages(content)
const heldContent = sanitize(rehostHeld.html)
const heldCheck = checkHtmlContent(heldContent)
if (!heldCheck.ok) return reply.code(422).send({ error: heldCheck.reason, code: 'CONTENT_BLOCKED' })
const titleHeldCheck = checkHtmlContent(title)
if (!titleHeldCheck.ok) return reply.code(422).send({ error: titleHeldCheck.reason, code: 'CONTENT_BLOCKED' })
const held = await holdContent({
kind: 'thread', authorId: request.user!.userId, categoryId: category_id,
payload: { content: heldContent, title, tag_ids: tag_ids ?? [] }, rule: ogThread.hold,
})
return held.ok ? reply.code(202).send(heldBody(held.row)) : reply.code(422).send(holdRefusal(held.code))
}

const thread = await ThreadModel.create({
category_id,
author_id: request.user!.userId,
Expand Down Expand Up @@ -393,8 +409,12 @@ app.get('/threads', {
return reply.code(422).send({ error: replyContentCheck.reason, code: 'CONTENT_BLOCKED' })
}

const ogPost = await moderateText(userId, 'forum', [toPlainText(sanitized)])
const ogPost = await moderateText(userId, 'forum', [textForModeration(sanitized)])
if (ogPost.blocked) return reply.code(422).send(blockedBody(ogPost))
if (ogPost.hold) {
const held = await holdContent({ kind: 'post', authorId: userId, threadId: resolvedThreadId, payload: { content: sanitized }, rule: ogPost.hold })
return held.ok ? reply.code(202).send(heldBody(held.row)) : reply.code(422).send(holdRefusal(held.code))
}

const post = await PostModel.create({
thread_id: resolvedThreadId,
Expand Down Expand Up @@ -487,8 +507,13 @@ app.get('/threads', {

// La modification passe par OctoGuard comme la publication (sinon : publier
// propre, puis modifier pour contourner)
const ogEdit = await moderateText(userId, 'forum', [toPlainText(editSanitized)])
const ogEdit = await moderateText(userId, 'forum', [textForModeration(editSanitized)])
if (ogEdit.blocked) return reply.code(422).send(blockedBody(ogEdit))
// Modification retenue : l'ancien texte reste en ligne jusqu'à la décision
if (ogEdit.hold) {
const held = await holdContent({ kind: 'post_edit', authorId: userId, threadId: existing.thread_id, postId: id, payload: { content: editSanitized }, rule: ogEdit.hold })
return held.ok ? reply.code(202).send(heldBody(held.row)) : reply.code(422).send(holdRefusal(held.code))
}

const post = await PostModel.updateContent(id, editSanitized)
if (ogEdit.report) void fileAutoReport({ targetType: 'post', targetId: id, ruleName: ogEdit.report.rule_name, excerpt: ogEdit.report.excerpt })
Expand Down
64 changes: 62 additions & 2 deletions nodyx-core/src/routes/octoguard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { getInstanceCommunityId } from '../middleware/adminOnly'
import { runSandbox, ruleStats, ruleHits } from '../services/octoguard/sandbox'
import { BASE_PRESETS, BASE_PROFILE_VERSION, startingLevel, exportProfile, previewImport, applyImport, ProfileZ, type Profile } from '../services/octoguard/profile'
import { checkRuleParams } from '../services/octoguard/params'
import { listPending, countPending, approveHeld, refuseHeld, listMine, forAuthor, HELD_EXPIRY_HOURS } from '../services/octoguard/held'
import { resolveReportTarget, type ReportTargetType as ResolvableTarget } from '../services/reports'
import * as NotificationModel from '../models/notification'
import * as PostModel from '../models/post'
Expand All @@ -52,7 +53,7 @@ const Duration = z.object({
const RuleParams = z.object({}).passthrough()

const RuleType = z.enum(['regex', 'caps', 'link_domain', 'mention_spam', 'link_spam', 'keywords', 'flood', 'zalgo'])
const RuleAction = z.enum(['delete', 'warn', 'mute', 'ban_temp', 'notify_only', 'report_only'])
const RuleAction = z.enum(['delete', 'warn', 'mute', 'ban_temp', 'notify_only', 'report_only', 'hold'])

const Escalation = z.object({
warns_threshold: z.number().int().positive(),
Expand Down Expand Up @@ -175,7 +176,7 @@ async function logAdminAction(
// Qui peut quoi dans OctoGuard (CDC modération, E1) : régler OctoGuard =
// manage_octoguard ; les sourdines et les signalements sont du travail de
// modération des membres (moderate_members), ouvert aussi à manage_octoguard.
const MEMBER_MODERATION_ROUTES = /^\/api\/v1\/admin\/octoguard\/(mutes|mute-check|reports(?!\/settings))(\/|$)/
const MEMBER_MODERATION_ROUTES = /^\/api\/v1\/admin\/octoguard\/(mutes|mute-check|held|reports(?!\/settings))(\/|$)/
const guardMemberModeration = requireAnyPermission('moderate_members', 'manage_octoguard')
const guardOctoguard = requirePermission('manage_octoguard')

Expand Down Expand Up @@ -782,6 +783,53 @@ export async function octoguardAdminPlugin(app: FastifyInstance) {
})
})

// ─── Contenus retenus (cran « Retenir », CDC E4h lot 2) ────────────────────

app.get('/held', async (_request, reply) => {
const items = await listPending(200)
return reply.send({ held: items, count: await countPending(), expiry_hours: HELD_EXPIRY_HOURS })
})

app.post('/held/:id/:decision', {
preHandler: [rateLimit],
}, async (request, reply) => {
const { id, decision } = request.params as { id: string; decision: string }
if (!/^[0-9a-f-]{36}$/i.test(id) || (decision !== 'approve' && decision !== 'refuse')) {
return reply.code(400).send({ error: 'Invalid request', code: 'INVALID_REQUEST' })
}
const actor = request.user!
const { rows } = await db.query<{ author_id: string; status: string }>(
`SELECT author_id, status FROM octoguard_held WHERE id = $1`, [id]
)
const h = rows[0]
if (!h) return reply.code(404).send({ error: 'Not found', code: 'NOT_FOUND' })
if (h.status !== 'pending') return reply.code(409).send({ error: 'Already decided', code: 'ALREADY_DECIDED' })
// On ne relit pas son propre contenu
if (h.author_id === actor.userId) {
return reply.code(403).send({ error: 'You cannot review your own content', code: 'CANNOT_REVIEW_SELF' })
}
// Même garde-fou de rang que les signalements : pas de jugement d'un pair
const access = await getInstanceAccess(actor.userId)
if (access.rank < rankOf('admin')) {
const author = await getInstanceAccess(h.author_id)
if (!outranks(access, author.role)) {
return reply.code(403).send({ error: 'Cannot act on a member of equal or higher rank', code: 'RANK_TOO_LOW' })
}
}
const who = { id: actor.userId, username: actor.username }
const res = decision === 'approve' ? await approveHeld(id, who) : await refuseHeld(id, who)
if (!res.ok) {
const why: Record<string, string> = {
ALREADY_DECIDED: 'Already decided',
AUTHOR_BANNED: 'The author has been banned since: refuse this content',
THREAD_LOCKED: 'The thread has been locked since: refuse this content',
PUBLISH_FAILED: 'The channel or thread no longer exists',
}
return reply.code(409).send({ error: why[res.code], code: res.code })
}
return reply.send({ ok: true, published_id: res.published_id })
})

// ─── Reports admin (Module 7) ─────────────────────────────────────────────

app.get('/reports', async (request, reply) => {
Expand Down Expand Up @@ -946,6 +994,18 @@ export async function octoguardAdminPlugin(app: FastifyInstance) {
export async function reportsPublicPlugin(app: FastifyInstance) {
app.addHook('preHandler', requireAuth)

// Mes contenus retenus, pour les afficher « en attente de validation » là
// où je les ai écrits. Jamais le nom de la règle (D20).
app.get('/held/mine', { preHandler: [rateLimit] }, async (request, reply) => {
const q = request.query as { channel_id?: string; thread_id?: string }
const uuid = /^[0-9a-f-]{36}$/i
if ((q.channel_id && !uuid.test(q.channel_id)) || (q.thread_id && !uuid.test(q.thread_id))) {
return reply.code(400).send({ error: 'Invalid id', code: 'INVALID_REQUEST' })
}
const rows = await listMine(request.user!.userId, { channelId: q.channel_id, threadId: q.thread_id })
return reply.send({ held: rows.map(forAuthor), expiry_hours: HELD_EXPIRY_HOURS })
})

app.post('/', {
preHandler: [rateLimit, validate({ body: CreateReportBody })],
}, async (request, reply) => {
Expand Down
7 changes: 3 additions & 4 deletions nodyx-core/src/routes/wiki.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,7 @@
* - Delete: admin or moderator only
*/

import { moderateText, blockedBody } from '../services/octoguard/moderate'
import { toPlainText } from '../services/reports'
import { moderateText, textForModeration, blockedBody } from '../services/octoguard/moderate'
import { FastifyInstance } from 'fastify'
import { z } from 'zod'
import { db } from '../config/database.js'
Expand Down Expand Up @@ -146,7 +145,7 @@ export default async function wikiRoutes(app: FastifyInstance) {
}

// OctoGuard (wiki) : titre et contenu. Sans dossier (une page wiki n'est pas une cible de signalement).
const ogWiki = await moderateText(user.userId, 'wiki', [body.title, body.excerpt, toPlainText(body.content)])
const ogWiki = await moderateText(user.userId, 'wiki', [body.title, body.excerpt, textForModeration(body.content)])
if (ogWiki.blocked) return reply.code(422).send(blockedBody(ogWiki))

const slug = generateWikiSlug(body.title)
Expand Down Expand Up @@ -189,7 +188,7 @@ export default async function wikiRoutes(app: FastifyInstance) {
return reply.code(403).send({ error: 'Permission insuffisante pour modifier cette page.' })
}

const ogWikiEdit = await moderateText(user.userId, 'wiki', [body.title, body.excerpt, body.content ? toPlainText(body.content) : undefined])
const ogWikiEdit = await moderateText(user.userId, 'wiki', [body.title, body.excerpt, body.content ? textForModeration(body.content) : undefined])
if (ogWikiEdit.blocked) return reply.code(422).send(blockedBody(ogWikiEdit))

const setParts: string[] = ['updated_at = NOW()', 'editor_id = $1']
Expand Down
Loading
Loading